Repository: prometheus/alertmanager Branch: main Commit: 5899c15562bc Files: 593 Total size: 3.0 MB Directory structure: gitextract_cu_vryvw/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── ci.yml │ ├── container_description.yml │ ├── mixin.yml │ ├── publish.yml │ ├── release.yml │ ├── stale.yml │ └── ui-ci.yml ├── .gitignore ├── .golangci.yml ├── .promu.yml ├── .yamllint ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── COPYRIGHT.txt ├── Dockerfile ├── LICENSE ├── MAINTAINERS.md ├── Makefile ├── Makefile.common ├── NOTICE ├── Procfile ├── README.md ├── RELEASE.md ├── SECURITY.md ├── VERSION ├── alert/ │ ├── alert.go │ ├── alert_test.go │ ├── state.go │ ├── status.go │ ├── validate.go │ └── validate_test.go ├── api/ │ ├── api.go │ ├── metrics/ │ │ └── metrics.go │ ├── v1_deprecation_router.go │ └── v2/ │ ├── api.go │ ├── api_test.go │ ├── client/ │ │ ├── alert/ │ │ │ ├── alert_client.go │ │ │ ├── get_alerts_parameters.go │ │ │ ├── get_alerts_responses.go │ │ │ ├── post_alerts_parameters.go │ │ │ └── post_alerts_responses.go │ │ ├── alertgroup/ │ │ │ ├── alertgroup_client.go │ │ │ ├── get_alert_groups_parameters.go │ │ │ └── get_alert_groups_responses.go │ │ ├── alertmanager_api_client.go │ │ ├── general/ │ │ │ ├── general_client.go │ │ │ ├── get_status_parameters.go │ │ │ └── get_status_responses.go │ │ ├── receiver/ │ │ │ ├── get_receivers_parameters.go │ │ │ ├── get_receivers_responses.go │ │ │ └── receiver_client.go │ │ └── silence/ │ │ ├── delete_silence_parameters.go │ │ ├── delete_silence_responses.go │ │ ├── get_silence_parameters.go │ │ ├── get_silence_responses.go │ │ ├── get_silences_parameters.go │ │ ├── get_silences_responses.go │ │ ├── post_silences_parameters.go │ │ ├── post_silences_responses.go │ │ └── silence_client.go │ ├── compat.go │ ├── models/ │ │ ├── alert.go │ │ ├── alert_group.go │ │ ├── alert_groups.go │ │ ├── alert_status.go │ │ ├── alertmanager_config.go │ │ ├── alertmanager_status.go │ │ ├── cluster_status.go │ │ ├── gettable_alert.go │ │ ├── gettable_alerts.go │ │ ├── gettable_silence.go │ │ ├── gettable_silences.go │ │ ├── label_set.go │ │ ├── matcher.go │ │ ├── matchers.go │ │ ├── peer_status.go │ │ ├── postable_alert.go │ │ ├── postable_alerts.go │ │ ├── postable_silence.go │ │ ├── receiver.go │ │ ├── silence.go │ │ ├── silence_status.go │ │ └── version_info.go │ ├── openapi.yaml │ ├── restapi/ │ │ ├── configure_alertmanager.go │ │ ├── doc.go │ │ ├── embedded_spec.go │ │ ├── operations/ │ │ │ ├── alert/ │ │ │ │ ├── get_alerts.go │ │ │ │ ├── get_alerts_parameters.go │ │ │ │ ├── get_alerts_responses.go │ │ │ │ ├── get_alerts_urlbuilder.go │ │ │ │ ├── post_alerts.go │ │ │ │ ├── post_alerts_parameters.go │ │ │ │ ├── post_alerts_responses.go │ │ │ │ └── post_alerts_urlbuilder.go │ │ │ ├── alertgroup/ │ │ │ │ ├── get_alert_groups.go │ │ │ │ ├── get_alert_groups_parameters.go │ │ │ │ ├── get_alert_groups_responses.go │ │ │ │ └── get_alert_groups_urlbuilder.go │ │ │ ├── alertmanager_api.go │ │ │ ├── general/ │ │ │ │ ├── get_status.go │ │ │ │ ├── get_status_parameters.go │ │ │ │ ├── get_status_responses.go │ │ │ │ └── get_status_urlbuilder.go │ │ │ ├── receiver/ │ │ │ │ ├── get_receivers.go │ │ │ │ ├── get_receivers_parameters.go │ │ │ │ ├── get_receivers_responses.go │ │ │ │ └── get_receivers_urlbuilder.go │ │ │ └── silence/ │ │ │ ├── delete_silence.go │ │ │ ├── delete_silence_parameters.go │ │ │ ├── delete_silence_responses.go │ │ │ ├── delete_silence_urlbuilder.go │ │ │ ├── get_silence.go │ │ │ ├── get_silence_parameters.go │ │ │ ├── get_silence_responses.go │ │ │ ├── get_silence_urlbuilder.go │ │ │ ├── get_silences.go │ │ │ ├── get_silences_parameters.go │ │ │ ├── get_silences_responses.go │ │ │ ├── get_silences_urlbuilder.go │ │ │ ├── post_silences.go │ │ │ ├── post_silences_parameters.go │ │ │ ├── post_silences_responses.go │ │ │ └── post_silences_urlbuilder.go │ │ └── server.go │ └── testing.go ├── buf.gen.yaml ├── buf.yaml ├── cli/ │ ├── alert.go │ ├── alert_add.go │ ├── alert_query.go │ ├── check_config.go │ ├── check_config_test.go │ ├── cluster.go │ ├── config/ │ │ ├── config.go │ │ ├── config_test.go │ │ ├── http_config.go │ │ └── testdata/ │ │ ├── amtool.bad.yml │ │ ├── amtool.good1.yml │ │ ├── amtool.good2.yml │ │ ├── http_config.bad.yml │ │ ├── http_config.basic_auth.good.yml │ │ └── http_config.good.yml │ ├── config.go │ ├── format/ │ │ ├── format.go │ │ ├── format_extended.go │ │ ├── format_json.go │ │ ├── format_simple.go │ │ └── sort.go │ ├── root.go │ ├── routing.go │ ├── silence.go │ ├── silence_add.go │ ├── silence_expire.go │ ├── silence_import.go │ ├── silence_query.go │ ├── silence_update.go │ ├── template.go │ ├── template_render.go │ ├── test_routing.go │ ├── test_routing_test.go │ ├── testdata/ │ │ ├── conf.bad.yml │ │ ├── conf.good.yml │ │ ├── conf.routing-reverted.yml │ │ └── conf.routing.yml │ └── utils.go ├── cluster/ │ ├── advertise.go │ ├── advertise_test.go │ ├── channel.go │ ├── channel_test.go │ ├── cluster.go │ ├── cluster_test.go │ ├── clusterpb/ │ │ ├── cluster.pb.go │ │ └── cluster.proto │ ├── connection_pool.go │ ├── delegate.go │ ├── testdata/ │ │ ├── certs/ │ │ │ ├── ca-config.json │ │ │ ├── ca-csr.json │ │ │ ├── ca-key.pem │ │ │ ├── ca.csr │ │ │ ├── ca.pem │ │ │ ├── node1-csr.json │ │ │ ├── node1-key.pem │ │ │ ├── node1.csr │ │ │ ├── node1.pem │ │ │ ├── node2-csr.json │ │ │ ├── node2-key.pem │ │ │ ├── node2.csr │ │ │ └── node2.pem │ │ ├── empty_tls_config.yml │ │ ├── tls_config_node1.yml │ │ ├── tls_config_node2.yml │ │ ├── tls_config_with_missing_client.yml │ │ └── tls_config_with_missing_server.yml │ ├── tls_config.go │ ├── tls_connection.go │ ├── tls_connection_test.go │ ├── tls_transport.go │ └── tls_transport_test.go ├── cmd/ │ ├── alertmanager/ │ │ ├── main.go │ │ └── main_test.go │ └── amtool/ │ ├── README.md │ └── main.go ├── config/ │ ├── common/ │ │ ├── inhibitrule.go │ │ ├── inhibitrule_test.go │ │ ├── matchers.go │ │ ├── matchers_test.go │ │ ├── notifierconfig.go │ │ ├── url.go │ │ └── url_test.go │ ├── config.go │ ├── config_fuzz_test.go │ ├── config_test.go │ ├── coordinator.go │ ├── coordinator_test.go │ ├── notifiers.go │ ├── notifiers_test.go │ ├── receiver/ │ │ ├── receiver.go │ │ └── receiver_test.go │ └── testdata/ │ ├── conf.empty-fields.yml │ ├── conf.good.yml │ ├── conf.group-by-all.yml │ ├── conf.http-config.good.yml │ ├── conf.mattermost-both-webhook-url-and-file.yml │ ├── conf.mattermost-default-webhook-url-file.yml │ ├── conf.mattermost-default-webhook-url.yml │ ├── conf.mattermost-no-webhook-url.yml │ ├── conf.mattermost-valid-receiver-both-webhook-url-and-file.yml │ ├── conf.nil-match_re-route.yml │ ├── conf.nil-source_match_re-inhibition.yml │ ├── conf.nil-target_match_re-inhibition.yml │ ├── conf.opsgenie-both-file-and-apikey.yml │ ├── conf.opsgenie-default-apikey-file.yml │ ├── conf.opsgenie-default-apikey-old-team.yml │ ├── conf.opsgenie-default-apikey.yml │ ├── conf.opsgenie-no-apikey.yml │ ├── conf.rocketchat-both-token-and-tokenfile.yml │ ├── conf.rocketchat-both-tokenid-and-tokenidfile.yml │ ├── conf.rocketchat-default-token-file.yml │ ├── conf.rocketchat-default-token.yml │ ├── conf.rocketchat-no-token.yml │ ├── conf.slack-both-file-and-token.yml │ ├── conf.slack-both-file-and-url.yml │ ├── conf.slack-both-url-and-token.yml │ ├── conf.slack-default-api-url-file.yml │ ├── conf.slack-default-app-token.yml │ ├── conf.slack-no-api-url-or-token.yml │ ├── conf.slack-update-message-and-webhook.yml │ ├── conf.smtp-both-password-and-file.yml │ ├── conf.smtp-no-username-or-password.yml │ ├── conf.smtp-password-global-and-local.yml │ ├── conf.sns-invalid.yml │ ├── conf.sns-topic-arn.yml │ ├── conf.telegram-both-bot-token-and-file.yml │ ├── conf.telegram-default-bot-token-file.yml │ ├── conf.telegram-default-bot-token.yml │ ├── conf.telegram-no-bot-token.yml │ ├── conf.telegram-valid-receiver-both-bot-token-and-file.yml │ ├── conf.victorops-both-file-and-apikey.yml │ ├── conf.victorops-default-apikey-file.yml │ ├── conf.victorops-default-apikey.yml │ ├── conf.victorops-no-apikey.yml │ ├── conf.wechat-both-file-and-secret.yml │ ├── conf.wechat-default-api-secret-file.yml │ └── conf.wechat-no-api-secret.yml ├── dispatch/ │ ├── dispatch.go │ ├── dispatch_bench_test.go │ ├── dispatch_test.go │ ├── route.go │ └── route_test.go ├── doc/ │ ├── alertmanager-mixin/ │ │ ├── .gitignore │ │ ├── .lint │ │ ├── Makefile │ │ ├── README.md │ │ ├── alerts.jsonnet │ │ ├── alerts.libsonnet │ │ ├── config.libsonnet │ │ ├── dashboards/ │ │ │ └── overview.libsonnet │ │ ├── dashboards.jsonnet │ │ ├── dashboards.libsonnet │ │ ├── jsonnetfile.json │ │ ├── jsonnetfile.lock.json │ │ └── mixin.libsonnet │ ├── arch.xml │ ├── design/ │ │ └── secure-cluster-traffic.md │ └── examples/ │ └── simple.yml ├── docs/ │ ├── alertmanager.md │ ├── alerts_api.md │ ├── configuration.md │ ├── high_availability.md │ ├── https.md │ ├── index.md │ ├── integrations.md │ ├── management_api.md │ ├── notification_examples.md │ ├── notifications.md │ └── overview.md ├── examples/ │ ├── ha/ │ │ ├── send_alerts.sh │ │ └── tls/ │ │ ├── Makefile │ │ ├── Procfile │ │ ├── README.md │ │ ├── certs/ │ │ │ ├── ca-config.json │ │ │ ├── ca-csr.json │ │ │ ├── ca-key.pem │ │ │ ├── ca.csr │ │ │ ├── ca.pem │ │ │ ├── node1-csr.json │ │ │ ├── node1-key.pem │ │ │ ├── node1.csr │ │ │ ├── node1.pem │ │ │ ├── node2-csr.json │ │ │ ├── node2-key.pem │ │ │ ├── node2.csr │ │ │ └── node2.pem │ │ ├── tls_config_node1.yml │ │ └── tls_config_node2.yml │ └── webhook/ │ ├── echo.go │ └── teams.tmpl ├── featurecontrol/ │ ├── featurecontrol.go │ └── featurecontrol_test.go ├── go.mod ├── go.sum ├── inhibit/ │ ├── index.go │ ├── inhibit.go │ ├── inhibit_bench_test.go │ └── inhibit_test.go ├── internal/ │ └── tools/ │ ├── go.mod │ └── go.sum ├── limit/ │ ├── bucket.go │ └── bucket_test.go ├── matcher/ │ ├── compat/ │ │ ├── parse.go │ │ └── parse_test.go │ ├── compliance/ │ │ └── compliance_test.go │ └── parse/ │ ├── bench_test.go │ ├── fuzz_test.go │ ├── lexer.go │ ├── lexer_test.go │ ├── parse.go │ ├── parse_test.go │ └── token.go ├── nflog/ │ ├── nflog.go │ ├── nflog_test.go │ └── nflogpb/ │ ├── nflog.pb.go │ ├── nflog.proto │ ├── set.go │ └── set_test.go ├── notify/ │ ├── discord/ │ │ ├── discord.go │ │ └── discord_test.go │ ├── email/ │ │ ├── email.go │ │ ├── email_test.go │ │ └── testdata/ │ │ ├── auth-local.yml │ │ ├── auth.yml │ │ ├── noauth-local.yml │ │ └── noauth.yml │ ├── incidentio/ │ │ ├── incidentio.go │ │ └── incidentio_test.go │ ├── jira/ │ │ ├── jira.go │ │ ├── jira_test.go │ │ └── types.go │ ├── mattermost/ │ │ ├── mattermost.go │ │ └── mattermost_test.go │ ├── msteams/ │ │ ├── msteams.go │ │ └── msteams_test.go │ ├── msteamsv2/ │ │ ├── msteamsv2.go │ │ └── msteamsv2_test.go │ ├── mute.go │ ├── mute_test.go │ ├── notify.go │ ├── notify_test.go │ ├── opsgenie/ │ │ ├── api_key_file │ │ ├── opsgenie.go │ │ └── opsgenie_test.go │ ├── pagerduty/ │ │ ├── pagerduty.go │ │ └── pagerduty_test.go │ ├── pushover/ │ │ ├── pushover.go │ │ └── pushover_test.go │ ├── rocketchat/ │ │ ├── rocketchat.go │ │ └── rocketchat_test.go │ ├── slack/ │ │ ├── slack.go │ │ └── slack_test.go │ ├── sns/ │ │ ├── sns.go │ │ └── sns_test.go │ ├── telegram/ │ │ ├── telegram.go │ │ └── telegram_test.go │ ├── test/ │ │ └── test.go │ ├── util.go │ ├── util_test.go │ ├── victorops/ │ │ ├── victorops.go │ │ └── victorops_test.go │ ├── webex/ │ │ ├── webex.go │ │ └── webex_test.go │ ├── webhook/ │ │ ├── webhook.go │ │ └── webhook_test.go │ └── wechat/ │ ├── wechat.go │ └── wechat_test.go ├── pkg/ │ ├── README.md │ ├── labels/ │ │ ├── matcher.go │ │ ├── matcher_test.go │ │ ├── parse.go │ │ └── parse_test.go │ └── modtimevfs/ │ └── modtimevfs.go ├── provider/ │ ├── mem/ │ │ ├── mem.go │ │ └── mem_test.go │ └── provider.go ├── scripts/ │ ├── genproto.sh │ └── swagger.sh ├── silence/ │ ├── cache.go │ ├── cache_test.go │ ├── silence.go │ ├── silence_bench_test.go │ ├── silence_test.go │ ├── silencepb/ │ │ ├── silence.pb.go │ │ └── silence.proto │ ├── state.go │ └── state_test.go ├── store/ │ ├── store.go │ └── store_test.go ├── template/ │ ├── Dockerfile │ ├── Makefile │ ├── default.tmpl │ ├── email.html │ ├── email.tmpl │ ├── inline-css.js │ ├── template.go │ └── template_test.go ├── test/ │ ├── cli/ │ │ ├── acceptance/ │ │ │ └── cli_test.go │ │ ├── acceptance.go │ │ └── mock.go │ ├── testutils/ │ │ ├── acceptance.go │ │ ├── collector.go │ │ └── mock.go │ └── with_api_v2/ │ ├── acceptance/ │ │ ├── api_test.go │ │ ├── cluster_test.go │ │ ├── inhibit_test.go │ │ ├── send_test.go │ │ ├── silence_test.go │ │ ├── utf8_test.go │ │ └── web_test.go │ ├── acceptance.go │ └── mock.go ├── timeinterval/ │ ├── timeinterval.go │ └── timeinterval_test.go ├── tracing/ │ ├── config.go │ ├── http.go │ ├── testdata/ │ │ └── ca.cer │ ├── tracing.go │ └── tracing_test.go ├── types/ │ ├── types.go │ └── types_test.go └── ui/ ├── Dockerfile ├── app/ │ ├── .gitignore │ ├── CONTRIBUTING.md │ ├── Makefile │ ├── README.md │ ├── elm.json │ ├── index.html │ ├── lib/ │ │ ├── elm-datepicker/ │ │ │ └── css/ │ │ │ └── elm-datepicker.css │ │ └── font-awesome-4.7.0/ │ │ ├── css/ │ │ │ └── font-awesome.css │ │ └── fonts/ │ │ └── FontAwesome.otf │ ├── review/ │ │ ├── elm.json │ │ └── src/ │ │ └── ReviewConfig.elm │ ├── script.js │ ├── src/ │ │ ├── Alerts/ │ │ │ └── Api.elm │ │ ├── Data/ │ │ │ ├── Alert.elm │ │ │ ├── AlertGroup.elm │ │ │ ├── AlertStatus.elm │ │ │ ├── AlertmanagerConfig.elm │ │ │ ├── AlertmanagerStatus.elm │ │ │ ├── ClusterStatus.elm │ │ │ ├── GettableAlert.elm │ │ │ ├── GettableSilence.elm │ │ │ ├── InlineResponse200.elm │ │ │ ├── Matcher.elm │ │ │ ├── PeerStatus.elm │ │ │ ├── PostableAlert.elm │ │ │ ├── PostableSilence.elm │ │ │ ├── Receiver.elm │ │ │ ├── Silence.elm │ │ │ ├── SilenceStatus.elm │ │ │ └── VersionInfo.elm │ │ ├── DateTime.elm │ │ ├── Main.elm │ │ ├── Parsing.elm │ │ ├── Silences/ │ │ │ ├── Api.elm │ │ │ ├── Decoders.elm │ │ │ └── Types.elm │ │ ├── Status/ │ │ │ ├── Api.elm │ │ │ └── Types.elm │ │ ├── Types.elm │ │ ├── Updates.elm │ │ ├── Utils/ │ │ │ ├── Api.elm │ │ │ ├── Date.elm │ │ │ ├── DateTimePicker/ │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ ├── Utils.elm │ │ │ │ └── Views.elm │ │ │ ├── Filter.elm │ │ │ ├── FormValidation.elm │ │ │ ├── Keyboard.elm │ │ │ ├── List.elm │ │ │ ├── Match.elm │ │ │ ├── String.elm │ │ │ ├── Types.elm │ │ │ └── Views.elm │ │ ├── Views/ │ │ │ ├── AlertList/ │ │ │ │ ├── AlertView.elm │ │ │ │ ├── Parsing.elm │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ └── Views.elm │ │ │ ├── FilterBar/ │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ └── Views.elm │ │ │ ├── GroupBar/ │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ └── Views.elm │ │ │ ├── NavBar/ │ │ │ │ ├── Types.elm │ │ │ │ └── Views.elm │ │ │ ├── NotFound/ │ │ │ │ └── Views.elm │ │ │ ├── ReceiverBar/ │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ └── Views.elm │ │ │ ├── Settings/ │ │ │ │ ├── Parsing.elm │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ └── Views.elm │ │ │ ├── Shared/ │ │ │ │ ├── Alert.elm │ │ │ │ ├── AlertCompact.elm │ │ │ │ ├── AlertListCompact.elm │ │ │ │ ├── Dialog.elm │ │ │ │ ├── SilencePreview.elm │ │ │ │ └── Types.elm │ │ │ ├── SilenceForm/ │ │ │ │ ├── Parsing.elm │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ └── Views.elm │ │ │ ├── SilenceList/ │ │ │ │ ├── Parsing.elm │ │ │ │ ├── SilenceView.elm │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ └── Views.elm │ │ │ ├── SilenceView/ │ │ │ │ ├── Parsing.elm │ │ │ │ ├── Types.elm │ │ │ │ ├── Updates.elm │ │ │ │ └── Views.elm │ │ │ └── Status/ │ │ │ ├── Parsing.elm │ │ │ ├── Types.elm │ │ │ ├── Updates.elm │ │ │ └── Views.elm │ │ └── Views.elm │ └── tests/ │ ├── Filter.elm │ ├── Helpers.elm │ ├── Match.elm │ └── StringUtils.elm ├── mantine-ui/ │ ├── .gitignore │ ├── .nvmrc │ ├── .prettierrc.mjs │ ├── .stylelintignore │ ├── .stylelintrc.json │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── postcss.config.cjs │ ├── src/ │ │ ├── App.tsx │ │ ├── components/ │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── Header.module.css │ │ │ ├── Header.tsx │ │ │ ├── InfoPageCard.tsx │ │ │ └── InfoPageStack.tsx │ │ ├── data/ │ │ │ ├── api.ts │ │ │ ├── groups.ts │ │ │ ├── silences.ts │ │ │ └── status.ts │ │ ├── highlightjs.css │ │ ├── main.tsx │ │ ├── pages/ │ │ │ ├── Alerts.page.test.tsx │ │ │ ├── Alerts.page.tsx │ │ │ ├── Config.page.tsx │ │ │ ├── Silences.page.tsx │ │ │ └── Status.page.tsx │ │ ├── theme.ts │ │ └── vite-env.d.ts │ ├── test-utils/ │ │ ├── index.ts │ │ └── render.tsx │ ├── tsconfig.json │ ├── vite.config.mjs │ └── vitest.setup.mjs ├── web.go └── web_test.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .build/ .tarballs/ !.build/linux-amd64/ !.build/linux-armv7/ !.build/linux-arm64/ !.build/linux-ppc64le/ !.build/linux-s390x/ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ --- name: Bug report description: Create a report to help us improve. body: - type: markdown attributes: value: | Thank you for opening a bug report for Alertmanager. Please do *NOT* ask support questions in Github issues. If your issue is not a feature request or bug report use our [community support](https://prometheus.io/community/). There is also [commercial support](https://prometheus.io/support-training/) available. - type: textarea attributes: label: What did you do? description: Please provide steps for us to reproduce this issue. validations: required: true - type: textarea attributes: label: What did you expect to see? - type: textarea attributes: label: What did you see instead? Under which circumstances? validations: required: true - type: markdown attributes: value: | ## Environment - type: input attributes: label: System information description: Insert output of `uname -srm` here, or operating system version. placeholder: e.g. Linux 5.16.15 x86_64 - type: textarea attributes: label: Alertmanager version description: Insert output of `alertmanager --version` here. render: text placeholder: | e.g. alertmanager, version 0.22.2 (branch: HEAD, revision: 44f8adc06af5101ad64bd8b9c8b18273f2922051) build user: root@b595c7f32520 build date: 20210602-07:50:37 go version: go1.16.4 platform: linux/amd64 - type: textarea attributes: label: Alertmanager configuration file description: Insert relevant configuration here. Don't forget to remove secrets. render: yaml - type: textarea attributes: label: Prometheus version description: Insert output of `prometheus --version` here (if relevant to the issue). render: text placeholder: | e.g. prometheus, version 2.23.0 (branch: HEAD, revision: 26d89b4b0776fe4cd5a3656dfa520f119a375273) build user: root@37609b3a0a21 build date: 20201126-10:56:17 go version: go1.15.5 platform: linux/amd64 - type: textarea attributes: label: Prometheus configuration file description: Insert relevant configuration here. Don't forget to remove secrets. render: yaml - type: textarea attributes: label: Logs description: Insert Prometheus and Alertmanager logs relevant to the issue here. render: text ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Prometheus Community Support url: https://prometheus.io/community/ about: If you need help or support, please request help here. - name: Commercial Support & Training url: https://prometheus.io/support-training/ about: If you want commercial support or training, vendors are listed here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ --- name: Feature request description: Suggest an idea for this project. body: - type: markdown attributes: value: >- Please do *NOT* ask support questions in Github issues. If your issue is not a feature request or bug report use our [community support](https://prometheus.io/community/). There is also [commercial support](https://prometheus.io/support-training/) available. - type: textarea attributes: label: Proposal description: Use case. Why is this important? placeholder: “Nice to have” is not a good use case. :) validations: required: true ================================================ FILE: .github/pull_request_template.md ================================================ #### Pull Request Checklist Please check all the applicable boxes. - Please list all open issue(s) discussed with maintainers related to this change - Fixes # - Is this a new Receiver integration? - [ ] I have already tried to use the [Webhook Receiver Integration](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) and [3rd party integrations](https://prometheus.io/docs/operating/integrations/#alertmanager-webhook-receiver) before adding this new Receiver Integration - Is this a bugfix? - [ ] I have added tests that can reproduce the bug which pass with this bugfix applied - Is this a new feature? - [ ] I have added tests that test the new feature's functionality - Does this change affect performance? - [ ] I have provided benchmarks comparison that shows performance is improved or is not degraded - You can use [`benchstat`](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat) to compare benchmarks - [ ] I have added new benchmarks if required or requested by maintainers - Is this a breaking change? - [ ] My changes do not break the existing cluster messages - [ ] My changes do not break the existing api - [ ] I have added/updated the required documentation - [ ] I have signed-off my commits - [ ] I will follow [best practices for contributing to this project](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-open-source) #### Which user-facing changes does this PR introduce? ```release-notes ``` ================================================ FILE: .github/workflows/ci.yml ================================================ --- name: CI on: # yamllint disable-line rule:truthy pull_request: workflow_call: jobs: test_frontend: name: Test alertmanager frontend runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - run: make clean - run: make all working-directory: ./ui/app - run: make assets - run: make apiv2 - run: git diff --exit-code build: name: Build Alertmanager for common architectures runs-on: ubuntu-latest strategy: matrix: thread: [0, 1, 2] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.0.0 - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/build with: promu_opts: "-p linux/amd64 -p windows/amd64 -p linux/arm64 -p darwin/amd64 -p darwin/arm64 -p linux/386" parallelism: 3 thread: ${{ matrix.thread }} test: name: Test runs-on: ubuntu-latest # Whenever the Go version is updated here, .promu.yml # should also be updated. container: image: quay.io/prometheus/golang-builder:1.26-base services: maildev-noauth: image: maildev/maildev:2.2.1 maildev-auth: image: maildev/maildev:2.2.1 env: MAILDEV_INCOMING_USER: user MAILDEV_INCOMING_PASS: pass env: EMAIL_NO_AUTH_CONFIG: testdata/noauth.yml EMAIL_AUTH_CONFIG: testdata/auth.yml steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/setup_environment - run: make - run: git diff --exit-code ================================================ FILE: .github/workflows/container_description.yml ================================================ --- name: Push README to Docker Hub on: push: paths: - "README.md" - "README-containers.md" - ".github/workflows/container_description.yml" branches: [ main, master ] permissions: contents: read jobs: PushDockerHubReadme: runs-on: ubuntu-latest name: Push README to Docker Hub if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. steps: - name: git checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set docker hub repo name run: echo "DOCKER_REPO_NAME=$(make docker-repo-name)" >> $GITHUB_ENV - name: Push README to Dockerhub uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1 env: DOCKER_USER: ${{ secrets.DOCKER_HUB_LOGIN }} DOCKER_PASS: ${{ secrets.DOCKER_HUB_PASSWORD }} with: destination_container_repo: ${{ env.DOCKER_REPO_NAME }} provider: dockerhub short_description: ${{ env.DOCKER_REPO_NAME }} # Empty string results in README-containers.md being pushed if it # exists. Otherwise, README.md is pushed. readme_file: '' PushQuayIoReadme: runs-on: ubuntu-latest name: Push README to quay.io if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. steps: - name: git checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - name: Set quay.io org name run: echo "DOCKER_REPO=$(echo quay.io/${GITHUB_REPOSITORY_OWNER} | tr -d '-')" >> $GITHUB_ENV - name: Set quay.io repo name run: echo "DOCKER_REPO_NAME=$(make docker-repo-name)" >> $GITHUB_ENV - name: Push README to quay.io uses: christian-korneck/update-container-description-action@d36005551adeaba9698d8d67a296bd16fa91f8e8 # v1 env: DOCKER_APIKEY: ${{ secrets.QUAY_IO_API_TOKEN }} with: destination_container_repo: ${{ env.DOCKER_REPO_NAME }} provider: quay # Empty string results in README-containers.md being pushed if it # exists. Otherwise, README.md is pushed. readme_file: '' ================================================ FILE: .github/workflows/mixin.yml ================================================ name: mixin on: pull_request: paths: - "doc/alertmanager-mixin/**" jobs: mixin: name: mixin-lint runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: install Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: 1.26.x # pin the mixtool version until https://github.com/monitoring-mixins/mixtool/issues/135 is merged. - run: go install github.com/monitoring-mixins/mixtool/cmd/mixtool@2282201396b69055bb0f92f187049027a16d2130 - run: go install github.com/google/go-jsonnet/cmd/jsonnetfmt@latest - run: go install github.com/jsonnet-bundler/jsonnet-bundler/cmd/jb@latest - run: make -C doc/alertmanager-mixin lint ================================================ FILE: .github/workflows/publish.yml ================================================ --- name: Publish on: # yamllint disable-line rule:truthy push: branches: - main jobs: ci: name: Run ci uses: ./.github/workflows/ci.yml build: name: Build Alertmanager for all architectures runs-on: ubuntu-latest strategy: matrix: thread: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] needs: ci steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/build with: parallelism: 12 thread: ${{ matrix.thread }} publish_main: name: Publish main branch artefacts runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/publish_main with: docker_hub_login: ${{ secrets.docker_hub_login }} docker_hub_password: ${{ secrets.docker_hub_password }} quay_io_login: ${{ secrets.quay_io_login }} quay_io_password: ${{ secrets.quay_io_password }} ================================================ FILE: .github/workflows/release.yml ================================================ --- name: Release on: # yamllint disable-line rule:truthy push: tags: - v* jobs: ci: name: Run ci uses: ./.github/workflows/ci.yml build: name: Build Alertmanager for all architectures runs-on: ubuntu-latest strategy: matrix: thread: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] needs: ci steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/build with: parallelism: 12 thread: ${{ matrix.thread }} publish_release: name: Publish release artefacts runs-on: ubuntu-latest needs: build steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: prometheus/promci@fc721ff8497a70a93a881cd552b71af7fb3a9d53 # v0.5.4 - uses: ./.github/promci/actions/publish_release with: docker_hub_login: ${{ secrets.docker_hub_login }} docker_hub_password: ${{ secrets.docker_hub_password }} quay_io_login: ${{ secrets.quay_io_login }} quay_io_password: ${{ secrets.quay_io_password }} github_token: ${{ secrets.PROMBOT_GITHUB_TOKEN }} ================================================ FILE: .github/workflows/stale.yml ================================================ name: Stale Check on: workflow_dispatch: {} schedule: - cron: '16 22 * * *' permissions: issues: write pull-requests: write jobs: stale: if: github.repository_owner == 'prometheus' || github.repository_owner == 'prometheus-community' # Don't run this workflow on forks. runs-on: ubuntu-latest steps: - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} # opt out of defaults to avoid marking issues as stale and closing them # https://github.com/actions/stale#days-before-close # https://github.com/actions/stale#days-before-stale days-before-stale: -1 days-before-close: -1 # Setting it to empty string to skip comments. # https://github.com/actions/stale#stale-pr-message # https://github.com/actions/stale#stale-issue-message stale-pr-message: '' stale-issue-message: '' operations-per-run: 30 # override days-before-stale, for only marking the pull requests as stale days-before-pr-stale: 60 stale-pr-label: stale exempt-pr-labels: keepalive ================================================ FILE: .github/workflows/ui-ci.yml ================================================ name: UI CI on: pull_request: branches: - '**' paths: - 'ui/**' concurrency: group: ${{ github.workflow }}-${{ github.event.number || github.sha }} cancel-in-progress: true jobs: test_mantine_ui: name: Test mantine-ui runs-on: ubuntu-latest defaults: run: working-directory: ./ui/mantine-ui steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: './ui/mantine-ui/.nvmrc' cache: 'npm' cache-dependency-path: '**/package-lock.json' - name: Install dependencies run: npm install - name: Run build run: npm run build - name: Run tests run: npm test ================================================ FILE: .gitignore ================================================ /data/ /alertmanager /amtool *.yml *.yaml /.build /.release /.tarballs /vendor !.golangci.yml !/cli/testdata/*.yml !/cli/config/testdata/*.yml !/cluster/testdata/*.yml !/config/testdata/*.yml !/examples/ha/tls/*.yml !/notify/email/testdata/*.yml !/doc/examples/simple.yml !/circle.yml !/.travis.yml !/.promu.yml !/api/v2/openapi.yaml !.github/workflows/*.yml !.github/ISSUE_TEMPLATE/*.yml !buf*.yaml ================================================ FILE: .golangci.yml ================================================ version: "2" linters: enable: - depguard - errorlint - godot - misspell - modernize - revive - sloglint - testifylint settings: depguard: rules: main: deny: - pkg: github.com/stretchr/testify/assert desc: "Use github.com/stretchr/testify/require instead of github.com/stretchr/testify/assert" - pkg: github.com/go-kit/kit/log desc: "Use github.com/go-kit/log instead of github.com/go-kit/kit/log" - pkg: github.com/pkg/errors desc: "Use errors or fmt instead of github.com/pkg/errors" errcheck: exclude-functions: # Don't flag lines such as "io.Copy(io.Discard, resp.Body)". - io.Copy # The next two are used in HTTP handlers, any error is handled by the server itself. - io.WriteString - (net/http.ResponseWriter).Write # No need to check for errors on server's shutdown. - (*net/http.Server).Shutdown # Never check for rollback errors as Rollback() is called when a previous error was detected. - (github.com/prometheus/prometheus/storage.Appender).Rollback godot: scope: toplevel exclude: - "^ ?This file is safe to edit" - "^ ?scheme value" period: true capital: true revive: rules: - name: blank-imports - name: context-as-argument - name: error-naming - name: error-return - name: error-strings - name: errorf - name: exported arguments: - disableStutteringCheck - name: if-return - name: increment-decrement - name: indent-error-flow - name: package-comments - name: range - name: receiver-naming - name: time-naming - name: unexported-return - name: var-declaration - name: var-naming disabled: true testifylint: disable: - float-compare - go-require enable-all: true exclusions: presets: - comments - common-false-positives - legacy - std-error-handling paths: # Skip autogenerated files. - ^.*\.(pb|y)\.go$ rules: - linters: - errcheck path: _test.go - linters: - modernize text: "omitzero: Omitempty has no effect on nested struct fields" - linters: - staticcheck text: "SA1019:.*types\\.Alert.*" warn-unused: true issues: max-issues-per-linter: 0 max-same-issues: 0 run: timeout: 5m formatters: enable: - gofumpt - goimports settings: gofumpt: extra-rules: true goimports: local-prefixes: - github.com/prometheus/alertmanager ================================================ FILE: .promu.yml ================================================ go: # Whenever the Go version is updated here, # .circle/config.yml should also be updated. version: 1.26 repository: path: github.com/prometheus/alertmanager build: binaries: - name: alertmanager path: ./cmd/alertmanager - name: amtool path: ./cmd/amtool tags: all: - netgo windows: [] ldflags: | -X github.com/prometheus/common/version.Version={{.Version}} -X github.com/prometheus/common/version.Revision={{.Revision}} -X github.com/prometheus/common/version.Branch={{.Branch}} -X github.com/prometheus/common/version.BuildUser={{user}}@{{host}} -X github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} tarball: files: - examples/ha/alertmanager.yml - LICENSE - NOTICE crossbuild: platforms: - darwin - dragonfly - freebsd - illumos - linux - netbsd - openbsd - windows ================================================ FILE: .yamllint ================================================ --- extends: default ignore: | **/node_modules web/api/v1/testdata/openapi_*_golden.yaml rules: braces: max-spaces-inside: 1 level: error brackets: max-spaces-inside: 1 level: error commas: disable comments: disable comments-indentation: disable document-start: disable indentation: spaces: consistent indent-sequences: consistent key-duplicates: ignore: | config/testdata/section_key_dup.bad.yml line-length: disable truthy: check-keys: false ================================================ FILE: CHANGELOG.md ================================================ ## main / (unreleased) * [CHANGE] ... * [FEATURE] ... * [ENHANCEMENT] ... * [BUGFIX] Use dispatcher tick time when evaluating repeat interval in dedup stage. #2461 ## 0.31.1 / 2026-02-11 * [BUGFIX] docs: Fix email TLS configuration example. #4976 * [BUGFIX] docs: Add telegram bot token options to global config docs. #4999 ## 0.31.0 / 2026-02-02 * [ENHANCEMENT] docs(opsgenie): Fix description of `api_url` field. #4908 * [ENHANCEMENT] docs(slack): Document missing app configs. #4871 * [ENHANCEMENT] docs: Fix `max-silence-size-bytes`. #4805 * [ENHANCEMENT] docs: Update expr for `AlertmanagerClusterFailedToSendAlerts` to exclude value 0. #4872 * [ENHANCEMENT] docs: Use matchers for inhibit rules examples. #4131 * [ENHANCEMENT] docs: add notification integrations. #4901 * [ENHANCEMENT] docs: update `slack_config` attachments documentation links. #4802 * [ENHANCEMENT] docs: update description of filter query params in openapi doc. #4810 * [ENHANCEMENT] provider: Reduce lock contention. #4809 * [FEATURE] slack: Add support for top-level text field in slack notification. #4867 * [FEATURE] smtp: Add support for authsecret from file. #3087 * [FEATURE] smtp: Customize the ssl/tls port support (#4757). #4818 * [FEATURE] smtp: Enhance email notifier configuration validation. #4826 * [FEATURE] telegram: Add `chat_id_file` configuration parameter. #4909 * [FEATURE] telegram: Support global bot token. #4823 * [FEATURE] webhook: Support templating in url fields. #4798 * [FEATURE] wechat: Add config directive to pass api secret via file. #4734 * [FEATURE] provider: Implement per alert limits. #4819 * [BUGFIX] Allow empty `group_by` to override parent route. #4825 * [BUGFIX] Set `spellcheck=false` attribute on silence filter input. #4811 * [BUGFIX] jira: Fix for handling api v3 with ADF. #4756 * [BUGFIX] jira: Prevent hostname corruption in cloud api url replacement. #4892 ## 0.30.1 / 2026-01-12 * [BUGFIX] Fix memory leak in tracing client. #4828 ## 0.30.0 / 2025-12-15 * [CHANGE] Don't allow calling qids with an empty ids list. #4707 * [FEATURE] Add mattermost integration. #4090 * [FEATURE] Add saturday to the first day of the week options. #4473 * [FEATURE] Add templating functions for working with urls. #4625 * [FEATURE] cluster: Allow persistent peer names. #4636 * [FEATURE] dispatch: Add start delay. #4704 * [FEATURE] provider: Add subscriber channel metrics. #4630 * [FEATURE] template: Add tojson function. #4773 * [FEATURE] Add api http metrics. #4162 * [FEATURE] Add distributed tracing support. #4745 * [FEATURE] Add names to inhibit rules. #4628 * [FEATURE] Add timeout option for pagerduty notifier. #4354 * [FEATURE] Add timeout option for slack notifier. #4355 * [FEATURE] Allow nested details fields in pagerduty. #3944 * [FEATURE] Implement `phantom_threading` to group email alerts into threads. #4623 * [FEATURE] gc: Report errors, but remove erroneous silences and continue. #4724 * [FEATURE] jira: Template customfields. #4029 * [FEATURE] jira: Allow configuring issue update via parameter. #4621 * [FEATURE] Slack app support. #4211 * [ENHANCEMENT] Add comment about smtp plain authentication. #4741 * [ENHANCEMENT] Add documentation about high availability. #4708 * [ENHANCEMENT] Add documentation for `client_allowed_sans`. #4706 * [ENHANCEMENT] Improve logging around webhook dispatch failure. #4511 * [ENHANCEMENT] Compile silence matchers when the silence is added. #4695 * [ENHANCEMENT] Fix '`s/client/alerts_api/g`' broken link in 0.29. #4718 * [ENHANCEMENT] Fix `rocketchat_config` docs. #4767 * [ENHANCEMENT] Fix: `` was renamed. #4729 * [ENHANCEMENT] Improve inhibition performance. #4607 * [ENHANCEMENT] Loadsnapshot: update matcher index properly while not holding lock. #4714 * [ENHANCEMENT] Logging improvements. #4113 * [ENHANCEMENT] Move query locking back into private query function. #4694 * [ENHANCEMENT] Optimize the new inhibitor implementation for ~2.5x performance improvement. #4668 * [ENHANCEMENT] Reduce the time dispatch.group holds the mutex. #4670 * [ENHANCEMENT] Use b.loop() to simplify the code and improve performance. #4642 * [ENHANCEMENT] Remove duplicate slice during silences query. #4696 * [ENHANCEMENT] Silences: optimize incremental mutes queries via a silence version index. #4723 * [ENHANCEMENT] Update description for filter param in openapi. #4775 * [BUGFIX] Add new behavior to avoid races on config reload. #4705 * [BUGFIX] config: Fix duplicate header detection for all case variants. #2810 * [BUGFIX] marker: Stop state leakage from aggregation groups. #4438 * [BUGFIX] Fix pprof debug endpoints not working with --web.route-prefix. #4698 * [BUGFIX] Set context timeout for resolvepeers. #4343 ## 0.29.0 / 2025-11-01 * [FEATURE] Add incident.io notifier. #4372 * [FEATURE] Add monospace message formatting. #4362 * [FEATURE] Add ability to customize interval for maintenance to run. #4541 * [ENHANCEMENT] Update Jira notifier to support both Jira cloud API v3 and Jira datacenter API v2. #4542 * [ENHANCEMENT] Increase mixin rate intervals for alert `FailedToSendAlerts`. #4206 * [ENHANCEMENT] Make /alertmanager group writable in docker image. #4469 * [BUGFIX] Fix logged notification count on error in notify. #4323 * [BUGFIX] Fix docker image permissions path. #4288 * [BUGFIX] Fix error handling in template rendering for Telegram. #4353 * [BUGFIX] Fix duplicate `other` in error messages for config. #4366 * [BUGFIX] Fix logic that considers an alert reopened in Jira. #4478 * [BUGFIX] Fix Jira issue count #4615 ## 0.28.1 / 2025-03-07 * [ENHANCEMENT] Improved performance of inhibition rules when using Equal labels. #4119 * [ENHANCEMENT] Improve the documentation on escaping in UTF-8 matchers. #4157 * [ENHANCEMENT] Update alertmanager_config_hash metric help to document the hash is not cryptographically strong. #4210 * [BUGFIX] Fix panic in amtool when using `--verbose`. #4218 * [BUGFIX] Fix templating of channel field for Rocket.Chat. #4220 * [BUGFIX] Fix `rocketchat_configs` written as `rocket_configs` in docs. #4217 * [BUGFIX] Fix usage for `--enable-feature` flag. #4214 * [BUGFIX] Trim whitespace from OpsGenie API Key. #4195 * [BUGFIX] Fix Jira project template not rendered when searching for existing issues. #4291 * [BUGFIX] Fix subtle bug in JSON/YAML encoding of inhibition rules that would cause Equal labels to be omitted. #4292 * [BUGFIX] Fix header for `slack_configs` in docs. #4247 * [BUGFIX] Fix weight and wrap of Microsoft Teams notifications. #4222 * [BUGFIX] Fix format of YAML examples in configuration.md. #4207 ## 0.28.0 / 2025-01-15 * [CHANGE] Templating errors in the SNS integration now return an error. #3531 #3879 * [CHANGE] Adopt log/slog, drop go-kit/log #4089 * [FEATURE] Add a new Microsoft Teams integration based on Flows #4024 * [FEATURE] Add a new Rocket.Chat integration #3600 * [FEATURE] Add a new Jira integration #3590 #3931 * [FEATURE] Add support for `GOMEMLIMIT`, enable it via the feature flag `--enable-feature=auto-gomemlimit`. #3895 * [FEATURE] Add support for `GOMAXPROCS`, enable it via the feature flag `--enable-feature=auto-gomaxprocs`. #3837 * [FEATURE] Add support for limits of silences including the maximum number of active and pending silences, and the maximum size per silence (in bytes). You can use the flags `--silences.max-silences` and `--silences.max-silence-size-bytes` to set them accordingly #3852 #3862 #3866 #3885 #3886 #3877 * [FEATURE] Muted alerts now show whether they are suppressed or not in both the `/api/v2/alerts` endpoint and the Alertmanager UI. #3793 #3797 #3792 * [ENHANCEMENT] Add support for `content`, `username` and `avatar_url` in the Discord integration. `content` and `username` also support templating. #4007 * [ENHANCEMENT] Only invalidate the silences cache if a new silence is created or an existing silence replaced - should improve latency on both `GET api/v2/alerts` and `POST api/v2/alerts` API endpoint. #3961 * [ENHANCEMENT] Add image source label to Dockerfile. To get changelogs shown when using Renovate #4062 * [ENHANCEMENT] Build using go 1.23 #4071 * [ENHANCEMENT] Support setting a global SMTP TLS configuration. #3732 * [ENHANCEMENT] The setting `room_id` in the WebEx integration can now be templated to allow for dynamic room IDs. #3801 * [ENHANCEMENT] Enable setting `message_thread_id` for the Telegram integration. #3638 * [ENHANCEMENT] Support the `since` and `humanizeDuration` functions to templates. This means users can now format time to more human-readable text. #3863 * [ENHANCEMENT] Support the `date` and `tz` functions to templates. This means users can now format time in a specified format and also change the timezone to their specific locale. #3812 * [ENHANCEMENT] Latency metrics now support native histograms. #3737 * [ENHANCEMENT] Add full width to adaptive card for msteamsv2 #4135 * [ENHANCEMENT] Add timeout option for webhook notifier. #4137 * [ENHANCEMENT] Update config to allow showing secret values when marshaled #4158 * [ENHANCEMENT] Enable templating for Jira project and issue_type #4159 * [BUGFIX] Fix the SMTP integration not correctly closing an SMTP submission, which may lead to unsuccessful dispatches being marked as successful. #4006 * [BUGFIX] The `ParseMode` option is now set explicitly in the Telegram integration. If we don't HTML tags had not been parsed by default. #4027 * [BUGFIX] Fix a memory leak that was caused by updates silences continuously. #3930 * [BUGFIX] Fix hiding secret URLs when the URL is incorrect. #3887 * [BUGFIX] Fix a race condition in the alerts - it was more of a hypothetical race condition that could have occurred in the alert reception pipeline. #3648 * [BUGFIX] Fix a race condition in the alert delivery pipeline that would cause a firing alert that was delivered earlier to be deleted from the aggregation group when instead it should have been delivered again. #3826 * [BUGFIX] Fix version in APIv1 deprecation notice. #3815 * [BUGFIX] Fix crash errors when using `url_file` in the Webhook integration. #3800 * [BUGFIX] fix `Route.ID()` returns conflicting IDs. #3803 * [BUGFIX] Fix deadlock on the alerts memory store. #3715 * [BUGFIX] Fix `amtool template render` when using the default values. #3725 * [BUGFIX] Fix `webhook_url_file` for both the Discord and Microsoft Teams integrations. #3728 #3745 * [BUGFIX] Fix wechat api link #4084 * [BUGFIX] Fix build info metric #4166 * [BUGFIX] Fix UTF-8 not allowed in Equal field for inhibition rules #4177 ## 0.27.0 / 2024-02-28 * [CHANGE] Discord Integration: Enforce max length in `message`. #3597 * [CHANGE] API: Removal of all `api/v1/` endpoints. These endpoints now log and return a deprecation message and respond with a status code of `410`. #2970 * [FEATURE] UTF-8 Support: Introduction of support for any UTF-8 character as part of label names and matchers. Please read more below. #3453, #3483, #3567, #3570 * [FEATURE] Metrics: Introduced the experimental feature flag `--enable-feature=receiver-name-in-metrics` to include the receiver name in the following metrics: #3045 * `alertmanager_notifications_total` * `alertmanager_notifications_failed_totall` * `alertmanager_notification_requests_total` * `alertmanager_notification_requests_failed_total` * `alertmanager_notification_latency_seconds` * [FEATURE] Metrics: Introduced a new gauge named `alertmanager_inhibition_rules` that counts the number of configured inhibition rules. #3681 * [FEATURE] Metrics: Introduced a new counter named `alertmanager_alerts_supressed_total` that tracks muted alerts, it contains a `reason` label to indicate the source of the mute. #3565 * [ENHANCEMENT] Discord Integration: Introduced support for `webhook_url_file`. #3555 * [ENHANCEMENT] Microsoft Teams Integration: Introduced support for `webhook_url_file`. #3555 * [ENHANCEMENT] Microsoft Teams Integration: Add support for `summary`. #3616 * [ENHANCEMENT] Metrics: Notification metrics now support two new values for the label `reason`, `contextCanceled` and `contextDeadlineExceeded`. #3631 * [ENHANCEMENT] Email Integration: Contents of `auth_password_file` are now trimmed of prefixed and suffixed whitespace. #3680 * [BUGFIX] amtool: Fixes the error `scheme required for webhook url` when using amtool with `--alertmanager.url`. #3509 * [BUGFIX] Mixin: Fix `AlertmanagerFailedToSendAlerts`, `AlertmanagerClusterFailedToSendAlerts`, and `AlertmanagerClusterFailedToSendAlerts` to make sure they ignore the `reason` label. #3599 ### Removal of API v1 The Alertmanager `v1` API has been deprecated since January 2019 with the release of Alertmanager `v0.16.0`. With the release of version `0.27.0` it is now removed. A successful HTTP request to any of the `v1` endpoints will log and return a deprecation message while responding with a status code of `410`. Please ensure you switch to the `v2` equivalent endpoint in your integrations before upgrading. ### Alertmanager support for all UTF-8 characters in matchers and label names Starting with Alertmanager `v0.27.0`, we have a new parser for matchers that has a number of backwards incompatible changes. While most matchers will be forward-compatible, some will not. Alertmanager is operating a transition period where it supports both UTF-8 and classic matchers, so **it's entirely safe to upgrade without any additional configuration**. With that said, we recommend the following: - If this is a new Alertmanager installation, we recommend enabling UTF-8 strict mode before creating an Alertmanager configuration file. You can enable strict mode with `alertmanager --config.file=config.yml --enable-feature="utf8-strict-mode"`. - If this is an existing Alertmanager installation, we recommend running the Alertmanager in the default mode called fallback mode before enabling UTF-8 strict mode. In this mode, Alertmanager will log a warning if you need to make any changes to your configuration file before UTF-8 strict mode can be enabled. **Alertmanager will make UTF-8 strict mode the default in the next two versions**, so it's important to transition as soon as possible. Irrespective of whether an Alertmanager installation is a new or existing installation, you can also use `amtool` to validate that an Alertmanager configuration file is compatible with UTF-8 strict mode before enabling it in Alertmanager server by running `amtool check-config config.yml` and inspecting the log messages. Should you encounter any problems, you can run the Alertmanager with just the classic parser enabled by running `alertmanager --config.file=config.yml --enable-feature="classic-mode"`. If so, please submit a bug report via GitHub issues. ## 0.26.0 / 2023-08-23 * [SECURITY] Fix stored XSS via the /api/v1/alerts endpoint in the Alertmanager UI. CVE-2023-40577 * [CHANGE] Telegram Integration: `api_url` is now optional. #2981 * [CHANGE] Telegram Integration: `ParseMode` default is now `HTML` instead of `MarkdownV2`. #2981 * [CHANGE] Webhook Integration: `url` is now marked as a secret. It will no longer show up in the logs as clear-text. #3228 * [CHANGE] Metrics: New label `reason` for `alertmanager_notifications_failed_total` metric to indicate the type of error of the alert delivery. #3094 #3307 * [FEATURE] Clustering: New flag `--cluster.label`, to help to block any traffic that is not meant for the cluster. #3354 * [FEATURE] Integrations: Add Microsoft Teams as a supported integration. #3324 * [ENHANCEMENT] Telegram Integration: Support `bot_token_file` for loading this secret from a file. #3226 * [ENHANCEMENT] Webhook Integration: Support `url_file` for loading this secret from a file. #3223 * [ENHANCEMENT] Webhook Integration: Leading and trailing white space is now removed for the contents of `url_file`. #3363 * [ENHANCEMENT] Pushover Integration: Support options `device` and `sound` (sound was previously supported but undocumented). #3318 * [ENHANCEMENT] Pushover Integration: Support `user_key_file` and `token_file` for loading this secret from a file. #3200 * [ENHANCEMENT] Slack Integration: Support errors wrapped in successful (HTTP status code 200) responses. #3121 * [ENHANCEMENT] API: Add `CORS` and `Cache-Control` HTTP headers to all version 2 API routes. #3195 * [ENHANCEMENT] UI: Receiver name is now visible as part of the alerts page. #3289 * [ENHANCEMENT] Templating: Better default text when using `{{ .Annotations }}` and `{{ .Labels }}`. #3256 * [ENHANCEMENT] Templating: Introduced a new function `trimSpace` which removes leading and trailing white spaces. #3223 * [ENHANCEMENT] CLI: `amtool silence query` now supports the `--id` flag to query an individual silence. #3241 * [ENHANCEMENT] Metrics: Introduced `alertmanager_nflog_maintenance_total` and `alertmanager_nflog_maintenance_errors_total` to monitor maintenance of the notification log. #3286 * [ENHANCEMENT] Metrics: Introduced `alertmanager_silences_maintenance_total` and `alertmanager_silences_maintenance_errors_total` to monitor maintenance of silences. #3285 * [ENHANCEMENT] Logging: Log GroupKey and alerts on alert delivery when using debug mode. #3438 * [BUGFIX] Configuration: Empty list of `receivers` and `inhibit_rules` would cause the alertmanager to crash. #3209 * [BUGFIX] Templating: Fixed a race condition when using the `title` function. It is now race-safe. #3278 * [BUGFIX] API: Fixed duplicate receiver names in the `api/v2/receivers` API endpoint. #3338 * [BUGFIX] API: Attempting to delete a silence now returns the correct status code, `404` instead of `500`. #3352 * [BUGFIX] Clustering: Fixes a panic when `tls_client_config` is empty. #3443 * [BUGFIX] Fix stored XSS via the /api/v1/alerts endpoint in the Alertmanager UI. ## 0.25.0 / 2022-12-22 * [CHANGE] Change the default `parse_mode` value from `MarkdownV2` to `HTML` for Telegram. #2981 * [CHANGE] Make `api_url` field optional for Telegram. #2981 * [CHANGE] Use CanonicalMIMEHeaderKey instead of TitleCasing for email headers. #3080 * [CHANGE] Reduce the number of notification logs broadcasted between peers by expiring them after (2 * repeat interval). #2982 * [FEATURE] Add `proxy_url` support for OAuth2 in HTTP client configuration. #3010 * [FEATURE] Reload TLS certificate and key from disk when updated. #3168 * [FEATURE] Add Discord integration. #2948 * [FEATURE] Add Webex integration. #3132 * [ENHANCEMENT] Add `--web.systemd-socket` flag to systemd socket activation listeners instead of port listeners (Linux only). #3140 * [ENHANCEMENT] Add `enable_http2` support in HTTP client configuration. #3010 * [ENHANCEMENT] Add `min_version` support to select the minimum TLS version in HTTP client configuration. #3010 * [ENHANCEMENT] Add `max_version` support to select the maximum TLS version in HTTP client configuration. #3168 * [ENHANCEMENT] Emit warning logs when truncating messages in notifications. #3145 * [ENHANCEMENT] Add `--data.maintenance-interval` flag to define the interval between the garbage collection and snapshotting to disk of the silences and the notification logs. #2849 * [ENHANCEMENT] Support HEAD method for the `/-/healty` and `/-/ready` endpoints. #3039 * [ENHANCEMENT] Truncate messages with the `…` ellipsis character instead of the 3-dots string `...`. #3072 * [ENHANCEMENT] Add support for reading global and local SMTP passwords from files. #3038 * [ENHANCEMENT] Add Location support to time intervals. #2782 * [ENHANCEMENT] UI: Add 'Link' button to alerts in list. #2880 * [ENHANCEMENT] Add the `source` field to the PagerDuty configuration. #3106 * [ENHANCEMENT] Add support for reading PagerDuty routing and service keys from files. #3107 * [ENHANCEMENT] Log response details when notifications fail for Webhooks, Pushover and VictorOps. #3103 * [ENHANCEMENT] UI: Allow to choose the first day of the week as Sunday or Monday. #3093 * [ENHANCEMENT] Add support for reading VictorOps API key from file. #3111 * [ENHANCEMENT] Support templating for Opsgenie's responder type. #3060 * [BUGFIX] Fail configuration loading if `api_key` and `api_key_file` are defined at the same time. #2910 * [BUGFIX] Fix the `alertmanager_alerts` metric to avoid counting resolved alerts as active. Also added a new `alertmanager_marked_alerts` metric that retain the old behavior. #2943 * [BUGFIX] Trim contents of Slack API URLs when reading from files. #2929 * [BUGFIX] amtool: Avoid panic when the label value matcher is empty. #2968 * [BUGFIX] Fail configuration loading if `api_url` is empty for OpsGenie. #2910 * [BUGFIX] Fix email template for resolved notifications. #3166 * [BUGFIX] Use the HTML template engine when the parse mode is HTML for Telegram. #3183 ## 0.24.0 / 2022-03-24 * [CHANGE] Add the `/api/v2` prefix to all endpoints in the OpenAPI specification and generated client code. #2696 * [CHANGE] Remove the `github.com/prometheus/alertmanager/client` Go package. #2763 * [FEATURE] Add `--cluster.tls-config` experimental flag to secure cluster traffic via mutual TLS. #2237 * [FEATURE] Add support for active time intervals. Active and mute time intervals should be defined via `time_intervals` rather than `mute_time_intervals` (the latter is deprecated but it will be supported until v1.0). #2779 * [FEATURE] Add Telegram integration. #2827 * [ENHANCEMENT] Add `update_alerts` field to the OpsGenie configuration to update message and description when sending alerts. #2519 * [ENHANCEMENT] Add `--cluster.allow-insecure-public-advertise-address-discovery` feature flag to enable discovery and use of public IP addresses for clustering. #2719 * [ENHANCEMENT] Add `entity` and `actions` fields to the OpsGenie configuration. #2753 * [ENHANCEMENT] Add `opsgenie_api_key_file` field to the global configuration. #2728 * [ENHANCEMENT] Add support for `teams` responders to the OpsGenie configuration. #2685 * [ENHANCEMENT] Add the User-Agent header to all notification requests. #2730 * [ENHANCEMENT] Re-enable HTTP/2. #2720 * [ENHANCEMENT] web: Add support for security-related HTTP headers. #2759 * [ENHANCEMENT] amtool: Allow filtering of silences by `createdBy` author. #2718 * [ENHANCEMENT] amtool: add `--http.config.file` flag to configure HTTP settings. #2764 * [BUGFIX] Fix HTTP client configuration for the SNS receiver. #2706 * [BUGFIX] Fix unclosed file descriptor after reading the silences snapshot file. #2710 * [BUGFIX] Fix field names for `mute_time_intervals` in JSON marshaling. #2765 * [BUGFIX] Ensure that the root route doesn't have any matchers. #2780 * [BUGFIX] Truncate the message's title to 1024 chars to avoid hitting Slack limits. #2774 * [BUGFIX] Fix the default HTML email template (`email.default.html`) to match with the canonical source. #2798 * [BUGFIX] Detect SNS FIFO topic based on the rendered value. #2819 * [BUGFIX] Avoid deleting and recreating a silence when an update is possible. #2816 * [BUGFIX] api/v2: Return 200 OK when deleting an expired silence. #2817 * [BUGFIX] amtool: Fix the silence's end date when adding a silence. The end date is (start date + duration) while it used to be (current time + duration). The new behavior is consistent with the update operation. #2741 ## 0.23.0 / 2021-08-25 * [FEATURE] Add AWS SNS receiver. #2615 * [FEATURE] amtool: add new template render command. #2538 * [ENHANCEMENT] amtool: Add ability to skip TLS verification for amtool. #2663 * [ENHANCEMENT] amtool: Detect version drift and warn users. #2672 * [BUGFIX] Time-based muting: Ensure time interval comparisons are in UTC. #2648 * [BUGFIX] amtool: Fix empty isEqual when talking to incompatible alertmanager. #2668 ## 0.22.2 / 2021-06-01 * [BUGFIX] Include pending silences for future muting decisions. #2590 ## 0.22.1 / 2021-05-27 This release addresses a regression in the API v1 that was introduced in 0.22.0. Matchers in silences created with the API v1 could be considered negative matchers. This affects users using amtool prior to v0.17.0. * [BUGFIX] API v1: Decode matchers without isEqual are positive matchers. #2603 ## 0.22.0 / 2021-05-21 * [CHANGE] Amtool and Alertmanager binaries help now prints to stdout. #2505 * [CHANGE] Use path relative to the configuration file for certificates and password files. #2502 * [CHANGE] Display Silence and Alert dates in ISO8601 format. #2363 * [FEATURE] Add date picker to silence form views. #2262 * [FEATURE] Add support for negative matchers. #2434 #2460 and many more. * [FEATURE] Add time-based muting to routing tree. #2393 * [FEATURE] Support TLS and basic authentication on the web server. #2446 * [FEATURE] Add OAuth 2.0 client support in HTTP client. #2560 * [ENHANCEMENT] Add composite durations in the configuration (e.g. 2h20m). #2353 * [ENHANCEMENT] Add follow_redirect option to disable following redirects. #2551 * [ENHANCEMENT] Add metric for permanently failed notifications. #2383 * [ENHANCEMENT] Add support for custom authorization scheme. #2499 * [ENHANCEMENT] Add support for not following HTTP redirects. #2499 * [ENHANCEMENT] Add support to set the Slack URL from a file. #2534 * [ENHANCEMENT] amtool: Add alert status to extended and simple output. #2324 * [ENHANCEMENT] Do not omit false booleans in the configuration page. #2317 * [ENHANCEMENT] OpsGenie: Propagate labels to Opsgenie details. #2276 * [ENHANCEMENT] PagerDuty: Filter out empty images and links. #2379 * [ENHANCEMENT] WeChat: add markdown support. #2309 * [BUGFIX] Fix a possible deadlock on shutdown. #2558 * [BUGFIX] UI: Fix extended printing of regex sign. #2445 * [BUGFIX] UI: Fix the favicon when using a path prefix. #2392 * [BUGFIX] Make filter labels consistent with Prometheus. #2403 * [BUGFIX] alertmanager_config_last_reload_successful takes templating failures into account. #2373 * [BUGFIX] amtool: avoid nil dereference in silence update. #2427 * [BUGFIX] VictorOps: Catch routing_key templating errors. #2467 ## 0.21.0 / 2020-06-16 This release removes the HipChat integration as it is discontinued by Atlassian on June 30th 2020. * [CHANGE] [HipChat] Remove HipChat integration as it is end-of-life. #2282 * [CHANGE] [amtool] Remove default assignment of environment variables. #2161 * [CHANGE] [PagerDuty] Enforce 512KB event size limit. #2225 * [ENHANCEMENT] [amtool] Add `cluster` command to show cluster and peer statuses. #2256 * [ENHANCEMENT] Add redirection from `/` to the routes prefix when it isn't empty. #2235 * [ENHANCEMENT] [Webhook] Add `max_alerts` option to limit the number of alerts included in the payload. #2274 * [ENHANCEMENT] Improve logs for API v2, notifications and clustering. #2177 #2188 #2260 #2261 #2273 * [BUGFIX] Fix child routes not inheriting their parent route's grouping when `group_by: [...]`. #2154 * [BUGFIX] [UI] Fix the receiver selector in the Alerts page when the receiver name contains regular expression metacharacters such as `+`. #2090 * [BUGFIX] Fix error message about start and end time validation. #2173 * [BUGFIX] Fix a potential race condition in dispatcher. #2208 * [BUGFIX] [API v2] Return an empty array of peers when the clustering is disabled. #2203 * [BUGFIX] Fix the registration of `alertmanager_dispatcher_aggregation_groups` and `alertmanager_dispatcher_alert_processing_duration_seconds` metrics. #2200 * [BUGFIX] Always retry notifications with back-off. #2290 ## 0.20.0 / 2019-12-11 * [CHANGE] Check that at least one silence matcher matches a non-empty string. #2081 * [ENHANCEMENT] [pagerduty] Check that PagerDuty keys aren't empty. #2085 * [ENHANCEMENT] [template] Add the `stringSlice` function. #2101 * [ENHANCEMENT] Add `alertmanager_dispatcher_aggregation_groups` and `alertmanager_dispatcher_alert_processing_duration_seconds` metrics. #2113 * [ENHANCEMENT] Log unused receivers. #2114 * [ENHANCEMENT] Add `alertmanager_receivers` metric. #2114 * [ENHANCEMENT] Add `alertmanager_integrations` metric. #2117 * [ENHANCEMENT] [email] Add Message-Id Header to outgoing emails. #2057 * [BUGFIX] Don't garbage-collect alerts from the store. #2040 * [BUGFIX] [ui] Disable the grammarly plugin on all textareas. #2061 * [BUGFIX] [config] Forbid nil regexp matchers. #2083 * [BUGFIX] [ui] Fix Silences UI when several filters are applied. #2075 Contributors: * @CharlesJUDITH * @NotAFile * @Pger-Y * @TheMeier * @johncming * @n33pm * @ntk148v * @oddlittlebird * @perlun * @qoops-1 * @roidelapluie * @simonpasquier * @stephenreddek * @sylr * @vrischmann ## 0.19.0 / 2019-09-03 * [CHANGE] Reject invalid external URLs at startup. #1960 * [CHANGE] Add Fingerprint to template data. #1945 * [CHANGE] Check Smarthost validity at config loading. #1957 * [ENHANCEMENT] Improve error messages for email receiver. #1953 * [ENHANCEMENT] Log error messages from OpsGenie API. #1965 * [ENHANCEMENT] Add the ability to configure Slack markdown field. #1967 * [ENHANCEMENT] Log warning when repeat_interval > retention. #1993 * [ENHANCEMENT] Add `alertmanager_cluster_enabled` metric. #1973 * [ENHANCEMENT] [ui] Recreate silence with previous comment. #1927 * [BUGFIX] [ui] Fix /api/v2/alerts/groups endpoint with similar alert groups. #1964 * [BUGFIX] Allow slashes in receivers. #2011 * [BUGFIX] [ui] Fix expand/collapse button with identical alert groups. #2012 ## 0.18.0 / 2019-07-08 * [CHANGE] Remove quantile labels from Summary metrics. #1921 * [CHANGE] [OpsGenie] Move from the deprecated `teams` field in the configuration to `responders`. #1863 * [CHANGE] [ui] Collapse alert groups on the initial view. #1876 * [CHANGE] [Wechat] Set the default API secret to blank. #1888 * [CHANGE/BUGFIX] [PagerDuty] Fix embedding of images, the `text` field in the configuration has been renamed to `href`. #1931 * [ENHANCEMENT] Use persistent HTTP clients. #1904 * [ENHANCEMENT] Add `alertmanager_cluster_alive_messages_total`, `alertmanager_cluster_peer_info` and `alertmanager_cluster_pings_seconds` metrics. #1941 * [ENHANCEMENT] [api] Add missing metrics for API v2. #1902 * [ENHANCEMENT] [Slack] Log error message on retry errors. #1655 * [ENHANCEMENT] [ui] Allow to create silences from the alerts filter bar. #1911 * [ENHANCEMENT] [ui] Enable auto resize the textarea fields. #1893 * [BUGFIX] [amtool] Use scheme, authentication and base path from the URL if present. #1892 #1940 * [BUGFIX] [amtool] Support filtering alerts by receiver. #1915 * [BUGFIX] [api] Fix /api/v2/alerts with multiple receivers. #1948 * [BUGFIX] [PagerDuty] Truncate description to 1024 chars for PagerDuty v1. #1922 * [BUGFIX] [ui] Add filtering based off of "active" query param. #1879 ## 0.17.0 / 2019-05-02 This release includes changes to amtool which are not fully backwards compatible with the previous amtool version (#1798) related to backup and import of silences. If a backup of silences is created using a previous version of amtool (v0.16.1 or earlier), it is possible that not all silences can be correctly imported using a later version of amtool. Additionally, the groups endpoint that was dropped from api v1 has been added to api v2. The default for viewing alerts in the UI now consumes from this endpoint and displays alerts grouped according to the groups defined in the running configuration. Custom grouping is still supported. This release has added two new flags that may need to be tweaked. For people running with a lot of concurrent requests, consider increasing the value of `--web.get-concurrency`. An increase in 503 errors indicates that the request rate is exceeding the number of currently available workers. The other new flag, --web.timeout, limits the time a request is allowed to run. The default behavior is to not use a timeout. * [CHANGE] Modify the self-inhibition prevention semantics (#1873) * [CHANGE] Make api/v2/status.cluster.{name,peers} properties optional for Alertmanager with disabled clustering (#1728) * [FEATURE] Add groups endpoint to v2 api (#1791) * [FEATURE] Optional timeout for HTTP requests (#1743) * [ENHANCEMENT] Set HTTP headers to prevent asset caching (#1817) * [ENHANCEMENT] API returns current silenced/inhibited state of alerts (#1733) * [ENHANCEMENT] Configurable concurrency limit for GET requests (#1743) * [ENHANCEMENT] Pushover notifier: support HTML, URL title and custom sounds (#1634) * [ENHANCEMENT] Support adding custom fields to VictorOps notifications (#1420) * [ENHANCEMENT] Migrate amtool CLI to API v2 (#1798) * [ENHANCEMENT][ui] Default alert list view grouped by configured alert groups (#1864) * [ENHANCEMENT][ui] Remove superfluous inhibited/silenced text, show inhibited status (#1698, #1862) * [ENHANCEMENT][ui] Silence preview now shows already-muted alerts (#1776) * [ENHANCEMENT][ui] Sort silences from api/v2 similarly to api/v1 (#1786) * [BUGFIX] Trim PagerDuty message summary to 1024 chars (#1701) * [BUGFIX] Add fix for race causing alerts to be dropped (#1843) * [BUGFIX][ui] Correctly construct filter query string for api (#1869) * [BUGFIX][ui] Do not display GroupByAll and GroupBy in marshaled config (#1665) * [BUGFIX][ui] Respect regex setting when creating silences (#1697) ## 0.16.2 / 2019-04-03 Updating to v0.16.2 is recommended for all users using the Slack, Pagerduty, Hipchat, Wechat, VictorOps and Pushover notifier, as connection errors could leak secrets embedded in the notifier's URL to stdout. * [BUGFIX] Redact notifier URL from logs to not leak secrets embedded in the URL (#1822, #1825) * [BUGFIX] Allow sending of unauthenticated SMTP requests when `smtp_auth_username` is not supplied (#1739) ## 0.16.1 / 2019-01-31 * [BUGFIX] Do not populate cluster info if clustering is disabled in API v2 (#1726) ## 0.16.0 / 2019-01-17 This release introduces a new API v2, fully generated via the OpenAPI project [1]. At the same time with this release the previous API v1 is being deprecated. API v1 will be removed with Alertmanager release v0.18.0. * [CHANGE] Deprecate API v1 * [CHANGE] Remove `api/v1/alerts/groups` GET endpoint (#1508 & #1525) * [CHANGE] Revert Alertmanager working directory changes in Docker image back to `/alertmanager` (#1435) * [CHANGE] Using the recommended label syntax for maintainer in Dockerfile (#1533) * [CHANGE] Change `alertmanager_notifications_total` to count attempted notifications, not only successful ones (#1578) * [CHANGE] Run as nobody inside container (#1586) * [CHANGE] Support `w` for weeks when creating silences, remove `y` for year (#1620) * [FEATURE] Introduce OpenAPI generated API v2 (#1352) * [FEATURE] Lookup parts in strings using regexp.MatchString in templates (#1452) * [FEATURE] Support image/thumb url in attachment in Slack notifier (#1506) * [FEATURE] Support custom TLS certificates for the email notifier (#1528) * [FEATURE] Add support for images and links in the PagerDuty notification config (#1559) * [FEATURE] Add support for grouping by all labels (#1588) * [FEATURE] [amtool] Add timeout support to amtool commands (#1471) * [FEATURE] [amtool] Added `config routes` tools for visualization and testing routes (#1511) * [FEATURE] [amtool] Support adding alerts using amtool (#1461) * [ENHANCEMENT] Add support for --log.format (#1658) * [ENHANCEMENT] Add CORS support to API v2 (#1667) * [ENHANCEMENT] Support HTML, URL title and custom sounds for Pushover (#1634) * [ENHANCEMENT] Update Alert compact view (#1698) * [ENHANCEMENT] Support adding custom fields to VictorOps notifications (#1420) * [ENHANCEMENT] Add help link in UI to Alertmanager documentation (#1522) * [ENHANCEMENT] Enforce HTTP or HTTPS URLs in Alertmanager config (#1567) * [ENHANCEMENT] Make OpsGenie API Key a templated string (#1594) * [ENHANCEMENT] Add name, value and SlackConfirmationField to action in Slack notifier (#1557) * [ENHANCEMENT] Show more alert information on silence form and silence view pages (#1601) * [ENHANCEMENT] Add cluster peers DNS refresh job (#1428) * [BUGFIX] Fix unmarshaling of secret URLs in config (#1663) * [BUGFIX] Do not write groupbyall and groupby when marshaling config (#1665) * [BUGFIX] Make a copy of firing alerts with EndsAt=0 when flushing (#1686) * [BUGFIX] Respect regex matchers when recreating silences in UI (#1697) * [BUGFIX] Change DefaultGlobalConfig to a function in Alertmanager configuration (#1656) * [BUGFIX] Fix email template typo in alert-warning style (#1421) * [BUGFIX] Fix silence redirect on silence creation UI page (#1548) * [BUGFIX] Add missing `callback_id` parameter in Slack notifier (#1592) * [BUGFIX] Throw error if no auth mechanism matches in email notifier (#1608) * [BUGFIX] Use quoted-printable transfer encoding for the email notifier (#1609) * [BUGFIX] Do not merge expired gossip messages (#1631) * [BUGFIX] Fix "PLAIN" auth during notification via smtp-over-tls on port 465 (#1591) * [BUGFIX] [amtool] Support for assuming first label is alertname in silence add and query (#1693) * [BUGFIX] [amtool] Support assuming first label is alertname in alert query with matchers (#1575) * [BUGFIX] [amtool] Fix config path check in amtool (#1538) * [BUGFIX] [amtool] Fix rfc3339 example texts (#1526) * [BUGFIX] [amtool] Fixed issue with loading path of a default configs (#1529) [1] https://github.com/prometheus/alertmanager#api ## 0.15.3 / 2018-11-09 * [BUGFIX] Fix alert merging supporting both empty and set EndsAt property for firing alerts send by Prometheus (#1611) ## 0.15.2 / 2018-08-14 * [ENHANCEMENT] [amtool] Add support for stdin to check-config (#1431) * [ENHANCEMENT] Log PagerDuty v1 response on BadRequest (#1481) * [BUGFIX] Correctly encode query strings in notifiers (#1516) * [BUGFIX] Add cache control headers to the API responses to avoid IE caching (#1500) * [BUGFIX] Avoid listener blocking on unsubscribe (#1482) * [BUGFIX] Fix a bunch of unhandled errors (#1501) * [BUGFIX] Update PagerDuty API V2 to send full details on resolve (#1483) * [BUGFIX] Validate URLs at config load time (#1468) * [BUGFIX] Fix Settle() interval (#1478) * [BUGFIX] Fix email to be green if only none firing (#1475) * [BUGFIX] Handle errors in notify (#1474) * [BUGFIX] Fix templating of hipchat room id (#1463) ## 0.15.1 / 2018-07-10 * [BUGFIX] Fix email template typo in alert-warning style (#1421) * [BUGFIX] Fix regression in Pager Duty config (#1455) * [BUGFIX] Catch templating errors in Wechat Notify (#1436) * [BUGFIX] Fail when no private address can be found for cluster (#1437) * [BUGFIX] Make sure we don't miss the first pushPull when joining cluster (#1456) * [BUGFIX] Fix concurrent read and write group error in dispatch (#1447) ## 0.15.0 / 2018-06-22 * [CHANGE] [amtool] Update silence add and update flags (#1298) * [CHANGE] Replace deprecated InstrumentHandler() (#1302) * [CHANGE] Validate Slack field config and only allow the necessary input (#1334) * [CHANGE] Remove legacy alert ingest endpoint (#1362) * [CHANGE] Move to memberlist as underlying gossip protocol including cluster flag changes from --mesh.xxx to --cluster.xxx (#1232) * [CHANGE] Move Alertmanager working directory in Docker image to /etc/alertmanager (#1313) * [BUGFIX/CHANGE] The default group by is no labels. (#1287) * [FEATURE] [amtool] Filter alerts by receiver (#1402) * [FEATURE] Wait for mesh to settle before sending alerts (#1209) * [FEATURE] [amtool] Support basic auth in alertmanager url (#1279) * [FEATURE] Make HTTP clients used for integrations configurable * [ENHANCEMENT] Support receiving alerts with end time and zero start time * [ENHANCEMENT] Sort dispatched alerts by job+instance (#1234) * [ENHANCEMENT] Support alert query filters `active` and `unprocessed` (#1366) * [ENHANCEMENT] [amtool] Expose alert query flags --active and --unprocessed (#1370) * [ENHANCEMENT] Add Slack actions to notifications (#1355) * [BUGFIX] Register nflog snapShotSize metric * [BUGFIX] Sort alerts in correct order before flushing to notifiers (#1349) * [BUGFIX] Don't reset initial wait timer if flush is in-progress (#1301) * [BUGFIX] Fix resolved alerts still inhibiting (#1331) * [BUGFIX] Template wechat config fields (#1356) * [BUGFIX] Notify resolved alerts properly (#1408) * [BUGFIX] Fix parsing for label values with commas (#1395) * [BUGFIX] Hide sensitive Wechat configuration (#1253) * [BUGFIX] Prepopulate matchers when recreating a silence (#1270) * [BUGFIX] Fix wechat panic (#1293) * [BUGFIX] Allow empty matchers in silences/filtering (#1289) * [BUGFIX] Properly configure HTTP client for Wechat integration ## 0.14.0 / 2018-02-12 * [ENHANCEMENT] [amtool] Silence update support dwy suffixes to expire flag (#1197) * [ENHANCEMENT] Allow templating PagerDuty receiver severity (#1214) * [ENHANCEMENT] Include receiver name in failed notifications log messages (#1207) * [ENHANCEMENT] Allow global opsgenie api key (#1208) * [ENHANCEMENT] Add mesh metrics (#1225) * [ENHANCEMENT] Add Class field to PagerDuty; add templating to PagerDuty-CEF fields (#1231) * [BUGFIX] Don't notify of resolved alerts if none were reported firing (#1198) * [BUGFIX] Notify only when new firing alerts are added (#1205) * [BUGFIX] [mesh] Fix pending connections never set to established (#1204) * [BUGFIX] Allow OpsGenie notifier to have empty team fields (#1224) * [BUGFIX] Don't count alerts with EndTime in the future as resolved (#1233) * [BUGFIX] Speed up re-rendering of Silence UI (#1235) * [BUGFIX] Forbid 0 value for group_interval and repeat_interval (#1230) * [BUGFIX] Fix WeChat agentid issue (#1229) ## 0.13.0 / 2018-01-12 * [CHANGE] Switch cmd/alertmanager to kingpin (#974) * [CHANGE] [amtool] Switch amtool to kingpin (#976) * [CHANGE] [amtool] silence query: --expired flag only shows expired silences (#1190) * [CHANGE] Return config reload result from reload endpoint (#1180) * [FEATURE] UI silence form is populated from location bar (#1148) * [FEATURE] Add /-/healthy endpoint (#1159) * [ENHANCEMENT] Instrument and log snapshot sizes on maintenance (#1155) * [ENHANCEMENT] Make alertGC interval configurable (#1151) * [ENHANCEMENT] Display mesh connections in the Status page (#1164) * [BUGFIX] Template service keys for pagerduty notifier (#1182) * [BUGFIX] Fix expire buttons on the silences page (#1171) * [BUGFIX] Fix JavaScript error in MSIE due to endswith() usage (#1172) * [BUGFIX] Correctly format UI error output (#1167) ## 0.12.0 / 2017-12-15 * [FEATURE] package amtool in docker container (#1127) * [FEATURE] Add notify support for Chinese User wechat (#1059) * [FEATURE] [amtool] Add a new `silence import` command (#1082) * [FEATURE] [amtool] Add new command to update silence (#1123) * [FEATURE] [amtool] Add ability to query for silences that will expire soon (#1120) * [ENHANCEMENT] Template source field in PagerDuty alert payload (#1117) * [ENHANCEMENT] Add footer field for slack messages (#1141) * [ENHANCEMENT] Add Slack additional "fields" to notifications (#1135) * [ENHANCEMENT] Adding check for webhook's URL formatting (#1129) * [ENHANCEMENT] Let the browser remember the creator of a silence (#1112) * [BUGFIX] Fix race in stopping inhibitor (#1118) * [BUGFIX] Fix browser UI when entering negative duration (#1132) ## 0.11.0 / 2017-11-16 * [CHANGE] Make silence negative filtering consistent with alert filtering (#1095) * [CHANGE] Change HipChat and OpsGenie api config names (#1087) * [ENHANCEMENT] amtool: Allow 'd', 'w', 'y' time suffixes when creating silence (#1091) * [ENHANCEMENT] Support OpsGenie Priority field (#1094) * [BUGFIX] Fix UI when no silences are present (#1090) * [BUGFIX] Fix OpsGenie Teams field (#1101) * [BUGFIX] Fix OpsGenie Tags field (#1108) ## 0.10.0 / 2017-11-09 * [CHANGE] Prevent inhibiting alerts in the source of the inhibition (#1017) * [ENHANCEMENT] Improve amtool check-config use and description text (#1016) * [ENHANCEMENT] Add metrics about current silences and alerts (#998) * [ENHANCEMENT] Sorted silences based on current status (#1015) * [ENHANCEMENT] Add metric of alertmanager position in mesh (#1024) * [ENHANCEMENT] Initialise notifications_total and notifications_failed_total (#1011) * [ENHANCEMENT] Allow selectable matchers on silence view (#1030) * [ENHANCEMENT] Allow template in victorops message_type field (#1038) * [ENHANCEMENT] Optionally hide inhibited alerts in API response (#1039) * [ENHANCEMENT] Toggle silenced and inhibited alerts in UI (#1049) * [ENHANCEMENT] Fix pushover limits (title, message, url) (#1055) * [ENHANCEMENT] Add limit to OpsGenie message (#1045) * [ENHANCEMENT] Upgrade OpsGenie notifier to v2 API. (#1061) * [ENHANCEMENT] Allow template in victorops routing_key field (#1083) * [ENHANCEMENT] Add support for PagerDuty API v2 (#1054) * [BUGFIX] Fix inhibit race (#1032) * [BUGFIX] Fix segfault on amtool (#1031) * [BUGFIX] Remove .WasInhibited and .WasSilenced fields of Alert type (#1026) * [BUGFIX] nflog: Fix Log() crash when gossip is nil (#1064) * [BUGFIX] Fix notifications for flapping alerts (#1071) * [BUGFIX] Fix shutdown crash with nil mesh router (#1077) * [BUGFIX] Fix negative matchers filtering (#1077) ## 0.9.1 / 2017-09-29 * [BUGFIX] Fix -web.external-url regression in ui (#1008) * [BUGFIX] Fix multipart email implementation (#1009) ## 0.9.0 / 2017-09-28 * [ENHANCEMENT] Add current time to webhook message (#909) * [ENHANCEMENT] Add link_names to slack notifier (#912) * [ENHANCEMENT] Make ui labels selectable/highlightable (#932) * [ENHANCEMENT] Make links in ui annotations selectable (#946) * [ENHANCEMENT] Expose the alert's "fingerprint" (unique identifier) through API (#786) * [ENHANCEMENT] Add README information for amtool (#939) * [ENHANCEMENT] Use user-set logging option consistently throughout alertmanager (#968) * [ENHANCEMENT] Sort alerts returned from API by their fingerprint (#969) * [ENHANCEMENT] Add edit/delete silence buttons on silence page view (#970) * [ENHANCEMENT] Add check-config subcommand to amtool (#978) * [ENHANCEMENT] Add email notification text content support (#934) * [ENHANCEMENT] Support passing binary name to make build target (#990) * [ENHANCEMENT] Show total no. of silenced alerts in preview (#994) * [ENHANCEMENT] Added confirmation dialog when expiring silences (#993) * [BUGFIX] Fix crash when no mesh router is configured (#919) * [BUGFIX] Render status page without mesh (#920) * [BUGFIX] Exit amtool subcommands with non-zero error code (#938) * [BUGFIX] Change mktemp invocation in makefile to work for macOS (#971) * [BUGFIX] Add a mutex to silences.go:gossipData (#984) * [BUGFIX] silences: avoid deadlock (#995) * [BUGFIX] Ignore expired silences OnGossip (#999) ## 0.8.0 / 2017-07-20 * [FEATURE] Add ability to filter alerts by receiver in the UI (#890) * [FEATURE] Add User-Agent for webhook requests (#893) * [ENHANCEMENT] Add possibility to have a global victorops api_key (#897) * [ENHANCEMENT] Add EntityDisplayName and improve StateMessage for Victorops (#769) * [ENHANCEMENT] Omit empty config fields and show regex upon re-marshaling to elide secrets (#864) * [ENHANCEMENT] Parse API error messages in UI (#866) * [ENHANCEMENT] Enable sending mail via smtp port 465 (#704) * [BUGFIX] Prevent duplicate notifications by sorting matchers (#882) * [BUGFIX] Remove timeout for UI requests (#890) * [BUGFIX] Update config file location of CLI in flag usage text (#895) ## 0.7.1 / 2017-06-09 * [BUGFIX] Fix filtering by label on Alert list and Silence list page ## 0.7.0 / 2017-06-08 * [CHANGE] Rewrite UI from scratch improving UX * [CHANGE] Rename `config` to `configYAML` on `api/v1/status` * [FEATURE] Add ability to update a silence on `api/v1/silences` POST endpoint (See #765) * [FEATURE] Return alert status on `api/v1/alerts` GET endpoint * [FEATURE] Serve silence state on `api/v1/silences` GET endpoint * [FEATURE] Add ability to specify a route prefix * [FEATURE] Add option to disable AM listening on mesh port * [ENHANCEMENT] Add ability to specify `filter` string and `silenced` flag on `api/v1/alerts` GET endpoint * [ENHANCEMENT] Update `cache-control` to prevent caching for web assets in general. * [ENHANCEMENT] Serve web assets by alertmanager instead of external CDN (See #846) * [ENHANCEMENT] Elide secrets in alertmanager config (See #840) * [ENHANCEMENT] AMTool: Move config file to a more consistent location (See #843) * [BUGFIX] Enable builds for Solaris/Illumos * [BUGFIX] Load web assets based on url path (See #323) ## 0.6.2 / 2017-05-09 * [BUGFIX] Correctly link to silences from alert again * [BUGFIX] Correctly hide silenced/show active alerts in UI again * [BUGFIX] Fix regression of alerts not being displayed until first processing * [BUGFIX] Fix internal usage of wrong lock for silence markers * [BUGFIX] Adapt amtool's API parsing to recent API changes * [BUGFIX] Correctly marshal regexes in config JSON response * [CHANGE] Anchor silence regex matchers to be consistent with Prometheus * [ENHANCEMENT] Error if root route is using `continue` keyword ## 0.6.1 / 2017-04-28 * [BUGFIX] Fix incorrectly serialized hash for notification providers. * [ENHANCEMENT] Add processing status field to alerts. * [FEATURE] Add config hash metric. ## 0.6.0 / 2017-04-25 * [BUGFIX] Add `groupKey` to `alerts/groups` endpoint https://github.com/prometheus/alertmanager/pull/576 * [BUGFIX] Only notify on firing alerts https://github.com/prometheus/alertmanager/pull/595 * [BUGFIX] Correctly marshal regex's in config for routing tree https://github.com/prometheus/alertmanager/pull/602 * [BUGFIX] Prevent panic when failing to load config https://github.com/prometheus/alertmanager/pull/607 * [BUGFIX] Prevent panic when alertmanager is started with an empty `-mesh.peer` https://github.com/prometheus/alertmanager/pull/726 * [CHANGE] Rename VictorOps config variables https://github.com/prometheus/alertmanager/pull/667 * [CHANGE] No longer generate releases for openbsd/arm https://github.com/prometheus/alertmanager/pull/732 * [ENHANCEMENT] Add `DELETE` as accepted CORS method https://github.com/prometheus/alertmanager/commit/0ecc59076ca6b4cbb63252fa7720a3d89d1c81d3 * [ENHANCEMENT] Switch to using `gogoproto` for protobuf https://github.com/prometheus/alertmanager/pull/715 * [ENHANCEMENT] Include notifier type in logs and errors https://github.com/prometheus/alertmanager/pull/702 * [FEATURE] Expose mesh peers on status page https://github.com/prometheus/alertmanager/pull/644 * [FEATURE] Add `reReplaceAll` template function https://github.com/prometheus/alertmanager/pull/639 * [FEATURE] Allow label-based filtering alerts/silences through API https://github.com/prometheus/alertmanager/pull/633 * [FEATURE] Add commandline tool for interacting with alertmanager https://github.com/prometheus/alertmanager/pull/636 ## 0.5.1 / 2016-11-24 * [BUGFIX] Fix crash caused by race condition in silencing * [ENHANCEMENT] Improve logging of API errors * [ENHANCEMENT] Add metrics for the notification log ## 0.5.0 / 2016-11-01 This release requires a storage wipe. It contains fundamental internal changes that came with implementing the high availability mode. * [FEATURE] Alertmanager clustering for high availability * [FEATURE] Garbage collection of old silences and notification logs * [CHANGE] New storage format * [CHANGE] Stricter silence semantics for consistent historical view ## 0.4.2 / 2016-09-02 * [BUGFIX] Fix broken regex checkbox in silence form * [BUGFIX] Simplify inconsistent silence update behavior ## 0.4.1 / 2016-08-31 * [BUGFIX] Wait for silence query to finish instead of showing error * [BUGFIX] Fix sorting of silences * [BUGFIX] Provide visual feedback after creating a silence * [BUGFIX] Fix styling of silences * [ENHANCEMENT] Provide cleaner API silence interface ## 0.4.0 / 2016-08-23 * [FEATURE] Silences are now paginated in the web ui * [CHANGE] Failure to start on unparsed flags ## 0.3.0 / 2016-07-07 * [CHANGE] Alerts are purely in memory and no longer persistent across restarts * [FEATURE] Add SMTP LOGIN authentication mechanism ## 0.2.1 / 2016-06-23 * [ENHANCEMENT] Allow inheritance of route receiver * [ENHANCEMENT] Add silence cache to silence provider * [BUGFIX] Fix HipChat room number in integration URL ## 0.2.0 / 2016-06-17 This release uses a new storage backend based on BoltDB. You have to backup and wipe your former storage path to run it. * [CHANGE] Use BoltDB as data store. * [CHANGE] Move SMTP authentication to configuration file * [FEATURE] add /-/reload HTTP endpoint * [FEATURE] Filter silenced alerts in web UI * [ENHANCEMENT] reduce inhibition computation complexity * [ENHANCEMENT] Add support for teams and tags in OpsGenie integration * [BUGFIX] Handle OpsGenie responses correctly * [BUGFIX] Fix Pushover queue length issue * [BUGFIX] STARTTLS before querying auth mechanism in email integration ## 0.1.1 / 2016-03-15 * [BUGFIX] Fix global database lock issue * [ENHANCEMENT] Improve SQLite alerts index * [ENHANCEMENT] Enable debug endpoint ## 0.1.0 / 2016-02-23 This version is a full rewrite of the Alertmanager with a very different feature set. Thus, there is no meaningful changelog. Changes with respect to 0.1.0-beta2: * [CHANGE] Expose same data structure to templates and webhook * [ENHANCEMENT] Show generator URL in default templates and web UI * [ENHANCEMENT] Support for Slack icon_emoji field * [ENHANCEMENT] Expose incident key to templates and webhook data * [ENHANCEMENT] Allow markdown in Slack 'text' field * [BUGFIX] Fixed database locking issue ## 0.1.0-beta2 / 2016-02-03 * [BUGFIX] Properly set timeout for incoming alerts with fixed start time * [ENHANCEMENT] Send source field in OpsGenie integration * [ENHANCEMENT] Improved routing configuration validation * [FEATURE] Basic instrumentation added ## 0.1.0-beta1 / 2016-01-08 * [BUGFIX] Send full alert group state on each update. Fixes erroneous resolved notifications. * [FEATURE] HipChat integration * [CHANGE] Slack integration no longer sends resolved notifications by default ## 0.1.0-beta0 / 2015-12-23 This version is a full rewrite of the Alertmanager with a very different feature set. Thus, there is no meaningful changelog. ## 0.0.4 / 2015-09-09 * [BUGFIX] Fix version info string in startup message. * [BUGFIX] Fix Pushover notifications by setting the right priority level, as well as required retry and expiry intervals. * [FEATURE] Make it possible to link to individual alerts in the UI. * [FEATURE] Rearrange alert columns in UI and allow expanding more alert details. * [FEATURE] Add Amazon SNS notifications. * [FEATURE] Add OpsGenie Webhook notifications. * [FEATURE] Add `-web.external-url` flag to control the externally visible Alertmanager URL. * [FEATURE] Add runbook and alertmanager URLs to PagerDuty and email notifications. * [FEATURE] Add a GET API to /api/alerts which pulls JSON formatted AlertAggregates. * [ENHANCEMENT] Sort alerts consistently in web UI. * [ENHANCEMENT] Suggest to use email address as silence creator. * [ENHANCEMENT] Make Slack timeout configurable. * [ENHANCEMENT] Add channel name to error logging about Slack notifications. * [ENHANCEMENT] Refactoring and tests for Flowdock notifications. * [ENHANCEMENT] New Dockerfile using alpine-golang-make-onbuild base image. * [CLEANUP] Add Docker instructions and other cleanups in README.md. * [CLEANUP] Update Makefile.COMMON from prometheus/utils. ## 0.0.3 / 2015-06-10 * [BUGFIX] Fix email template body writer being called with parameters in wrong order. ## 0.0.2 / 2015-06-09 * [BUGFIX] Fixed silences.json permissions in Docker image. * [CHANGE] Changed case of API JSON properties to initial lower letter. * [CHANGE] Migrated logging to use http://github.com/prometheus/log. * [FEATURE] Flowdock notification support. * [FEATURE] Slack notification support. * [FEATURE] Generic webhook notification support. * [FEATURE] Support for "@"-mentions in HipChat notifications. * [FEATURE] Path prefix option to support reverse proxies. * [ENHANCEMENT] Improved web redirection and 404 behavior. * [CLEANUP] Updated compiled web assets from source. * [CLEANUP] Updated fsnotify package to its new source location. * [CLEANUP] Updates to README.md and AUTHORS.md. * [CLEANUP] Various smaller cleanups and improvements. ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Prometheus Community Code of Conduct Prometheus follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/main/code-of-conduct.md). ================================================ FILE: COPYRIGHT.txt ================================================ Copyright Prometheus Team Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Dockerfile ================================================ ARG ARCH="amd64" ARG OS="linux" FROM quay.io/prometheus/busybox-${OS}-${ARCH}:latest LABEL maintainer="The Prometheus Authors " LABEL org.opencontainers.image.source="https://github.com/prometheus/alertmanager" ARG ARCH="amd64" ARG OS="linux" COPY .build/${OS}-${ARCH}/amtool /bin/amtool COPY .build/${OS}-${ARCH}/alertmanager /bin/alertmanager COPY examples/ha/alertmanager.yml /etc/alertmanager/alertmanager.yml RUN mkdir -p /alertmanager && \ chown -R nobody:nobody /etc/alertmanager /alertmanager && \ chmod -R g+w /alertmanager USER nobody EXPOSE 9093 VOLUME [ "/alertmanager" ] WORKDIR /alertmanager ENTRYPOINT [ "/bin/alertmanager" ] CMD [ "--config.file=/etc/alertmanager/alertmanager.yml", \ "--storage.path=/alertmanager" ] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: MAINTAINERS.md ================================================ * Simon Pasquier @simonpasquier * Andrey Kuzmin @w0rm * Josue Abreu @gotjosh * George Robinson @grobinson-grafana * Solomon Jacobs @SoloJacobs * Ethan Hunter @Spaceman1701 * Guido Trotter @ultrotter * Siavash Safi @siavashs ================================================ FILE: Makefile ================================================ # Copyright 2015 The Prometheus Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # Needs to be defined before including Makefile.common to auto-generate targets DOCKER_ARCHS ?= amd64 armv7 arm64 ppc64le s390x include Makefile.common FRONTEND_DIR = $(BIN_DIR)/ui/app TEMPLATE_DIR = $(BIN_DIR)/template DOCKER_IMAGE_NAME ?= alertmanager STATICCHECK_IGNORE = .PHONY: build-all # Will build both the front-end as well as the back-end build-all: assets apiv2 build .PHONY: build build: common-build .PHONY: lint lint: common-lint .PHONY: assets assets: ui/app/script.js template/email.tmpl ui/app/script.js: $(shell find ui/app/src -iname *.elm) api/v2/openapi.yaml cd $(FRONTEND_DIR) && $(MAKE) script.js template/email.tmpl: template/email.html cd $(TEMPLATE_DIR) && $(MAKE) email.tmpl .PHONY: apiv2 apiv2: api/v2/models api/v2/restapi api/v2/client api/v2/models api/v2/restapi api/v2/client: api/v2/openapi.yaml scripts/swagger.sh .PHONY: fuzz-config fuzz-config: go test -fuzz=^Fuzz -fuzztime=5s ./config .PHONY: clean clean: - @rm -rf template/email.tmpl \ api/v2/models api/v2/restapi api/v2/client - @cd $(FRONTEND_DIR) && $(MAKE) clean ================================================ FILE: Makefile.common ================================================ # Copyright The Prometheus Authors # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # A common Makefile that includes rules to be reused in different prometheus projects. # !!! Open PRs only against the prometheus/prometheus/Makefile.common repository! # Example usage : # Create the main Makefile in the root project directory. # include Makefile.common # customTarget: # @echo ">> Running customTarget" # # Ensure GOBIN is not set during build so that promu is installed to the correct path unexport GOBIN GO ?= go GOFMT ?= $(GO)fmt FIRST_GOPATH := $(firstword $(subst :, ,$(shell $(GO) env GOPATH))) GOOPTS ?= GOHOSTOS ?= $(shell $(GO) env GOHOSTOS) GOHOSTARCH ?= $(shell $(GO) env GOHOSTARCH) GO_VERSION ?= $(shell $(GO) version) GO_VERSION_NUMBER ?= $(word 3, $(GO_VERSION)) PRE_GO_111 ?= $(shell echo $(GO_VERSION_NUMBER) | grep -E 'go1\.(10|[0-9])\.') PROMU := $(FIRST_GOPATH)/bin/promu pkgs = ./... ifeq (arm, $(GOHOSTARCH)) GOHOSTARM ?= $(shell GOARM= $(GO) env GOARM) GO_BUILD_PLATFORM ?= $(GOHOSTOS)-$(GOHOSTARCH)v$(GOHOSTARM) else GO_BUILD_PLATFORM ?= $(GOHOSTOS)-$(GOHOSTARCH) endif GOTEST := $(GO) test GOTEST_DIR := ifneq ($(CIRCLE_JOB),) ifneq ($(shell command -v gotestsum 2> /dev/null),) GOTEST_DIR := test-results GOTEST := gotestsum --junitfile $(GOTEST_DIR)/unit-tests.xml -- endif endif PROMU_VERSION ?= 0.18.0 PROMU_URL := https://github.com/prometheus/promu/releases/download/v$(PROMU_VERSION)/promu-$(PROMU_VERSION).$(GO_BUILD_PLATFORM).tar.gz SKIP_GOLANGCI_LINT := GOLANGCI_LINT := GOLANGCI_LINT_OPTS ?= GOLANGCI_LINT_VERSION ?= v2.10.1 GOLANGCI_FMT_OPTS ?= # golangci-lint only supports linux, darwin and windows platforms on i386/amd64/arm64. # windows isn't included here because of the path separator being different. ifeq ($(GOHOSTOS),$(filter $(GOHOSTOS),linux darwin)) ifeq ($(GOHOSTARCH),$(filter $(GOHOSTARCH),amd64 i386 arm64)) # If we're in CI and there is an Actions file, that means the linter # is being run in Actions, so we don't need to run it here. ifneq (,$(SKIP_GOLANGCI_LINT)) GOLANGCI_LINT := else ifeq (,$(CIRCLE_JOB)) GOLANGCI_LINT := $(FIRST_GOPATH)/bin/golangci-lint else ifeq (,$(wildcard .github/workflows/golangci-lint.yml)) GOLANGCI_LINT := $(FIRST_GOPATH)/bin/golangci-lint endif endif endif PREFIX ?= $(shell pwd) BIN_DIR ?= $(shell pwd) DOCKER_IMAGE_TAG ?= $(subst /,-,$(shell git rev-parse --abbrev-ref HEAD)) DOCKERBUILD_CONTEXT ?= ./ DOCKER_REPO ?= prom # Check if deprecated DOCKERFILE_PATH is set ifdef DOCKERFILE_PATH $(error DOCKERFILE_PATH is deprecated. Use DOCKERFILE_VARIANTS ?= $(DOCKERFILE_PATH) in the Makefile) endif DOCKER_ARCHS ?= amd64 DOCKERFILE_VARIANTS ?= Dockerfile $(wildcard Dockerfile.*) # Function to extract variant from Dockerfile label. # Returns the variant name from io.prometheus.image.variant label, or "default" if not found. define dockerfile_variant $(strip $(or $(shell sed -n 's/.*io\.prometheus\.image\.variant="\([^"]*\)".*/\1/p' $(1)),default)) endef # Check for duplicate variant names (including default for Dockerfiles without labels). DOCKERFILE_VARIANT_NAMES := $(foreach df,$(DOCKERFILE_VARIANTS),$(call dockerfile_variant,$(df))) DOCKERFILE_VARIANT_NAMES_SORTED := $(sort $(DOCKERFILE_VARIANT_NAMES)) ifneq ($(words $(DOCKERFILE_VARIANT_NAMES)),$(words $(DOCKERFILE_VARIANT_NAMES_SORTED))) $(error Duplicate variant names found. Each Dockerfile must have a unique io.prometheus.image.variant label, and only one can be without a label (default)) endif # Build variant:dockerfile pairs for shell iteration. DOCKERFILE_VARIANTS_WITH_NAMES := $(foreach df,$(DOCKERFILE_VARIANTS),$(call dockerfile_variant,$(df)):$(df)) # Shell helper to check whether a dockerfile/arch pair is excluded. define dockerfile_arch_is_excluded case " $(DOCKERFILE_ARCH_EXCLUSIONS) " in \ *" $$dockerfile:$(1) "*) true ;; \ *) false ;; \ esac endef # Shell helper to check whether a registry/arch pair is excluded. # Extracts registry from DOCKER_REPO (e.g., quay.io/prometheus -> quay.io) define registry_arch_is_excluded registry=$$(echo "$(DOCKER_REPO)" | cut -d'/' -f1); \ case " $(DOCKER_REGISTRY_ARCH_EXCLUSIONS) " in \ *" $$registry:$(1) "*) true ;; \ *) false ;; \ esac endef BUILD_DOCKER_ARCHS = $(addprefix common-docker-,$(DOCKER_ARCHS)) PUBLISH_DOCKER_ARCHS = $(addprefix common-docker-publish-,$(DOCKER_ARCHS)) TAG_DOCKER_ARCHS = $(addprefix common-docker-tag-latest-,$(DOCKER_ARCHS)) SANITIZED_DOCKER_IMAGE_TAG := $(subst +,-,$(DOCKER_IMAGE_TAG)) ifeq ($(GOHOSTARCH),amd64) ifeq ($(GOHOSTOS),$(filter $(GOHOSTOS),linux freebsd darwin windows)) # Only supported on amd64 test-flags := -race endif endif # This rule is used to forward a target like "build" to "common-build". This # allows a new "build" target to be defined in a Makefile which includes this # one and override "common-build" without override warnings. %: common-% ; .PHONY: common-all common-all: precheck style check_license lint yamllint unused build test .PHONY: common-style common-style: @echo ">> checking code style" @fmtRes=$$($(GOFMT) -d $$(git ls-files '*.go' ':!:vendor/*' || find . -path ./vendor -prune -o -name '*.go' -print)); \ if [ -n "$${fmtRes}" ]; then \ echo "gofmt checking failed!"; echo "$${fmtRes}"; echo; \ echo "Please ensure you are using $$($(GO) version) for formatting code."; \ exit 1; \ fi .PHONY: common-check_license common-check_license: @echo ">> checking license header" @licRes=$$(for file in $$(git ls-files '*.go' ':!:vendor/*' || find . -path ./vendor -prune -o -type f -iname '*.go' -print) ; do \ awk 'NR<=3' $$file | grep -Eq "(Copyright|generated|GENERATED)" || echo $$file; \ done); \ if [ -n "$${licRes}" ]; then \ echo "license header checking failed:"; echo "$${licRes}"; \ exit 1; \ fi @echo ">> checking for copyright years 2026 or later" @futureYearRes=$$(git grep -E 'Copyright (202[6-9]|20[3-9][0-9])' -- '*.go' ':!:vendor/*' || true); \ if [ -n "$${futureYearRes}" ]; then \ echo "Files with copyright year 2026 or later found (should use 'Copyright The Prometheus Authors'):"; echo "$${futureYearRes}"; \ exit 1; \ fi .PHONY: common-deps common-deps: @echo ">> getting dependencies" $(GO) mod download .PHONY: update-go-deps update-go-deps: @echo ">> updating Go dependencies" @for m in $$($(GO) list -mod=readonly -m -f '{{ if and (not .Indirect) (not .Main)}}{{.Path}}{{end}}' all); do \ $(GO) get $$m; \ done $(GO) mod tidy .PHONY: common-test-short common-test-short: $(GOTEST_DIR) @echo ">> running short tests" $(GOTEST) -short $(GOOPTS) $(pkgs) .PHONY: common-test common-test: $(GOTEST_DIR) @echo ">> running all tests" $(GOTEST) $(test-flags) $(GOOPTS) $(pkgs) $(GOTEST_DIR): @mkdir -p $@ .PHONY: common-format common-format: $(GOLANGCI_LINT) @echo ">> formatting code" $(GO) fmt $(pkgs) ifdef GOLANGCI_LINT @echo ">> formatting code with golangci-lint" $(GOLANGCI_LINT) fmt $(GOLANGCI_FMT_OPTS) endif .PHONY: common-vet common-vet: @echo ">> vetting code" $(GO) vet $(GOOPTS) $(pkgs) .PHONY: common-lint common-lint: $(GOLANGCI_LINT) ifdef GOLANGCI_LINT @echo ">> running golangci-lint" $(GOLANGCI_LINT) run $(GOLANGCI_LINT_OPTS) $(pkgs) endif .PHONY: common-lint-fix common-lint-fix: $(GOLANGCI_LINT) ifdef GOLANGCI_LINT @echo ">> running golangci-lint fix" $(GOLANGCI_LINT) run --fix $(GOLANGCI_LINT_OPTS) $(pkgs) endif .PHONY: common-yamllint common-yamllint: @echo ">> running yamllint on all YAML files in the repository" ifeq (, $(shell command -v yamllint 2> /dev/null)) @echo "yamllint not installed so skipping" else yamllint . endif # For backward-compatibility. .PHONY: common-staticcheck common-staticcheck: lint .PHONY: common-unused common-unused: @echo ">> running check for unused/missing packages in go.mod" $(GO) mod tidy @git diff --exit-code -- go.sum go.mod .PHONY: common-build common-build: promu @echo ">> building binaries" $(PROMU) build --prefix $(PREFIX) $(PROMU_BINARIES) .PHONY: common-tarball common-tarball: promu @echo ">> building release tarball" $(PROMU) tarball --prefix $(PREFIX) $(BIN_DIR) .PHONY: common-docker-repo-name common-docker-repo-name: @echo "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)" .PHONY: common-docker $(BUILD_DOCKER_ARCHS) common-docker: $(BUILD_DOCKER_ARCHS) $(BUILD_DOCKER_ARCHS): common-docker-%: @for variant in $(DOCKERFILE_VARIANTS_WITH_NAMES); do \ dockerfile=$${variant#*:}; \ variant_name=$${variant%%:*}; \ if $(call dockerfile_arch_is_excluded,$*); then \ echo "Skipping $$variant_name variant for linux-$* (excluded by DOCKERFILE_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ distroless_arch="$*"; \ if [ "$*" = "armv7" ]; then \ distroless_arch="arm"; \ fi; \ if [ "$$dockerfile" = "Dockerfile" ]; then \ echo "Building default variant ($$variant_name) for linux-$* using $$dockerfile"; \ docker build -t "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" \ -f $$dockerfile \ --build-arg ARCH="$*" \ --build-arg OS="linux" \ --build-arg DISTROLESS_ARCH="$$distroless_arch" \ $(DOCKERBUILD_CONTEXT); \ if [ "$$variant_name" != "default" ]; then \ echo "Tagging default variant with $$variant_name suffix"; \ docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" \ "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name"; \ fi; \ else \ echo "Building $$variant_name variant for linux-$* using $$dockerfile"; \ docker build -t "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name" \ -f $$dockerfile \ --build-arg ARCH="$*" \ --build-arg OS="linux" \ --build-arg DISTROLESS_ARCH="$$distroless_arch" \ $(DOCKERBUILD_CONTEXT); \ fi; \ done .PHONY: common-docker-publish $(PUBLISH_DOCKER_ARCHS) common-docker-publish: $(PUBLISH_DOCKER_ARCHS) $(PUBLISH_DOCKER_ARCHS): common-docker-publish-%: @for variant in $(DOCKERFILE_VARIANTS_WITH_NAMES); do \ dockerfile=$${variant#*:}; \ variant_name=$${variant%%:*}; \ if $(call dockerfile_arch_is_excluded,$*); then \ echo "Skipping push for $$variant_name variant on linux-$* (excluded by DOCKERFILE_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ if $(call registry_arch_is_excluded,$*); then \ echo "Skipping push for $$variant_name variant on linux-$* to $(DOCKER_REPO) (excluded by DOCKER_REGISTRY_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ echo "Pushing $$variant_name variant for linux-$*"; \ docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name"; \ fi; \ if [ "$$dockerfile" = "Dockerfile" ]; then \ echo "Pushing default variant ($$variant_name) for linux-$*"; \ docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)"; \ fi; \ if [ "$(DOCKER_IMAGE_TAG)" = "latest" ]; then \ if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ echo "Pushing $$variant_name variant version tags for linux-$*"; \ docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name"; \ fi; \ if [ "$$dockerfile" = "Dockerfile" ]; then \ echo "Pushing default variant version tag for linux-$*"; \ docker push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)"; \ fi; \ fi; \ done DOCKER_MAJOR_VERSION_TAG = $(firstword $(subst ., ,$(shell cat VERSION))) .PHONY: common-docker-tag-latest $(TAG_DOCKER_ARCHS) common-docker-tag-latest: $(TAG_DOCKER_ARCHS) $(TAG_DOCKER_ARCHS): common-docker-tag-latest-%: @for variant in $(DOCKERFILE_VARIANTS_WITH_NAMES); do \ dockerfile=$${variant#*:}; \ variant_name=$${variant%%:*}; \ if $(call dockerfile_arch_is_excluded,$*); then \ echo "Skipping tag for $$variant_name variant on linux-$* (excluded by DOCKERFILE_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ if $(call registry_arch_is_excluded,$*); then \ echo "Skipping tag for $$variant_name variant on linux-$* for $(DOCKER_REPO) (excluded by DOCKER_REGISTRY_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ echo "Tagging $$variant_name variant for linux-$* as latest"; \ docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:latest-$$variant_name"; \ docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name"; \ fi; \ if [ "$$dockerfile" = "Dockerfile" ]; then \ echo "Tagging default variant ($$variant_name) for linux-$* as latest"; \ docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:latest"; \ docker tag "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:$(SANITIZED_DOCKER_IMAGE_TAG)" "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$*:v$(DOCKER_MAJOR_VERSION_TAG)"; \ fi; \ done .PHONY: common-docker-manifest common-docker-manifest: @for variant in $(DOCKERFILE_VARIANTS_WITH_NAMES); do \ dockerfile=$${variant#*:}; \ variant_name=$${variant%%:*}; \ if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ echo "Creating manifest for $$variant_name variant"; \ refs=""; \ for arch in $(DOCKER_ARCHS); do \ if $(call dockerfile_arch_is_excluded,$$arch); then \ echo " Skipping $$arch for $$variant_name (excluded by DOCKERFILE_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ if $(call registry_arch_is_excluded,$$arch); then \ echo " Skipping $$arch for $$variant_name on $(DOCKER_REPO) (excluded by DOCKER_REGISTRY_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ refs="$$refs $(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$$arch:$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name"; \ done; \ if [ -z "$$refs" ]; then \ echo "Skipping manifest for $$variant_name variant (no supported architectures)"; \ continue; \ fi; \ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name" $$refs; \ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)-$$variant_name"; \ fi; \ if [ "$$dockerfile" = "Dockerfile" ]; then \ echo "Creating default variant ($$variant_name) manifest"; \ refs=""; \ for arch in $(DOCKER_ARCHS); do \ if $(call dockerfile_arch_is_excluded,$$arch); then \ echo " Skipping $$arch for default variant (excluded by DOCKERFILE_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ if $(call registry_arch_is_excluded,$$arch); then \ echo " Skipping $$arch for default variant on $(DOCKER_REPO) (excluded by DOCKER_REGISTRY_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ refs="$$refs $(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$$arch:$(SANITIZED_DOCKER_IMAGE_TAG)"; \ done; \ if [ -z "$$refs" ]; then \ echo "Skipping default variant manifest (no supported architectures)"; \ continue; \ fi; \ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)" $$refs; \ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):$(SANITIZED_DOCKER_IMAGE_TAG)"; \ fi; \ if [ "$(DOCKER_IMAGE_TAG)" = "latest" ]; then \ if [ "$$dockerfile" != "Dockerfile" ] || [ "$$variant_name" != "default" ]; then \ echo "Creating manifest for $$variant_name variant version tag"; \ refs=""; \ for arch in $(DOCKER_ARCHS); do \ if $(call dockerfile_arch_is_excluded,$$arch); then \ echo " Skipping $$arch for $$variant_name version tag (excluded by DOCKERFILE_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ if $(call registry_arch_is_excluded,$$arch); then \ echo " Skipping $$arch for $$variant_name version tag on $(DOCKER_REPO) (excluded by DOCKER_REGISTRY_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ refs="$$refs $(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$$arch:v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name"; \ done; \ if [ -z "$$refs" ]; then \ echo "Skipping version-tag manifest for $$variant_name variant (no supported architectures)"; \ continue; \ fi; \ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name" $$refs; \ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):v$(DOCKER_MAJOR_VERSION_TAG)-$$variant_name"; \ fi; \ if [ "$$dockerfile" = "Dockerfile" ]; then \ echo "Creating default variant version tag manifest"; \ refs=""; \ for arch in $(DOCKER_ARCHS); do \ if $(call dockerfile_arch_is_excluded,$$arch); then \ echo " Skipping $$arch for default variant version tag (excluded by DOCKERFILE_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ if $(call registry_arch_is_excluded,$$arch); then \ echo " Skipping $$arch for default variant version tag on $(DOCKER_REPO) (excluded by DOCKER_REGISTRY_ARCH_EXCLUSIONS)"; \ continue; \ fi; \ refs="$$refs $(DOCKER_REPO)/$(DOCKER_IMAGE_NAME)-linux-$$arch:v$(DOCKER_MAJOR_VERSION_TAG)"; \ done; \ if [ -z "$$refs" ]; then \ echo "Skipping default variant version-tag manifest (no supported architectures)"; \ continue; \ fi; \ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest create -a "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):v$(DOCKER_MAJOR_VERSION_TAG)" $$refs; \ DOCKER_CLI_EXPERIMENTAL=enabled docker manifest push "$(DOCKER_REPO)/$(DOCKER_IMAGE_NAME):v$(DOCKER_MAJOR_VERSION_TAG)"; \ fi; \ fi; \ done .PHONY: promu promu: $(PROMU) $(PROMU): $(eval PROMU_TMP := $(shell mktemp -d)) curl -s -L $(PROMU_URL) | tar -xvzf - -C $(PROMU_TMP) mkdir -p $(FIRST_GOPATH)/bin cp $(PROMU_TMP)/promu-$(PROMU_VERSION).$(GO_BUILD_PLATFORM)/promu $(FIRST_GOPATH)/bin/promu rm -r $(PROMU_TMP) .PHONY: common-proto common-proto: @echo ">> generating code from proto files" @./scripts/genproto.sh ifdef GOLANGCI_LINT $(GOLANGCI_LINT): mkdir -p $(FIRST_GOPATH)/bin curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/$(GOLANGCI_LINT_VERSION)/install.sh \ | sed -e '/install -d/d' \ | sh -s -- -b $(FIRST_GOPATH)/bin $(GOLANGCI_LINT_VERSION) endif .PHONY: common-print-golangci-lint-version common-print-golangci-lint-version: @echo $(GOLANGCI_LINT_VERSION) .PHONY: precheck precheck:: define PRECHECK_COMMAND_template = precheck:: $(1)_precheck PRECHECK_COMMAND_$(1) ?= $(1) $$(strip $$(PRECHECK_OPTIONS_$(1))) .PHONY: $(1)_precheck $(1)_precheck: @if ! $$(PRECHECK_COMMAND_$(1)) 1>/dev/null 2>&1; then \ echo "Execution of '$$(PRECHECK_COMMAND_$(1))' command failed. Is $(1) installed?"; \ exit 1; \ fi endef govulncheck: install-govulncheck govulncheck ./... install-govulncheck: command -v govulncheck > /dev/null || go install golang.org/x/vuln/cmd/govulncheck@latest ================================================ FILE: NOTICE ================================================ Prometheus Alertmanager Copyright 2013-2015 The Prometheus Authors This product includes software developed at SoundCloud Ltd. (http://soundcloud.com/). The following components are included in this product: Bootstrap http://getbootstrap.com Copyright 2011-2014 Twitter, Inc. Licensed under the MIT License ================================================ FILE: Procfile ================================================ a1: ./alertmanager --log.level=debug --storage.path=$TMPDIR/a1 --web.listen-address=:9093 --cluster.listen-address=127.0.0.1:8001 --config.file=examples/ha/alertmanager.yml a2: ./alertmanager --log.level=debug --storage.path=$TMPDIR/a2 --web.listen-address=:9094 --cluster.listen-address=127.0.0.1:8002 --cluster.peer=127.0.0.1:8001 --config.file=examples/ha/alertmanager.yml a3: ./alertmanager --log.level=debug --storage.path=$TMPDIR/a3 --web.listen-address=:9095 --cluster.listen-address=127.0.0.1:8003 --cluster.peer=127.0.0.1:8001 --config.file=examples/ha/alertmanager.yml wh: go run ./examples/webhook/echo.go ================================================ FILE: README.md ================================================ # Alertmanager [![CircleCI](https://circleci.com/gh/prometheus/alertmanager/tree/main.svg?style=shield)][circleci] [![Docker Repository on Quay](https://quay.io/repository/prometheus/alertmanager/status "Docker Repository on Quay")][quay] [![Docker Pulls](https://img.shields.io/docker/pulls/prom/alertmanager.svg?maxAge=604800)][hub] The Alertmanager handles alerts sent by client applications such as the Prometheus server. It takes care of deduplicating, grouping, and routing them to the correct [receiver integrations](https://prometheus.io/docs/alerting/latest/configuration/#receiver) such as email, PagerDuty, OpsGenie, or many other [mechanisms](https://prometheus.io/docs/operating/integrations/#alertmanager-webhook-receiver) thanks to the webhook receiver. It also takes care of silencing and inhibition of alerts. * [Documentation](http://prometheus.io/docs/alerting/alertmanager/) ## Install There are various ways of installing Alertmanager. ### Precompiled binaries Precompiled binaries for released versions are available in the [*download* section](https://prometheus.io/download/) on [prometheus.io](https://prometheus.io). Using the latest production release binary is the recommended way of installing Alertmanager. ### Docker images Docker images are available on [Quay.io](https://quay.io/repository/prometheus/alertmanager) or [Docker Hub](https://hub.docker.com/r/prom/alertmanager/). You can launch an Alertmanager container for trying it out with $ docker run --name alertmanager -d -p 127.0.0.1:9093:9093 quay.io/prometheus/alertmanager Alertmanager will now be reachable at http://localhost:9093/. ### Compiling the binary You can either `go install` it: ``` $ go install github.com/prometheus/alertmanager/cmd/...@latest # cd $GOPATH/src/github.com/prometheus/alertmanager $ alertmanager --config.file= ``` Or clone the repository and build manually: ``` $ mkdir -p $GOPATH/src/github.com/prometheus $ cd $GOPATH/src/github.com/prometheus $ git clone https://github.com/prometheus/alertmanager.git $ cd alertmanager $ make build $ ./alertmanager --config.file= ``` You can also build just one of the binaries in this repo by passing a name to the build function: ``` $ make build BINARIES=amtool ``` ## Example This is an example configuration that should cover most relevant aspects of the new YAML configuration format. The full documentation of the configuration can be found [here](https://prometheus.io/docs/alerting/configuration/). ```yaml global: # The smarthost and SMTP sender used for mail notifications. smtp_smarthost: 'localhost:25' smtp_from: 'alertmanager@example.org' # The root route on which each incoming alert enters. route: # The root route must not have any matchers as it is the entry point for # all alerts. It needs to have a receiver configured so alerts that do not # match any of the sub-routes are sent to someone. receiver: 'team-X-mails' # The labels by which incoming alerts are grouped together. For example, # multiple alerts coming in for cluster=A and alertname=LatencyHigh would # be batched into a single group. # # To aggregate by all possible labels use '...' as the sole label name. # This effectively disables aggregation entirely, passing through all # alerts as-is. This is unlikely to be what you want, unless you have # a very low alert volume or your upstream notification system performs # its own grouping. Example: group_by: [...] group_by: ['alertname', 'cluster'] # When a new group of alerts is created by an incoming alert, wait at # least 'group_wait' to send the initial notification. # This way ensures that you get multiple alerts for the same group that start # firing shortly after another are batched together on the first # notification. group_wait: 30s # When the first notification was sent, wait 'group_interval' to send a batch # of new alerts that started firing for that group. group_interval: 5m # If an alert has successfully been sent, wait 'repeat_interval' to # resend them. repeat_interval: 3h # All the above attributes are inherited by all child routes and can # overwritten on each. # The child route trees. routes: # This route performs a regular expression match on alert labels to # catch alerts that are related to a list of services. - matchers: - service=~"^(foo1|foo2|baz)$" receiver: team-X-mails # The service has a sub-route for critical alerts, any alerts # that do not match, i.e. severity != critical, fall-back to the # parent node and are sent to 'team-X-mails' routes: - matchers: - severity="critical" receiver: team-X-pager - matchers: - service="files" receiver: team-Y-mails routes: - matchers: - severity="critical" receiver: team-Y-pager # This route handles all alerts coming from a database service. If there's # no team to handle it, it defaults to the DB team. - matchers: - service="database" receiver: team-DB-pager # Also group alerts by affected database. group_by: [alertname, cluster, database] routes: - matchers: - owner="team-X" receiver: team-X-pager - matchers: - owner="team-Y" receiver: team-Y-pager # Inhibition rules allow to mute a set of alerts given that another alert is # firing. # We use this to mute any warning-level notifications if the same alert is # already critical. inhibit_rules: - source_matchers: - severity="critical" target_matchers: - severity="warning" # Apply inhibition if the alertname is the same. # CAUTION: # If all label names listed in `equal` are missing # from both the source and target alerts, # the inhibition rule will apply! equal: ['alertname'] receivers: - name: 'team-X-mails' email_configs: - to: 'team-X+alerts@example.org, team-Y+alerts@example.org' - name: 'team-X-pager' email_configs: - to: 'team-X+alerts-critical@example.org' pagerduty_configs: - routing_key: - name: 'team-Y-mails' email_configs: - to: 'team-Y+alerts@example.org' - name: 'team-Y-pager' pagerduty_configs: - routing_key: - name: 'team-DB-pager' pagerduty_configs: - routing_key: ``` ## API The current Alertmanager API is version 2. This API is fully generated via the [OpenAPI project](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) and [Go Swagger](https://github.com/go-swagger/go-swagger/) with the exception of the HTTP handlers themselves. The API specification can be found in [api/v2/openapi.yaml](api/v2/openapi.yaml). A HTML rendered version can be accessed [here](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/prometheus/alertmanager/main/api/v2/openapi.yaml). Clients can be easily generated via any OpenAPI generator for all major languages. APIv2 is accessed via the `/api/v2` prefix. APIv1 was deprecated in `0.16.0` and is removed as of version `0.27.0`. The v2 `/status` endpoint would be `/api/v2/status`. If `--web.route-prefix` is set then API routes are prefixed with that as well, so `--web.route-prefix=/alertmanager/` would relate to `/alertmanager/api/v2/status`. ## amtool `amtool` is a cli tool for interacting with the Alertmanager API. It is bundled with all releases of Alertmanager. ### Install Alternatively you can install with: ``` $ go install github.com/prometheus/alertmanager/cmd/amtool@latest ``` ### Examples View all currently firing alerts: ``` $ amtool alert Alertname Starts At Summary Test_Alert 2017-08-02 18:30:18 UTC This is a testing alert! Test_Alert 2017-08-02 18:30:18 UTC This is a testing alert! Check_Foo_Fails 2017-08-02 18:30:18 UTC This is a testing alert! Check_Foo_Fails 2017-08-02 18:30:18 UTC This is a testing alert! ``` View all currently firing alerts with extended output: ``` $ amtool -o extended alert Labels Annotations Starts At Ends At Generator URL alertname="Test_Alert" instance="node0" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local alertname="Test_Alert" instance="node1" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local alertname="Check_Foo_Fails" instance="node0" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local alertname="Check_Foo_Fails" instance="node1" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local ``` In addition to viewing alerts, you can use the rich query syntax provided by Alertmanager: ``` $ amtool -o extended alert query alertname="Test_Alert" Labels Annotations Starts At Ends At Generator URL alertname="Test_Alert" instance="node0" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local alertname="Test_Alert" instance="node1" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local $ amtool -o extended alert query instance=~".+1" Labels Annotations Starts At Ends At Generator URL alertname="Test_Alert" instance="node1" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local alertname="Check_Foo_Fails" instance="node1" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local $ amtool -o extended alert query alertname=~"Test.*" instance=~".+1" Labels Annotations Starts At Ends At Generator URL alertname="Test_Alert" instance="node1" link="https://example.com" summary="This is a testing alert!" 2017-08-02 18:31:24 UTC 0001-01-01 00:00:00 UTC http://my.testing.script.local ``` Silence an alert: ``` $ amtool silence add alertname=Test_Alert b3ede22e-ca14-4aa0-932c-ca2f3445f926 $ amtool silence add alertname="Test_Alert" instance=~".+0" e48cb58a-0b17-49ba-b734-3585139b1d25 ``` View silences: ``` $ amtool silence query ID Matchers Ends At Created By Comment b3ede22e-ca14-4aa0-932c-ca2f3445f926 alertname=Test_Alert 2017-08-02 19:54:50 UTC kellel $ amtool silence query instance=~".+0" ID Matchers Ends At Created By Comment e48cb58a-0b17-49ba-b734-3585139b1d25 alertname=Test_Alert instance=~.+0 2017-08-02 22:41:39 UTC kellel ``` Expire a silence: ``` $ amtool silence expire b3ede22e-ca14-4aa0-932c-ca2f3445f926 ``` Expire all silences matching a query: ``` $ amtool silence query instance=~".+0" ID Matchers Ends At Created By Comment e48cb58a-0b17-49ba-b734-3585139b1d25 alertname=Test_Alert instance=~.+0 2017-08-02 22:41:39 UTC kellel $ amtool silence expire $(amtool silence query -q instance=~".+0") $ amtool silence query instance=~".+0" ``` Expire all silences: ``` $ amtool silence expire $(amtool silence query -q) ``` Try out how a template works. Let's say you have this in your configuration file: ``` templates: - '/foo/bar/*.tmpl' ``` Then you can test out how a template would look like with example by using this command: ``` amtool template render --template.glob='/foo/bar/*.tmpl' --template.text='{{ template "slack.default.markdown.v1" . }}' ``` ### Configuration `amtool` allows a configuration file to specify some options for convenience. The default configuration file paths are `$HOME/.config/amtool/config.yml` or `/etc/amtool/config.yml` An example configuration file might look like the following: ``` # Define the path that `amtool` can find your `alertmanager` instance alertmanager.url: "http://localhost:9093" # Override the default author. (unset defaults to your username) author: me@example.com # Force amtool to give you an error if you don't include a comment on a silence comment_required: true # Set a default output format. (unset defaults to simple) output: extended # Set a default receiver receiver: team-X-pager ``` ### Routes `amtool` allows you to visualize the routes of your configuration in form of text tree view. Also you can use it to test the routing by passing it label set of an alert and it prints out all receivers the alert would match ordered and separated by `,`. (If you use `--verify.receivers` amtool returns error code 1 on mismatch) Example of usage: ``` # View routing tree of remote Alertmanager $ amtool config routes --alertmanager.url=http://localhost:9090 # Test if alert matches expected receiver $ amtool config routes test --config.file=doc/examples/simple.yml --tree --verify.receivers=team-X-pager service=database owner=team-X ``` ## High Availability Alertmanager's high availability is in production use at many companies and is enabled by default. > Important: Both UDP and TCP are needed in alertmanager 0.15 and higher for the cluster to work. > - If you are using a firewall, make sure to whitelist the clustering port for both protocols. > - If you are running in a container, make sure to expose the clustering port for both protocols. To create a highly available cluster of the Alertmanager the instances need to be configured to communicate with each other. This is configured using the `--cluster.*` flags. - `--cluster.listen-address` string: cluster listen address (default "0.0.0.0:9094"; empty string disables HA mode) - `--cluster.advertise-address` string: cluster advertise address - `--cluster.peer` value: initial peers (repeat flag for each additional peer) - `--cluster.peer-timeout` value: peer timeout period (default "15s") - `--cluster.peers-resolve-timeout` value: peers resolve timeout period (default "15s") - `--cluster.gossip-interval` value: cluster message propagation speed (default "200ms") - `--cluster.pushpull-interval` value: lower values will increase convergence speeds at expense of bandwidth (default "1m0s") - `--cluster.settle-timeout` value: maximum time to wait for cluster connections to settle before evaluating notifications. - `--cluster.tcp-timeout` value: timeout value for tcp connections, reads and writes (default "10s") - `--cluster.probe-timeout` value: time to wait for ack before marking node unhealthy (default "500ms") - `--cluster.probe-interval` value: interval between random node probes (default "1s") - `--cluster.reconnect-interval` value: interval between attempting to reconnect to lost peers (default "10s") - `--cluster.reconnect-timeout` value: length of time to attempt to reconnect to a lost peer (default: "6h0m0s") - `--cluster.label` value: the label is an optional string to include on each packet and stream. It uniquely identifies the cluster and prevents cross-communication issues when sending gossip messages (default:"") The chosen port in the `cluster.listen-address` flag is the port that needs to be specified in the `cluster.peer` flag of the other peers. The `cluster.advertise-address` flag is required if the instance doesn't have an IP address that is part of [RFC 6890](https://tools.ietf.org/html/rfc6890) with a default route. To start a cluster of three peers on your local machine use [`goreman`](https://github.com/mattn/goreman) and the Procfile within this repository. goreman start To point your Prometheus 1.4, or later, instance to multiple Alertmanagers, configure them in your `prometheus.yml` configuration file, for example: ```yaml alerting: alertmanagers: - static_configs: - targets: - alertmanager1:9093 - alertmanager2:9093 - alertmanager3:9093 ``` > Important: Do not load balance traffic between Prometheus and its Alertmanagers, but instead point Prometheus to a list of all Alertmanagers. The Alertmanager implementation expects all alerts to be sent to all Alertmanagers to ensure high availability. ### Turn off high availability If running Alertmanager in high availability mode is not desired, setting `--cluster.listen-address=` prevents Alertmanager from listening to incoming peer requests. ## Contributing Check the [Prometheus contributing page](https://github.com/prometheus/prometheus/blob/main/CONTRIBUTING.md). To contribute to the user interface, refer to [ui/app/CONTRIBUTING.md](ui/app/CONTRIBUTING.md). ## Architecture ![](doc/arch.svg) ## License Apache License 2.0, see [LICENSE](https://github.com/prometheus/alertmanager/blob/main/LICENSE). [hub]: https://hub.docker.com/r/prom/alertmanager/ [circleci]: https://circleci.com/gh/prometheus/alertmanager [quay]: https://quay.io/repository/prometheus/alertmanager ================================================ FILE: RELEASE.md ================================================ # Releases This page describes the release process and the currently planned schedule for upcoming releases as well as the respective release shepherd. Release shepherds are chosen on a voluntary basis. ## Release Schedule Release cadence of first pre-releases being cut is 12 weeks. | release series | date (year-month-day) | release shepherd | |----------------|-----------------------|-------------------------------------------| | v0.26 | 2023-08-23 | Josh Abreu (Github: @gotjosh) | | v0.27 | 2024-02-28 | Josh Abreu (Github: @gotjosh) | | v0.28 | 2024-05-28 | Josh Abreu (Github: @gotjosh) | | v0.29 | 2025-11-01 | Joe Adams (Github: @sysadmind) | | v0.30 | 2025-12-12 | Solomon Jacobs (Github: @SoloJacobs) | | v0.31 | 2026-01-31 | Solomon Jacobs (Github: @SoloJacobs) | | v0.32 | 2026-04-06 | Anand Rajagopal (Github: @rajagopalanand) | | v0.33 | 2026-06-06 | **volunteer welcome** | If you are interested in volunteering please create a pull request against the [prometheus/alertmanager](https://github.com/prometheus/alertmanager) repository and propose yourself for the release of your choice. If you'd like to know more about the shepherd responsibilities or the release instructions please [refer to the `RELEASE.MD`](https://github.com/prometheus/prometheus/blob/main/RELEASE.md) in [prometheus/prometheus](https://github.com/prometheus/prometheus). ================================================ FILE: SECURITY.md ================================================ # Reporting a security issue The Prometheus security policy, including how to report vulnerabilities, can be found here: ================================================ FILE: VERSION ================================================ 0.31.1 ================================================ FILE: alert/alert.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package alert import ( "fmt" "time" "github.com/prometheus/common/model" ) // Alert wraps a model.Alert with additional information relevant // to internal of the Alertmanager. // The type is never exposed to external communication and the // embedded alert has to be sanitized beforehand. type Alert struct { model.Alert // The authoritative timestamp. UpdatedAt time.Time Timeout bool } // Merge merges the timespan of two alerts based and overwrites annotations // based on the authoritative timestamp. A new alert is returned, the labels // are assumed to be equal. func (a *Alert) Merge(o *Alert) *Alert { // Let o always be the younger alert. if o.UpdatedAt.Before(a.UpdatedAt) { return o.Merge(a) } res := *o // Always pick the earliest starting time. if a.StartsAt.Before(o.StartsAt) { res.StartsAt = a.StartsAt } if o.Resolved() { // The latest explicit resolved timestamp wins if both alerts are effectively resolved. if a.Resolved() && a.EndsAt.After(o.EndsAt) { res.EndsAt = a.EndsAt } } else { // A non-timeout timestamp always rules if it is the latest. if a.EndsAt.After(o.EndsAt) && !a.Timeout { res.EndsAt = a.EndsAt } } return &res } // Validate overrides the same method in model.Alert to allow UTF-8 labels. // This can be removed once prometheus/common has support for UTF-8. func (a *Alert) Validate() error { if a.StartsAt.IsZero() { return fmt.Errorf("start time missing") } if !a.EndsAt.IsZero() && a.EndsAt.Before(a.StartsAt) { return fmt.Errorf("start time must be before end time") } if len(a.Labels) == 0 { return fmt.Errorf("at least one label pair required") } if err := validateLs(a.Labels); err != nil { return fmt.Errorf("invalid label set: %w", err) } if err := validateLs(a.Annotations); err != nil { return fmt.Errorf("invalid annotations: %w", err) } return nil } // AlertSlice is a sortable slice of Alerts. type AlertSlice []*Alert func (as AlertSlice) Less(i, j int) bool { // Look at labels.job, then labels.instance. for _, overrideKey := range [...]model.LabelName{"job", "instance"} { iVal, iOk := as[i].Labels[overrideKey] jVal, jOk := as[j].Labels[overrideKey] if !iOk && !jOk { continue } if !iOk { return false } if !jOk { return true } if iVal != jVal { return iVal < jVal } } return as[i].Labels.Before(as[j].Labels) } func (as AlertSlice) Swap(i, j int) { as[i], as[j] = as[j], as[i] } func (as AlertSlice) Len() int { return len(as) } // Alerts turns a sequence of internal alerts into a list of // exposable model.Alert structures. func Alerts(alerts ...*Alert) model.Alerts { res := make(model.Alerts, 0, len(alerts)) for _, a := range alerts { v := a.Alert // If the end timestamp is not reached yet, do not expose it. if !a.Resolved() { v.EndsAt = time.Time{} } res = append(res, &v) } return res } ================================================ FILE: alert/alert_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package alert import ( "reflect" "sort" "strconv" "testing" "time" "github.com/prometheus/common/model" ) func TestAlertMerge(t *testing.T) { now := time.Now() // By convention, alert A is always older than alert B. pairs := []struct { A, B, Res *Alert }{ { // Both alerts have the Timeout flag set. // StartsAt is defined by Alert A. // EndsAt is defined by Alert B. A: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now.Add(2 * time.Minute), }, UpdatedAt: now, Timeout: true, }, B: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now.Add(time.Minute), Timeout: true, }, Res: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now.Add(time.Minute), Timeout: true, }, }, { // Alert A has the Timeout flag set while Alert B has it unset. // StartsAt is defined by Alert A. // EndsAt is defined by Alert B. A: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now, Timeout: true, }, B: &Alert{ Alert: model.Alert{ StartsAt: now, EndsAt: now.Add(2 * time.Minute), }, UpdatedAt: now.Add(time.Minute), }, Res: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(2 * time.Minute), }, UpdatedAt: now.Add(time.Minute), }, }, { // Alert A has the Timeout flag unset while Alert B has it set. // StartsAt is defined by Alert A. // EndsAt is defined by Alert A. A: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now, }, B: &Alert{ Alert: model.Alert{ StartsAt: now, EndsAt: now.Add(2 * time.Minute), }, UpdatedAt: now.Add(time.Minute), Timeout: true, }, Res: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now.Add(time.Minute), Timeout: true, }, }, { // Both alerts have the Timeout flag unset and are not resolved. // StartsAt is defined by Alert A. // EndsAt is defined by Alert A. A: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now, }, B: &Alert{ Alert: model.Alert{ StartsAt: now, EndsAt: now.Add(2 * time.Minute), }, UpdatedAt: now.Add(time.Minute), }, Res: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now.Add(time.Minute), }, }, { // Both alerts have the Timeout flag unset and are not resolved. // StartsAt is defined by Alert A. // EndsAt is defined by Alert B. A: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now, }, B: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(4 * time.Minute), }, UpdatedAt: now.Add(time.Minute), }, Res: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-time.Minute), EndsAt: now.Add(4 * time.Minute), }, UpdatedAt: now.Add(time.Minute), }, }, { // Both alerts have the Timeout flag unset, A is resolved while B isn't. // StartsAt is defined by Alert A. // EndsAt is defined by Alert B. A: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-3 * time.Minute), EndsAt: now.Add(-time.Minute), }, UpdatedAt: now, }, B: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now.Add(time.Minute), }, UpdatedAt: now.Add(time.Minute), }, Res: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-3 * time.Minute), EndsAt: now.Add(time.Minute), }, UpdatedAt: now.Add(time.Minute), }, }, { // Both alerts have the Timeout flag unset, B is resolved while A isn't. // StartsAt is defined by Alert A. // EndsAt is defined by Alert B. A: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now.Add(3 * time.Minute), }, UpdatedAt: now, }, B: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now, }, UpdatedAt: now.Add(time.Minute), }, Res: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now, }, UpdatedAt: now.Add(time.Minute), }, }, { // Both alerts are resolved (EndsAt < now). // StartsAt is defined by Alert B. // EndsAt is defined by Alert A. A: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-3 * time.Minute), EndsAt: now.Add(-time.Minute), }, UpdatedAt: now.Add(-time.Minute), }, B: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-4 * time.Minute), EndsAt: now.Add(-2 * time.Minute), }, UpdatedAt: now.Add(time.Minute), }, Res: &Alert{ Alert: model.Alert{ StartsAt: now.Add(-4 * time.Minute), EndsAt: now.Add(-1 * time.Minute), }, UpdatedAt: now.Add(time.Minute), }, }, } for i, p := range pairs { t.Run(strconv.Itoa(i), func(t *testing.T) { if res := p.A.Merge(p.B); !reflect.DeepEqual(p.Res, res) { t.Errorf("unexpected merged alert %#v", res) } if res := p.B.Merge(p.A); !reflect.DeepEqual(p.Res, res) { t.Errorf("unexpected merged alert %#v", res) } }) } } func TestAlertSliceSort(t *testing.T) { var ( a1 = &Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "job": "j1", "instance": "i1", "alertname": "an1", }, }, } a2 = &Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "job": "j1", "instance": "i1", "alertname": "an2", }, }, } a3 = &Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "job": "j2", "instance": "i1", "alertname": "an1", }, }, } a4 = &Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "an1", }, }, } a5 = &Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "an2", }, }, } ) cases := []struct { alerts AlertSlice exp AlertSlice }{ { alerts: AlertSlice{a2, a1}, exp: AlertSlice{a1, a2}, }, { alerts: AlertSlice{a3, a2, a1}, exp: AlertSlice{a1, a2, a3}, }, { alerts: AlertSlice{a4, a2, a4}, exp: AlertSlice{a2, a4, a4}, }, { alerts: AlertSlice{a5, a4}, exp: AlertSlice{a4, a5}, }, } for _, tc := range cases { sort.Stable(tc.alerts) if !reflect.DeepEqual(tc.alerts, tc.exp) { t.Fatalf("expected %v but got %v", tc.exp, tc.alerts) } } } ================================================ FILE: alert/state.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package alert // AlertState is used as part of AlertStatus. type AlertState string // Possible values for AlertState. const ( AlertStateUnprocessed AlertState = "unprocessed" AlertStateActive AlertState = "active" AlertStateSuppressed AlertState = "suppressed" ) ================================================ FILE: alert/status.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package alert // AlertStatus stores the state of an alert and, as applicable, the IDs of // silences silencing the alert and of other alerts inhibiting the alert. Note // that currently, SilencedBy is supposed to be the complete set of the relevant // silences while InhibitedBy may contain only a subset of the inhibiting alerts // – in practice exactly one ID. (This somewhat confusing semantics might change // in the future.) type AlertStatus struct { State AlertState `json:"state"` SilencedBy []string `json:"silencedBy"` InhibitedBy []string `json:"inhibitedBy"` } ================================================ FILE: alert/validate.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package alert import ( "fmt" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/matcher/compat" ) func validateLs(ls model.LabelSet) error { for ln, lv := range ls { if !compat.IsValidLabelName(ln) { return fmt.Errorf("invalid name %q", ln) } if !lv.IsValid() { return fmt.Errorf("invalid value %q", lv) } } return nil } ================================================ FILE: alert/validate_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package alert import ( "testing" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/matcher/compat" ) func TestValidateUTF8Ls(t *testing.T) { tests := []struct { name string ls model.LabelSet err string }{{ name: "valid UTF-8 label set", ls: model.LabelSet{ "a": "a", "00": "b", "Σ": "c", "\xf0\x9f\x99\x82": "dΘ", }, }, { name: "invalid UTF-8 label set", ls: model.LabelSet{ "\xff": "a", }, err: "invalid name \"\\xff\"", }} // Change the mode to UTF-8 mode. ff, err := featurecontrol.NewFlags(promslog.NewNopLogger(), featurecontrol.FeatureUTF8StrictMode) require.NoError(t, err) compat.InitFromFlags(promslog.NewNopLogger(), ff) // Restore the mode to classic at the end of the test. ff, err = featurecontrol.NewFlags(promslog.NewNopLogger(), featurecontrol.FeatureClassicMode) require.NoError(t, err) defer compat.InitFromFlags(promslog.NewNopLogger(), ff) for _, test := range tests { t.Run(test.name, func(t *testing.T) { err := validateLs(test.ls) if err != nil && err.Error() != test.err { t.Errorf("unexpected err for %s: %s", test.ls, err) } else if err == nil && test.err != "" { t.Error("expected error, got nil") } }) } } ================================================ FILE: api/api.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package api import ( "context" "errors" "fmt" "log/slog" "net/http" "runtime" "strings" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/prometheus/common/route" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" apiv2 "github.com/prometheus/alertmanager/api/v2" "github.com/prometheus/alertmanager/cluster" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/dispatch" "github.com/prometheus/alertmanager/provider" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/types" ) // API represents all APIs of Alertmanager. type API struct { v2 *apiv2.API deprecationRouter *V1DeprecationRouter requestDuration *prometheus.HistogramVec requestsInFlight prometheus.Gauge concurrencyLimitExceeded prometheus.Counter timeout time.Duration inFlightSem chan struct{} } // Options for the creation of an API object. Alerts, Silences, AlertStatusFunc // and GroupMutedFunc are mandatory. The zero value for everything else is a safe // default. type Options struct { // Alerts to be used by the API. Mandatory. Alerts provider.Alerts // Silences to be used by the API. Mandatory. Silences *silence.Silences // AlertStatusFunc is used be the API to retrieve the AlertStatus of an // alert. Mandatory. AlertStatusFunc func(model.Fingerprint) types.AlertStatus // GroupMutedFunc is used be the API to know if an alert is muted. // Mandatory. GroupMutedFunc func(routeID, groupKey string) ([]string, bool) // Peer from the gossip cluster. If nil, no clustering will be used. Peer cluster.ClusterPeer // Timeout for all HTTP connections. The zero value (and negative // values) result in no timeout. Timeout time.Duration // Concurrency limit for GET requests. The zero value (and negative // values) result in a limit of GOMAXPROCS or 8, whichever is // larger. Status code 503 is served for GET requests that would exceed // the concurrency limit. Concurrency int // Logger is used for logging, if nil, no logging will happen. Logger *slog.Logger // Registry is used to register Prometheus metrics. If nil, no metrics // registration will happen. Registry prometheus.Registerer // RequestDuration is used to measure the duration of HTTP requests. RequestDuration *prometheus.HistogramVec // GroupFunc returns a list of alert groups. The alerts are grouped // according to the current active configuration. Alerts returned are // filtered by the arguments provided to the function. GroupFunc func(context.Context, func(*dispatch.Route) bool, func(*types.Alert, time.Time) bool) (dispatch.AlertGroups, map[model.Fingerprint][]string, error) } func (o Options) validate() error { if o.Alerts == nil { return errors.New("mandatory field Alerts not set") } if o.Silences == nil { return errors.New("mandatory field Silences not set") } if o.AlertStatusFunc == nil { return errors.New("mandatory field AlertStatusFunc not set") } if o.GroupMutedFunc == nil { return errors.New("mandatory field GroupMutedFunc not set") } if o.GroupFunc == nil { return errors.New("mandatory field GroupFunc not set") } return nil } // New creates a new API object combining all API versions. Note that an Update // call is also needed to get the APIs into an operational state. func New(opts Options) (*API, error) { if err := opts.validate(); err != nil { return nil, fmt.Errorf("invalid API options: %w", err) } l := opts.Logger if l == nil { l = promslog.NewNopLogger() } concurrency := opts.Concurrency if concurrency < 1 { concurrency = max(runtime.GOMAXPROCS(0), 8) } v2, err := apiv2.NewAPI( opts.Alerts, opts.GroupFunc, opts.AlertStatusFunc, opts.GroupMutedFunc, opts.Silences, opts.Peer, l.With("version", "v2"), opts.Registry, ) if err != nil { return nil, err } requestsInFlight := prometheus.NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_http_requests_in_flight", Help: "Current number of HTTP requests being processed.", ConstLabels: prometheus.Labels{"method": "get"}, }) concurrencyLimitExceeded := prometheus.NewCounter(prometheus.CounterOpts{ Name: "alertmanager_http_concurrency_limit_exceeded_total", Help: "Total number of times an HTTP request failed because the concurrency limit was reached.", ConstLabels: prometheus.Labels{"method": "get"}, }) if opts.Registry != nil { if err := opts.Registry.Register(requestsInFlight); err != nil { return nil, err } if err := opts.Registry.Register(concurrencyLimitExceeded); err != nil { return nil, err } } return &API{ deprecationRouter: NewV1DeprecationRouter(l.With("version", "v1")), v2: v2, requestDuration: opts.RequestDuration, requestsInFlight: requestsInFlight, concurrencyLimitExceeded: concurrencyLimitExceeded, timeout: opts.Timeout, inFlightSem: make(chan struct{}, concurrency), }, nil } // Register API. As APIv2 works on the http.Handler level, this method also creates a new // http.ServeMux and then uses it to register both the provided router (to // handle "/") and APIv2 (to handle "/api/v2"). The method returns // the newly created http.ServeMux. If a timeout has been set on construction of // API, it is enforced for all HTTP request going through this mux. The same is // true for the concurrency limit, with the exception that it is only applied to // GET requests. func (api *API) Register(r *route.Router, routePrefix string) *http.ServeMux { // TODO(gotjosh) API V1 was removed as of version 0.27, when we reach 1.0.0 we should removed these deprecation warnings. api.deprecationRouter.Register(r.WithPrefix("/api/v1")) mux := http.NewServeMux() mux.Handle("/", api.limitHandler(r)) apiPrefix := "" if routePrefix != "/" { apiPrefix = routePrefix } mux.Handle( apiPrefix+"/api/v2/", api.instrumentHandler( apiPrefix, api.limitHandler( http.StripPrefix( apiPrefix, api.v2.Handler, ), ), ), ) return mux } // Update config and resolve timeout of each API. APIv2 also needs // setAlertStatus to be updated. func (api *API) Update(cfg *config.Config, setAlertStatus func(ctx context.Context, labels model.LabelSet)) { api.v2.Update(cfg, setAlertStatus) } func (api *API) limitHandler(h http.Handler) http.Handler { concLimiter := http.HandlerFunc(func(rsp http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { // Only limit concurrency of GETs. select { case api.inFlightSem <- struct{}{}: // All good, carry on. api.requestsInFlight.Inc() defer func() { <-api.inFlightSem api.requestsInFlight.Dec() }() default: api.concurrencyLimitExceeded.Inc() http.Error(rsp, fmt.Sprintf( "Limit of concurrent GET requests reached (%d), try again later.\n", cap(api.inFlightSem), ), http.StatusServiceUnavailable) return } } h.ServeHTTP(rsp, req) }) if api.timeout <= 0 { return concLimiter } return http.TimeoutHandler(concLimiter, api.timeout, fmt.Sprintf( "Exceeded configured timeout of %v.\n", api.timeout, )) } func (api *API) instrumentHandler(prefix string, h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path, _ := strings.CutPrefix(r.URL.Path, prefix) // avoid high cardinality label values by replacing the actual silence IDs with a placeholder if strings.HasPrefix(path, "/api/v2/silence/") { path = "/api/v2/silence/{silenceID}" } promhttp.InstrumentHandlerDuration( api.requestDuration.MustCurryWith(prometheus.Labels{"handler": path}), otelhttp.NewHandler(h, path), ).ServeHTTP(w, r) }) } ================================================ FILE: api/metrics/metrics.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package metrics import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) // Alerts stores metrics for alerts. type Alerts struct { firing prometheus.Counter resolved prometheus.Counter invalid prometheus.Counter } // NewAlerts returns an *Alerts struct for the given API version. // Since v1 was deprecated in 0.27, v2 is now hardcoded. func NewAlerts(r prometheus.Registerer) *Alerts { if r == nil { return nil } numReceivedAlerts := promauto.With(r).NewCounterVec(prometheus.CounterOpts{ Name: "alertmanager_alerts_received_total", Help: "The total number of received alerts.", ConstLabels: prometheus.Labels{"version": "v2"}, }, []string{"status"}) numInvalidAlerts := promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_alerts_invalid_total", Help: "The total number of received alerts that were invalid.", ConstLabels: prometheus.Labels{"version": "v2"}, }) return &Alerts{ firing: numReceivedAlerts.WithLabelValues("firing"), resolved: numReceivedAlerts.WithLabelValues("resolved"), invalid: numInvalidAlerts, } } // Firing returns a counter of firing alerts. func (a *Alerts) Firing() prometheus.Counter { return a.firing } // Resolved returns a counter of resolved alerts. func (a *Alerts) Resolved() prometheus.Counter { return a.resolved } // Invalid returns a counter of invalid alerts. func (a *Alerts) Invalid() prometheus.Counter { return a.invalid } ================================================ FILE: api/v1_deprecation_router.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. package api import ( "encoding/json" "log/slog" "net/http" "github.com/prometheus/common/route" ) // V1DeprecationRouter is the router to signal v1 users that the API v1 is now removed. type V1DeprecationRouter struct { logger *slog.Logger } // NewV1DeprecationRouter returns a new V1DeprecationRouter. func NewV1DeprecationRouter(l *slog.Logger) *V1DeprecationRouter { return &V1DeprecationRouter{ logger: l, } } // Register registers all the API v1 routes with an endpoint that returns a JSON deprecation notice and a logs a warning. func (dr *V1DeprecationRouter) Register(r *route.Router) { r.Get("/status", dr.deprecationHandler) r.Get("/receivers", dr.deprecationHandler) r.Get("/alerts", dr.deprecationHandler) r.Post("/alerts", dr.deprecationHandler) r.Get("/silences", dr.deprecationHandler) r.Post("/silences", dr.deprecationHandler) r.Get("/silence/:sid", dr.deprecationHandler) r.Del("/silence/:sid", dr.deprecationHandler) } func (dr *V1DeprecationRouter) deprecationHandler(w http.ResponseWriter, req *http.Request) { dr.logger.Warn("v1 API received a request on a removed endpoint", "path", req.URL.Path, "method", req.Method) resp := struct { Status string `json:"status"` Error string `json:"error"` }{ "deprecated", "The Alertmanager v1 API was deprecated in version 0.16.0 and is removed as of version 0.27.0 - please use the equivalent route in the v2 API", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(410) if err := json.NewEncoder(w).Encode(resp); err != nil { dr.logger.Error("failed to write response", "err", err) } } ================================================ FILE: api/v2/api.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v2 import ( "context" "errors" "fmt" "log/slog" "net/http" "regexp" "slices" "sort" "sync" "time" "github.com/go-openapi/analysis" "github.com/go-openapi/loads" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/strfmt" "github.com/prometheus/client_golang/prometheus" prometheus_model "github.com/prometheus/common/model" "github.com/prometheus/common/version" "github.com/rs/cors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/codes" "github.com/prometheus/alertmanager/api/metrics" open_api_models "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/api/v2/restapi" "github.com/prometheus/alertmanager/api/v2/restapi/operations" alert_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/alert" alertgroup_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/alertgroup" general_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/general" receiver_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/receiver" silence_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/silence" "github.com/prometheus/alertmanager/cluster" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/dispatch" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/alertmanager/provider" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/alertmanager/types" ) var tracer = otel.Tracer("github.com/prometheus/alertmanager/api/v2") // API represents an Alertmanager API v2. type API struct { peer cluster.ClusterPeer silences *silence.Silences alerts provider.Alerts alertGroups groupsFn getAlertStatus getAlertStatusFn groupMutedFunc groupMutedFunc uptime time.Time // mtx protects alertmanagerConfig, setAlertStatus and route. mtx sync.RWMutex // resolveTimeout represents the default resolve timeout that an alert is // assigned if no end time is specified. alertmanagerConfig *config.Config route *dispatch.Route setAlertStatus setAlertStatusFn logger *slog.Logger m *metrics.Alerts Handler http.Handler } type ( groupsFn func(context.Context, func(*dispatch.Route) bool, func(*types.Alert, time.Time) bool) (dispatch.AlertGroups, map[prometheus_model.Fingerprint][]string, error) groupMutedFunc func(routeID, groupKey string) ([]string, bool) getAlertStatusFn func(prometheus_model.Fingerprint) types.AlertStatus setAlertStatusFn func(ctx context.Context, labels prometheus_model.LabelSet) ) // NewAPI returns a new Alertmanager API v2. func NewAPI( alerts provider.Alerts, gf groupsFn, asf getAlertStatusFn, gmf groupMutedFunc, silences *silence.Silences, peer cluster.ClusterPeer, l *slog.Logger, r prometheus.Registerer, ) (*API, error) { api := API{ alerts: alerts, getAlertStatus: asf, alertGroups: gf, groupMutedFunc: gmf, peer: peer, silences: silences, logger: l, m: metrics.NewAlerts(r), uptime: time.Now(), } // Load embedded swagger file. swaggerSpec, swaggerSpecAnalysis, err := getSwaggerSpec() if err != nil { return nil, err } // Create new service API. openAPI := operations.NewAlertmanagerAPI(swaggerSpec) // Skip the redoc middleware, only serving the OpenAPI specification and // the API itself via RoutesHandler. See: // https://github.com/go-swagger/go-swagger/issues/1779 openAPI.Middleware = func(b middleware.Builder) http.Handler { // Manually create the context so that we can use the singleton swaggerSpecAnalysis. swaggerContext := middleware.NewRoutableContextWithAnalyzedSpec(swaggerSpec, swaggerSpecAnalysis, openAPI, nil) return middleware.Spec("", swaggerSpec.Raw(), swaggerContext.RoutesHandler(b)) } openAPI.AlertGetAlertsHandler = alert_ops.GetAlertsHandlerFunc(api.getAlertsHandler) openAPI.AlertPostAlertsHandler = alert_ops.PostAlertsHandlerFunc(api.postAlertsHandler) openAPI.AlertgroupGetAlertGroupsHandler = alertgroup_ops.GetAlertGroupsHandlerFunc(api.getAlertGroupsHandler) openAPI.GeneralGetStatusHandler = general_ops.GetStatusHandlerFunc(api.getStatusHandler) openAPI.ReceiverGetReceiversHandler = receiver_ops.GetReceiversHandlerFunc(api.getReceiversHandler) openAPI.SilenceDeleteSilenceHandler = silence_ops.DeleteSilenceHandlerFunc(api.deleteSilenceHandler) openAPI.SilenceGetSilenceHandler = silence_ops.GetSilenceHandlerFunc(api.getSilenceHandler) openAPI.SilenceGetSilencesHandler = silence_ops.GetSilencesHandlerFunc(api.getSilencesHandler) openAPI.SilencePostSilencesHandler = silence_ops.PostSilencesHandlerFunc(api.postSilencesHandler) handleCORS := cors.Default().Handler api.Handler = handleCORS(setResponseHeaders(openAPI.Serve(nil))) return &api, nil } var responseHeaders = map[string]string{ "Cache-Control": "no-store", } func setResponseHeaders(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { for h, v := range responseHeaders { w.Header().Set(h, v) } h.ServeHTTP(w, r) }) } func (api *API) requestLogger(req *http.Request) *slog.Logger { return api.logger.With("path", req.URL.Path, "method", req.Method) } // Update sets the API struct members that may change between reloads of alertmanager. func (api *API) Update(cfg *config.Config, setAlertStatus setAlertStatusFn) { api.mtx.Lock() defer api.mtx.Unlock() api.alertmanagerConfig = cfg api.route = dispatch.NewRoute(cfg.Route, nil) api.setAlertStatus = setAlertStatus } func (api *API) getStatusHandler(params general_ops.GetStatusParams) middleware.Responder { api.mtx.RLock() defer api.mtx.RUnlock() _, span := tracer.Start(params.HTTPRequest.Context(), "api.getStatusHandler") defer span.End() original := api.alertmanagerConfig.String() uptime := strfmt.DateTime(api.uptime) status := open_api_models.ClusterStatusStatusDisabled resp := open_api_models.AlertmanagerStatus{ Uptime: &uptime, VersionInfo: &open_api_models.VersionInfo{ Version: &version.Version, Revision: &version.Revision, Branch: &version.Branch, BuildUser: &version.BuildUser, BuildDate: &version.BuildDate, GoVersion: &version.GoVersion, }, Config: &open_api_models.AlertmanagerConfig{ Original: &original, }, Cluster: &open_api_models.ClusterStatus{ Status: &status, Peers: []*open_api_models.PeerStatus{}, }, } // If alertmanager cluster feature is disabled, then api.peers == nil. if api.peer != nil { status := api.peer.Status() peers := []*open_api_models.PeerStatus{} for _, n := range api.peer.Peers() { address := n.Address() name := n.Name() peers = append(peers, &open_api_models.PeerStatus{ Name: &name, Address: &address, }) } sort.Slice(peers, func(i, j int) bool { return *peers[i].Name < *peers[j].Name }) resp.Cluster = &open_api_models.ClusterStatus{ Name: api.peer.Name(), Status: &status, Peers: peers, } } return general_ops.NewGetStatusOK().WithPayload(&resp) } func (api *API) getReceiversHandler(params receiver_ops.GetReceiversParams) middleware.Responder { api.mtx.RLock() defer api.mtx.RUnlock() _, span := tracer.Start(params.HTTPRequest.Context(), "api.getReceiversHandler") defer span.End() receivers := make([]*open_api_models.Receiver, 0, len(api.alertmanagerConfig.Receivers)) for i := range api.alertmanagerConfig.Receivers { receivers = append(receivers, &open_api_models.Receiver{Name: &api.alertmanagerConfig.Receivers[i].Name}) } return receiver_ops.NewGetReceiversOK().WithPayload(receivers) } func (api *API) getAlertsHandler(params alert_ops.GetAlertsParams) middleware.Responder { var ( receiverFilter *regexp.Regexp // Initialize result slice to prevent api returning `null` when there // are no alerts present res = open_api_models.GettableAlerts{} logger = api.requestLogger(params.HTTPRequest) ) ctx, span := tracer.Start(params.HTTPRequest.Context(), "api.getAlertsHandler") defer span.End() matchers, err := parseFilter(params.Filter) if err != nil { logger.Debug("Failed to parse matchers", "err", err) return alertgroup_ops.NewGetAlertGroupsBadRequest().WithPayload(err.Error()) } if params.Receiver != nil { receiverFilter, err = regexp.Compile("^(?:" + *params.Receiver + ")$") if err != nil { logger.Debug("Failed to compile receiver regex", "err", err) return alert_ops. NewGetAlertsBadRequest(). WithPayload( fmt.Sprintf("failed to parse receiver param: %v", err.Error()), ) } } alerts := api.alerts.GetPending() defer alerts.Close() alertFilter := api.alertFilter(matchers, *params.Silenced, *params.Inhibited, *params.Active) now := time.Now() api.mtx.RLock() for a := range alerts.Next() { alert := a.Data if err = alerts.Err(); err != nil { break } if err = ctx.Err(); err != nil { break } routes := api.route.Match(alert.Labels) receivers := make([]string, 0, len(routes)) for _, r := range routes { receivers = append(receivers, r.RouteOpts.Receiver) } if receiverFilter != nil && !slices.ContainsFunc(receivers, receiverFilter.MatchString) { continue } if !alertFilter(alert, now) { continue } openAlert := AlertToOpenAPIAlert(alert, api.getAlertStatus(alert.Fingerprint()), receivers, nil) res = append(res, openAlert) } api.mtx.RUnlock() if err != nil { logger.Error("Failed to get alerts", "err", err) return alert_ops.NewGetAlertsInternalServerError().WithPayload(err.Error()) } sort.Slice(res, func(i, j int) bool { return *res[i].Fingerprint < *res[j].Fingerprint }) return alert_ops.NewGetAlertsOK().WithPayload(res) } func (api *API) postAlertsHandler(params alert_ops.PostAlertsParams) middleware.Responder { logger := api.requestLogger(params.HTTPRequest) ctx, span := tracer.Start(params.HTTPRequest.Context(), "api.postAlertsHandler") defer span.End() alerts := OpenAPIAlertsToAlerts(ctx, params.Alerts) now := time.Now() api.mtx.RLock() resolveTimeout := time.Duration(api.alertmanagerConfig.Global.ResolveTimeout) api.mtx.RUnlock() for _, alert := range alerts { alert.UpdatedAt = now // Ensure StartsAt is set. if alert.StartsAt.IsZero() { if alert.EndsAt.IsZero() { alert.StartsAt = now } else { alert.StartsAt = alert.EndsAt } } // If no end time is defined, set a timeout after which an alert // is marked resolved if it is not updated. if alert.EndsAt.IsZero() { alert.Timeout = true alert.EndsAt = now.Add(resolveTimeout) } if alert.EndsAt.After(time.Now()) { api.m.Firing().Inc() } else { api.m.Resolved().Inc() } } // Make a best effort to insert all alerts that are valid. var ( validAlerts = make([]*types.Alert, 0, len(alerts)) validationErrs error ) for _, a := range alerts { removeEmptyLabels(a.Labels) if err := a.Validate(); err != nil { validationErrs = errors.Join(validationErrs, err) api.m.Invalid().Inc() continue } validAlerts = append(validAlerts, a) } if err := api.alerts.Put(ctx, validAlerts...); err != nil { message := "Failed to create alerts" logger.Error(message, "err", err) span.SetStatus(codes.Error, message) span.RecordError(err) return alert_ops.NewPostAlertsInternalServerError().WithPayload(err.Error()) } if validationErrs != nil { message := "Failed to validate alerts" logger.Error(message, "err", validationErrs.Error()) span.SetStatus(codes.Error, message) span.RecordError(validationErrs) return alert_ops.NewPostAlertsBadRequest().WithPayload(validationErrs.Error()) } return alert_ops.NewPostAlertsOK() } func (api *API) getAlertGroupsHandler(params alertgroup_ops.GetAlertGroupsParams) middleware.Responder { logger := api.requestLogger(params.HTTPRequest) ctx, span := tracer.Start(params.HTTPRequest.Context(), "api.getAlertGroupsHandler") defer span.End() matchers, err := parseFilter(params.Filter) if err != nil { logger.Debug("Failed to parse matchers", "err", err) return alertgroup_ops.NewGetAlertGroupsBadRequest().WithPayload(err.Error()) } var receiverFilter *regexp.Regexp if params.Receiver != nil { receiverFilter, err = regexp.Compile("^(?:" + *params.Receiver + ")$") if err != nil { logger.Error("Failed to compile receiver regex", "err", err) return alertgroup_ops. NewGetAlertGroupsBadRequest(). WithPayload( fmt.Sprintf("failed to parse receiver param: %v", err.Error()), ) } } rf := func(receiverFilter *regexp.Regexp) func(r *dispatch.Route) bool { return func(r *dispatch.Route) bool { receiver := r.RouteOpts.Receiver if receiverFilter != nil && !receiverFilter.MatchString(receiver) { return false } return true } }(receiverFilter) af := api.alertFilter(matchers, *params.Silenced, *params.Inhibited, *params.Active) alertGroups, allReceivers, err := api.alertGroups(ctx, rf, af) if err != nil { message := "Failed to get alert groups" logger.Error(message, "err", err) span.SetStatus(codes.Error, message) span.RecordError(err) return alertgroup_ops.NewGetAlertGroupsInternalServerError() } res := make(open_api_models.AlertGroups, 0, len(alertGroups)) for _, alertGroup := range alertGroups { mutedBy, isMuted := api.groupMutedFunc(alertGroup.RouteID, alertGroup.GroupKey) if !*params.Muted && isMuted { continue } ag := &open_api_models.AlertGroup{ Receiver: &open_api_models.Receiver{Name: &alertGroup.Receiver}, Labels: ModelLabelSetToAPILabelSet(alertGroup.Labels), Alerts: make([]*open_api_models.GettableAlert, 0, len(alertGroup.Alerts)), } for _, alert := range alertGroup.Alerts { fp := alert.Fingerprint() receivers := allReceivers[fp] status := api.getAlertStatus(fp) apiAlert := AlertToOpenAPIAlert(alert, status, receivers, mutedBy) ag.Alerts = append(ag.Alerts, apiAlert) } res = append(res, ag) } return alertgroup_ops.NewGetAlertGroupsOK().WithPayload(res) } func (api *API) alertFilter(matchers []*labels.Matcher, silenced, inhibited, active bool) func(a *types.Alert, now time.Time) bool { return func(a *types.Alert, now time.Time) bool { ctx, span := tracer.Start(context.Background(), "alertFilter") defer span.End() if !a.EndsAt.IsZero() && a.EndsAt.Before(now) { return false } // Set alert's current status based on its label set. api.setAlertStatus(ctx, a.Labels) // Get alert's current status after seeing if it is suppressed. status := api.getAlertStatus(a.Fingerprint()) if !active && status.State == types.AlertStateActive { return false } if !silenced && len(status.SilencedBy) != 0 { return false } if !inhibited && len(status.InhibitedBy) != 0 { return false } return alertMatchesFilterLabels(&a.Alert, matchers) } } func removeEmptyLabels(ls prometheus_model.LabelSet) { for k, v := range ls { if string(v) == "" { delete(ls, k) } } } func alertMatchesFilterLabels(a *prometheus_model.Alert, matchers []*labels.Matcher) bool { sms := make(map[string]string) for name, value := range a.Labels { sms[string(name)] = string(value) } return matchFilterLabels(matchers, sms) } func matchFilterLabels(matchers []*labels.Matcher, sms map[string]string) bool { for _, m := range matchers { v, prs := sms[m.Name] switch m.Type { case labels.MatchNotRegexp, labels.MatchNotEqual: if m.Value == "" && prs { continue } if !m.Matches(v) { return false } default: if m.Value == "" && !prs { continue } if !m.Matches(v) { return false } } } return true } func (api *API) getSilencesHandler(params silence_ops.GetSilencesParams) middleware.Responder { logger := api.requestLogger(params.HTTPRequest) ctx, span := tracer.Start(params.HTTPRequest.Context(), "api.getSilencesHandler") defer span.End() matchers, err := parseFilter(params.Filter) if err != nil { logger.Debug("Failed to parse matchers", "err", err) return silence_ops.NewGetSilencesBadRequest().WithPayload(err.Error()) } psils, _, err := api.silences.Query(ctx) if err != nil { logger.Error("Failed to get silences", "err", err) return silence_ops.NewGetSilencesInternalServerError().WithPayload(err.Error()) } sils := open_api_models.GettableSilences{} for _, ps := range psils { if !CheckSilenceMatchesFilterLabels(ps, matchers) { continue } silence, err := GettableSilenceFromProto(ps) if err != nil { logger.Error("Failed to unmarshal silence from proto", "err", err) return silence_ops.NewGetSilencesInternalServerError().WithPayload(err.Error()) } sils = append(sils, &silence) } SortSilences(sils) return silence_ops.NewGetSilencesOK().WithPayload(sils) } var silenceStateOrder = map[silence.SilenceState]int{ silence.SilenceStateActive: 1, silence.SilenceStatePending: 2, silence.SilenceStateExpired: 3, } // SortSilences sorts first according to the state "active, pending, expired" // then by end time or start time depending on the state. // Active silences should show the next to expire first // pending silences are ordered based on which one starts next // expired are ordered based on which one expired most recently. func SortSilences(sils open_api_models.GettableSilences) { sort.Slice(sils, func(i, j int) bool { state1 := silence.SilenceState(*sils[i].Status.State) state2 := silence.SilenceState(*sils[j].Status.State) if state1 != state2 { return silenceStateOrder[state1] < silenceStateOrder[state2] } switch state1 { case silence.SilenceStateActive: endsAt1 := time.Time(*sils[i].EndsAt) endsAt2 := time.Time(*sils[j].EndsAt) return endsAt1.Before(endsAt2) case silence.SilenceStatePending: startsAt1 := time.Time(*sils[i].StartsAt) startsAt2 := time.Time(*sils[j].StartsAt) return startsAt1.Before(startsAt2) case silence.SilenceStateExpired: endsAt1 := time.Time(*sils[i].EndsAt) endsAt2 := time.Time(*sils[j].EndsAt) return endsAt1.After(endsAt2) } return false }) } // CheckSilenceMatchesFilterLabels returns true if // a given silence matches a list of matchers. // A silence matches a filter (list of matchers) if // for all matchers in the filter, there exists a matcher in the silence // such that their names, types, and values are equivalent. func CheckSilenceMatchesFilterLabels(s *silencepb.Silence, matchers []*labels.Matcher) bool { // Check if any matcher set matches (OR logic) for _, ms := range s.MatcherSets { if checkMatcherSetMatchesFilterLabels(ms, matchers) { return true } } return false } func checkMatcherSetMatchesFilterLabels(ms *silencepb.MatcherSet, matchers []*labels.Matcher) bool { for _, matcher := range matchers { found := false for _, m := range ms.Matchers { if matcher.Name == m.Name && (matcher.Type == labels.MatchEqual && m.Type == silencepb.Matcher_EQUAL || matcher.Type == labels.MatchRegexp && m.Type == silencepb.Matcher_REGEXP || matcher.Type == labels.MatchNotEqual && m.Type == silencepb.Matcher_NOT_EQUAL || matcher.Type == labels.MatchNotRegexp && m.Type == silencepb.Matcher_NOT_REGEXP) && matcher.Value == m.Pattern { found = true break } } if !found { return false } } return true } func (api *API) getSilenceHandler(params silence_ops.GetSilenceParams) middleware.Responder { logger := api.requestLogger(params.HTTPRequest) ctx, span := tracer.Start(params.HTTPRequest.Context(), "api.getSilenceHandler") defer span.End() sils, _, err := api.silences.Query(ctx, silence.QIDs(params.SilenceID.String())) if err != nil { logger.Error("Failed to get silence by id", "err", err, "id", params.SilenceID.String()) return silence_ops.NewGetSilenceInternalServerError().WithPayload(err.Error()) } if len(sils) == 0 { logger.Error("Failed to find silence", "err", err, "id", params.SilenceID.String()) return silence_ops.NewGetSilenceNotFound() } sil, err := GettableSilenceFromProto(sils[0]) if err != nil { logger.Error("Failed to convert unmarshal from proto", "err", err) return silence_ops.NewGetSilenceInternalServerError().WithPayload(err.Error()) } return silence_ops.NewGetSilenceOK().WithPayload(&sil) } func (api *API) deleteSilenceHandler(params silence_ops.DeleteSilenceParams) middleware.Responder { logger := api.requestLogger(params.HTTPRequest) ctx, span := tracer.Start(params.HTTPRequest.Context(), "api.deleteSilenceHandler") defer span.End() sid := params.SilenceID.String() if err := api.silences.Expire(ctx, sid); err != nil { logger.Error("Failed to expire silence", "err", err) if errors.Is(err, silence.ErrNotFound) { return silence_ops.NewDeleteSilenceNotFound() } return silence_ops.NewDeleteSilenceInternalServerError().WithPayload(err.Error()) } return silence_ops.NewDeleteSilenceOK() } func (api *API) postSilencesHandler(params silence_ops.PostSilencesParams) middleware.Responder { logger := api.requestLogger(params.HTTPRequest) ctx, span := tracer.Start(params.HTTPRequest.Context(), "api.postSilencesHandler") defer span.End() sil, err := PostableSilenceToProto(params.Silence) if err != nil { logger.Error("Failed to marshal silence to proto", "err", err) return silence_ops.NewPostSilencesBadRequest().WithPayload( fmt.Sprintf("failed to convert API silence to internal silence: %v", err.Error()), ) } if sil.StartsAt.AsTime().After(sil.EndsAt.AsTime()) || sil.StartsAt.AsTime().Equal(sil.EndsAt.AsTime()) { msg := "Failed to create silence: start time must be before end time" logger.Error(msg, "starts_at", sil.StartsAt, "ends_at", sil.EndsAt) return silence_ops.NewPostSilencesBadRequest().WithPayload(msg) } if sil.EndsAt.AsTime().Before(time.Now()) { msg := "Failed to create silence: end time can't be in the past" logger.Error(msg, "ends_at", sil.EndsAt) return silence_ops.NewPostSilencesBadRequest().WithPayload(msg) } if err = api.silences.Set(ctx, sil); err != nil { logger.Error("Failed to create silence", "err", err) if errors.Is(err, silence.ErrNotFound) { return silence_ops.NewPostSilencesNotFound().WithPayload(err.Error()) } return silence_ops.NewPostSilencesBadRequest().WithPayload(err.Error()) } return silence_ops.NewPostSilencesOK().WithPayload(&silence_ops.PostSilencesOKBody{ SilenceID: sil.Id, }) } func parseFilter(filter []string) ([]*labels.Matcher, error) { matchers := make([]*labels.Matcher, 0, len(filter)) for _, matcherString := range filter { matcher, err := compat.Matcher(matcherString, "api") if err != nil { return nil, err } matchers = append(matchers, matcher) } return matchers, nil } var ( swaggerSpecCacheMx sync.Mutex swaggerSpecCache *loads.Document swaggerSpecAnalysisCache *analysis.Spec ) // getSwaggerSpec loads and caches the swagger spec. If a cached version already exists, // it returns the cached one. The reason why we cache it is because some downstream projects // (e.g. Grafana Mimir) creates many Alertmanager instances in the same process, so they would // incur in a significant memory penalty if we would reload the swagger spec each time. func getSwaggerSpec() (*loads.Document, *analysis.Spec, error) { swaggerSpecCacheMx.Lock() defer swaggerSpecCacheMx.Unlock() // Check if a cached version exists. if swaggerSpecCache != nil { return swaggerSpecCache, swaggerSpecAnalysisCache, nil } // Load embedded swagger file. swaggerSpec, err := loads.Analyzed(restapi.SwaggerJSON, "") if err != nil { return nil, nil, fmt.Errorf("failed to load embedded swagger file: %w", err) } swaggerSpecCache = swaggerSpec swaggerSpecAnalysisCache = analysis.New(swaggerSpec.Spec()) return swaggerSpec, swaggerSpecAnalysisCache, nil } ================================================ FILE: api/v2/api_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v2 import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/strfmt" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" open_api_models "github.com/prometheus/alertmanager/api/v2/models" general_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/general" receiver_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/receiver" silence_ops "github.com/prometheus/alertmanager/api/v2/restapi/operations/silence" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/alertmanager/types" ) // If api.peers == nil, Alertmanager cluster feature is disabled. Make sure to // not try to access properties of peer, which would trigger a nil pointer // dereference. func TestGetStatusHandlerWithNilPeer(t *testing.T) { api := API{ uptime: time.Now(), peer: nil, alertmanagerConfig: &config.Config{}, } // Test ensures this method call does not panic. status := api.getStatusHandler( general_ops.GetStatusParams{ HTTPRequest: httptest.NewRequest( "GET", "/api/v2/status", nil, ), }, ).(*general_ops.GetStatusOK) c := status.Payload.Cluster if c == nil || c.Status == nil { t.Fatal("expected cluster status not to be nil, violating the openapi specification") } if c.Peers == nil { t.Fatal("expected cluster peers to be not nil when api.peer is nil, violating the openapi specification") } if len(c.Peers) != 0 { t.Fatal("expected cluster peers to be empty when api.peer is nil, violating the openapi specification") } if c.Name != "" { t.Fatal("expected cluster name to be empty, violating the openapi specification") } } func assertEqualStrings(t *testing.T, expected, actual string) { if expected != actual { t.Fatal("expected: ", expected, ", actual: ", actual) } } var ( testComment = "comment" createdBy = "test" ) func newSilences(t *testing.T) *silence.Silences { silences, err := silence.New(silence.Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) return silences } func gettableSilence(id, state string, updatedAt, start, end string, ) *open_api_models.GettableSilence { updAt, err := strfmt.ParseDateTime(updatedAt) if err != nil { panic(err) } strAt, err := strfmt.ParseDateTime(start) if err != nil { panic(err) } endAt, err := strfmt.ParseDateTime(end) if err != nil { panic(err) } return &open_api_models.GettableSilence{ Silence: open_api_models.Silence{ StartsAt: &strAt, EndsAt: &endAt, Comment: &testComment, CreatedBy: &createdBy, }, ID: &id, UpdatedAt: &updAt, Status: &open_api_models.SilenceStatus{ State: &state, }, } } func TestGetSilencesHandler(t *testing.T) { updateTime := "2019-01-01T12:00:00+00:00" silences := []*open_api_models.GettableSilence{ gettableSilence("silence-6-expired", "expired", updateTime, "2019-01-01T12:00:00+00:00", "2019-01-01T11:00:00+00:00"), gettableSilence("silence-1-active", "active", updateTime, "2019-01-01T12:00:00+00:00", "2019-01-01T13:00:00+00:00"), gettableSilence("silence-7-expired", "expired", updateTime, "2019-01-01T12:00:00+00:00", "2019-01-01T10:00:00+00:00"), gettableSilence("silence-5-expired", "expired", updateTime, "2019-01-01T12:00:00+00:00", "2019-01-01T12:00:00+00:00"), gettableSilence("silence-0-active", "active", updateTime, "2019-01-01T12:00:00+00:00", "2019-01-01T12:00:00+00:00"), gettableSilence("silence-4-pending", "pending", updateTime, "2019-01-01T13:00:00+00:00", "2019-01-01T12:00:00+00:00"), gettableSilence("silence-3-pending", "pending", updateTime, "2019-01-01T12:00:00+00:00", "2019-01-01T12:00:00+00:00"), gettableSilence("silence-2-active", "active", updateTime, "2019-01-01T12:00:00+00:00", "2019-01-01T14:00:00+00:00"), } SortSilences(open_api_models.GettableSilences(silences)) for i, sil := range silences { assertEqualStrings(t, "silence-"+strconv.Itoa(i)+"-"+*sil.Status.State, *sil.ID) } } func TestDeleteSilenceHandler(t *testing.T) { now := timestamppb.Now() silences := newSilences(t) m := &silencepb.Matcher{Type: silencepb.Matcher_EQUAL, Name: "a", Pattern: "b"} unexpiredSil := &silencepb.Silence{ MatcherSets: []*silencepb.MatcherSet{{ Matchers: []*silencepb.Matcher{m}, }}, StartsAt: now, EndsAt: timestamppb.New(now.AsTime().Add(time.Hour)), UpdatedAt: now, } require.NoError(t, silences.Set(t.Context(), unexpiredSil)) expiredSil := &silencepb.Silence{ MatcherSets: []*silencepb.MatcherSet{{ Matchers: []*silencepb.Matcher{m}, }}, StartsAt: timestamppb.New(now.AsTime().Add(-time.Hour)), EndsAt: timestamppb.New(now.AsTime().Add(time.Hour)), UpdatedAt: now, } require.NoError(t, silences.Set(t.Context(), expiredSil)) require.NoError(t, silences.Expire(t.Context(), expiredSil.Id)) for i, tc := range []struct { sid string expectedCode int }{ { "unknownSid", 404, }, { unexpiredSil.Id, 200, }, { expiredSil.Id, 200, }, } { api := API{ uptime: time.Now(), silences: silences, logger: promslog.NewNopLogger(), } r, err := http.NewRequest("DELETE", "/api/v2/silence/${tc.sid}", nil) require.NoError(t, err) w := httptest.NewRecorder() p := runtime.TextProducer() responder := api.deleteSilenceHandler(silence_ops.DeleteSilenceParams{ SilenceID: strfmt.UUID(tc.sid), HTTPRequest: r, }) responder.WriteResponse(w, p) body, _ := io.ReadAll(w.Result().Body) require.Equal(t, tc.expectedCode, w.Code, "test case: %d, response: %s", i, string(body)) } } func TestPostSilencesHandler(t *testing.T) { now := timestamppb.Now() silences := newSilences(t) m := &silencepb.Matcher{Type: silencepb.Matcher_EQUAL, Name: "a", Pattern: "b"} unexpiredSil := &silencepb.Silence{ MatcherSets: []*silencepb.MatcherSet{{ Matchers: []*silencepb.Matcher{m}, }}, StartsAt: now, EndsAt: timestamppb.New(now.AsTime().Add(time.Hour)), UpdatedAt: now, } require.NoError(t, silences.Set(t.Context(), unexpiredSil)) expiredSil := &silencepb.Silence{ MatcherSets: []*silencepb.MatcherSet{{ Matchers: []*silencepb.Matcher{m}, }}, StartsAt: timestamppb.New(now.AsTime().Add(-time.Hour)), EndsAt: timestamppb.New(now.AsTime().Add(time.Hour)), UpdatedAt: now, } require.NoError(t, silences.Set(t.Context(), expiredSil)) require.NoError(t, silences.Expire(t.Context(), expiredSil.Id)) t.Run("Silences CRUD", func(t *testing.T) { for i, tc := range []struct { name string sid string start, end time.Time expectedCode int }{ { "with an non-existent silence ID - it returns 404", "unknownSid", now.AsTime().Add(time.Hour), now.AsTime().Add(time.Hour * 2), 404, }, { "with no silence ID - it creates the silence", "", now.AsTime().Add(time.Hour), now.AsTime().Add(time.Hour * 2), 200, }, { "with an active silence ID - it extends the silence", unexpiredSil.Id, now.AsTime().Add(time.Hour), now.AsTime().Add(time.Hour * 2), 200, }, { "with an expired silence ID - it re-creates the silence", expiredSil.Id, now.AsTime().Add(time.Hour), now.AsTime().Add(time.Hour * 2), 200, }, } { t.Run(tc.name, func(t *testing.T) { api := API{ uptime: time.Now(), silences: silences, logger: promslog.NewNopLogger(), } sil := createSilence(t, tc.sid, "silenceCreator", tc.start, tc.end) w := httptest.NewRecorder() postSilences(t, w, api.postSilencesHandler, sil) body, _ := io.ReadAll(w.Result().Body) require.Equal(t, tc.expectedCode, w.Code, "test case: %d, response: %s", i, string(body)) }) } }) } func TestPostSilencesHandlerMissingIdCreatesSilence(t *testing.T) { now := time.Now() silences := newSilences(t) api := API{ uptime: time.Now(), silences: silences, logger: promslog.NewNopLogger(), } // Create a new silence. It should be assigned a random UUID. sil := createSilence(t, "", "silenceCreator", now.Add(time.Hour), now.Add(time.Hour*2)) w := httptest.NewRecorder() postSilences(t, w, api.postSilencesHandler, sil) require.Equal(t, http.StatusOK, w.Code) // Get the silences from the API. w = httptest.NewRecorder() getSilences(t, w, api.getSilencesHandler) require.Equal(t, http.StatusOK, w.Code) var resp []open_api_models.GettableSilence require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) require.Len(t, resp, 1) // Change the ID. It should return 404 Not Found. sil = open_api_models.PostableSilence{ ID: "unknownID", Silence: resp[0].Silence, } w = httptest.NewRecorder() postSilences(t, w, api.postSilencesHandler, sil) require.Equal(t, http.StatusNotFound, w.Code) // Remove the ID. It should duplicate the silence with a different UUID. sil = open_api_models.PostableSilence{ ID: "", Silence: resp[0].Silence, } w = httptest.NewRecorder() postSilences(t, w, api.postSilencesHandler, sil) require.Equal(t, http.StatusOK, w.Code) // Get the silences from the API. There should now be 2 silences. w = httptest.NewRecorder() getSilences(t, w, api.getSilencesHandler) require.Equal(t, http.StatusOK, w.Code) require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) require.Len(t, resp, 2) require.NotEqual(t, resp[0].ID, resp[1].ID) } func getSilences( t *testing.T, w *httptest.ResponseRecorder, handlerFunc func(params silence_ops.GetSilencesParams) middleware.Responder, ) { r, err := http.NewRequest("GET", "/api/v2/silences", nil) require.NoError(t, err) p := runtime.TextProducer() responder := handlerFunc(silence_ops.GetSilencesParams{ HTTPRequest: r, Filter: nil, }) responder.WriteResponse(w, p) } func postSilences( t *testing.T, w *httptest.ResponseRecorder, handlerFunc func(params silence_ops.PostSilencesParams) middleware.Responder, sil open_api_models.PostableSilence, ) { b, err := json.Marshal(sil) require.NoError(t, err) r, err := http.NewRequest("POST", "/api/v2/silences", bytes.NewReader(b)) require.NoError(t, err) p := runtime.TextProducer() responder := handlerFunc(silence_ops.PostSilencesParams{ HTTPRequest: r, Silence: &sil, }) responder.WriteResponse(w, p) } func TestCheckSilenceMatchesFilterLabels(t *testing.T) { type test struct { silenceMatchers []*silencepb.Matcher filterMatchers []*labels.Matcher expected bool } tests := []test{ { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchEqual)}, true, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL)}, []*labels.Matcher{createLabelMatcher(t, "label", "novalue", labels.MatchEqual)}, false, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "(foo|bar)", silencepb.Matcher_REGEXP)}, []*labels.Matcher{createLabelMatcher(t, "label", "(foo|bar)", labels.MatchRegexp)}, true, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "foo", silencepb.Matcher_REGEXP)}, []*labels.Matcher{createLabelMatcher(t, "label", "(foo|bar)", labels.MatchRegexp)}, false, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchRegexp)}, false, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_REGEXP)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchEqual)}, false, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_NOT_EQUAL)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotEqual)}, true, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_NOT_REGEXP)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotRegexp)}, true, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotEqual)}, false, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_REGEXP)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotRegexp)}, false, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_NOT_EQUAL)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotRegexp)}, false, }, { []*silencepb.Matcher{createSilenceMatcher(t, "label", "value", silencepb.Matcher_NOT_REGEXP)}, []*labels.Matcher{createLabelMatcher(t, "label", "value", labels.MatchNotEqual)}, false, }, { []*silencepb.Matcher{ createSilenceMatcher(t, "label", "(foo|bar)", silencepb.Matcher_REGEXP), createSilenceMatcher(t, "label", "value", silencepb.Matcher_EQUAL), }, []*labels.Matcher{createLabelMatcher(t, "label", "(foo|bar)", labels.MatchRegexp)}, true, }, } for _, test := range tests { silence := silencepb.Silence{ MatcherSets: []*silencepb.MatcherSet{{ Matchers: test.silenceMatchers, }}, } actual := CheckSilenceMatchesFilterLabels(&silence, test.filterMatchers) if test.expected != actual { t.Fatal("unexpected match result between silence and filter. expected:", test.expected, ", actual:", actual) } } } func convertDateTime(ts time.Time) *strfmt.DateTime { dt := strfmt.DateTime(ts) return &dt } func TestAlertToOpenAPIAlert(t *testing.T) { var ( start = time.Now().Add(-time.Minute) updated = time.Now() active = "active" fp = "0223b772b51c29e1" receivers = []string{"receiver1", "receiver2"} alert = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"severity": "critical", "alertname": "alert1"}, StartsAt: start, }, UpdatedAt: updated, } ) openAPIAlert := AlertToOpenAPIAlert(alert, types.AlertStatus{State: types.AlertStateActive}, receivers, nil) require.Equal(t, &open_api_models.GettableAlert{ Annotations: open_api_models.LabelSet{}, Alert: open_api_models.Alert{ Labels: open_api_models.LabelSet{"severity": "critical", "alertname": "alert1"}, }, StartsAt: convertDateTime(start), EndsAt: convertDateTime(time.Time{}), UpdatedAt: convertDateTime(updated), Fingerprint: &fp, Receivers: []*open_api_models.Receiver{ {Name: &receivers[0]}, {Name: &receivers[1]}, }, Status: &open_api_models.AlertStatus{ State: &active, InhibitedBy: []string{}, SilencedBy: []string{}, MutedBy: []string{}, }, }, openAPIAlert) } func TestMatchFilterLabels(t *testing.T) { sms := map[string]string{ "foo": "bar", } testCases := []struct { matcher labels.MatchType name string val string expected bool }{ {labels.MatchEqual, "foo", "bar", true}, {labels.MatchEqual, "baz", "", true}, {labels.MatchEqual, "baz", "qux", false}, {labels.MatchEqual, "baz", "qux|", false}, {labels.MatchRegexp, "foo", "bar", true}, {labels.MatchRegexp, "baz", "", true}, {labels.MatchRegexp, "baz", "qux", false}, {labels.MatchRegexp, "baz", "qux|", true}, {labels.MatchNotEqual, "foo", "bar", false}, {labels.MatchNotEqual, "baz", "", false}, {labels.MatchNotEqual, "baz", "qux", true}, {labels.MatchNotEqual, "baz", "qux|", true}, {labels.MatchNotRegexp, "foo", "bar", false}, {labels.MatchNotRegexp, "baz", "", false}, {labels.MatchNotRegexp, "baz", "qux", true}, {labels.MatchNotRegexp, "baz", "qux|", false}, } for _, tc := range testCases { m, err := labels.NewMatcher(tc.matcher, tc.name, tc.val) require.NoError(t, err) ms := []*labels.Matcher{m} require.Equal(t, tc.expected, matchFilterLabels(ms, sms)) } } func TestGetReceiversHandler(t *testing.T) { in := ` route: receiver: team-X receivers: - name: 'team-X' - name: 'team-Y' ` cfg, _ := config.Load(in) api := API{ uptime: time.Now(), logger: promslog.NewNopLogger(), alertmanagerConfig: cfg, } for _, tc := range []struct { body string expectedCode int }{ { `[{"name":"team-X"},{"name":"team-Y"}]`, 200, }, } { r, err := http.NewRequest("GET", "/api/v2/receivers", nil) require.NoError(t, err) w := httptest.NewRecorder() p := runtime.TextProducer() responder := api.getReceiversHandler(receiver_ops.GetReceiversParams{ HTTPRequest: r, }) responder.WriteResponse(w, p) body, _ := io.ReadAll(w.Result().Body) require.Equal(t, tc.expectedCode, w.Code) require.Equal(t, tc.body, string(body)) } } func BenchmarkOpenAPIAlertsToAlerts(b *testing.B) { now := strfmt.DateTime(time.Now()) apiAlerts := make(open_api_models.PostableAlerts, 100) for i := range apiAlerts { apiAlerts[i] = &open_api_models.PostableAlert{ Alert: open_api_models.Alert{ Labels: open_api_models.LabelSet{"alertname": "test", "i": strconv.Itoa(i)}, }, StartsAt: now, EndsAt: now, } } b.Run("PreAllocated", func(b *testing.B) { ctx := context.Background() for i := 0; i < b.N; i++ { OpenAPIAlertsToAlerts(ctx, apiAlerts) } }) b.Run("AppendGrowth", func(b *testing.B) { for i := 0; i < b.N; i++ { alerts := []*types.Alert{} for _, apiAlert := range apiAlerts { alerts = append(alerts, &types.Alert{ Alert: model.Alert{ Labels: APILabelSetToModelLabelSet(apiAlert.Labels), Annotations: APILabelSetToModelLabelSet(apiAlert.Annotations), StartsAt: time.Time(apiAlert.StartsAt), EndsAt: time.Time(apiAlert.EndsAt), GeneratorURL: string(apiAlert.GeneratorURL), }, }) } _ = alerts } }) } func TestPostSilences_QuotedMatchers(t *testing.T) { // This test ensures that quoted values in matchers are preserved during JSON unmarshalling jsonBlob := `{"comment":"foo", "createdBy": "author", "startsAt":"2023-03-06T00:22:15Z", "endsAt":"2024-03-06T00:22:15Z", "matchers":[{"isRegex":true, "name":"instance", "value":"\"bar\""}]}` var ps open_api_models.PostableSilence err := json.Unmarshal([]byte(jsonBlob), &ps) require.NoError(t, err) require.Len(t, ps.Matchers, 1) require.Equal(t, "\"bar\"", *ps.Matchers[0].Value) silProto, err := PostableSilenceToProto(&ps) require.NoError(t, err) require.Len(t, silProto.MatcherSets, 1) require.Len(t, silProto.MatcherSets[0].Matchers, 1) require.Equal(t, "\"bar\"", silProto.MatcherSets[0].Matchers[0].Pattern) } ================================================ FILE: api/v2/client/alert/alert_client.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "fmt" "github.com/go-openapi/runtime" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // New creates a new alert API client. func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService { return &Client{transport: transport, formats: formats} } // New creates a new alert API client with basic auth credentials. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - user: user for basic authentication header. // - password: password for basic authentication header. func NewClientWithBasicAuth(host, basePath, scheme, user, password string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BasicAuth(user, password) return &Client{transport: transport, formats: strfmt.Default} } // New creates a new alert API client with a bearer token for authentication. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - bearerToken: bearer token for Bearer authentication header. func NewClientWithBearerToken(host, basePath, scheme, bearerToken string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BearerToken(bearerToken) return &Client{transport: transport, formats: strfmt.Default} } /* Client for alert API */ type Client struct { transport runtime.ClientTransport formats strfmt.Registry } // ClientOption may be used to customize the behavior of Client methods. type ClientOption func(*runtime.ClientOperation) // ClientService is the interface for Client methods type ClientService interface { GetAlerts(params *GetAlertsParams, opts ...ClientOption) (*GetAlertsOK, error) PostAlerts(params *PostAlertsParams, opts ...ClientOption) (*PostAlertsOK, error) SetTransport(transport runtime.ClientTransport) } /* GetAlerts Get a list of alerts */ func (a *Client) GetAlerts(params *GetAlertsParams, opts ...ClientOption) (*GetAlertsOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewGetAlertsParams() } op := &runtime.ClientOperation{ ID: "getAlerts", Method: "GET", PathPattern: "/alerts", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &GetAlertsReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*GetAlertsOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for getAlerts: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } /* PostAlerts Create new Alerts */ func (a *Client) PostAlerts(params *PostAlertsParams, opts ...ClientOption) (*PostAlertsOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewPostAlertsParams() } op := &runtime.ClientOperation{ ID: "postAlerts", Method: "POST", PathPattern: "/alerts", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &PostAlertsReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*PostAlertsOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for postAlerts: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } // SetTransport changes the transport on the client func (a *Client) SetTransport(transport runtime.ClientTransport) { a.transport = transport } ================================================ FILE: api/v2/client/alert/get_alerts_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // NewGetAlertsParams creates a new GetAlertsParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewGetAlertsParams() *GetAlertsParams { return &GetAlertsParams{ timeout: cr.DefaultTimeout, } } // NewGetAlertsParamsWithTimeout creates a new GetAlertsParams object // with the ability to set a timeout on a request. func NewGetAlertsParamsWithTimeout(timeout time.Duration) *GetAlertsParams { return &GetAlertsParams{ timeout: timeout, } } // NewGetAlertsParamsWithContext creates a new GetAlertsParams object // with the ability to set a context for a request. func NewGetAlertsParamsWithContext(ctx context.Context) *GetAlertsParams { return &GetAlertsParams{ Context: ctx, } } // NewGetAlertsParamsWithHTTPClient creates a new GetAlertsParams object // with the ability to set a custom HTTPClient for a request. func NewGetAlertsParamsWithHTTPClient(client *http.Client) *GetAlertsParams { return &GetAlertsParams{ HTTPClient: client, } } /* GetAlertsParams contains all the parameters to send to the API endpoint for the get alerts operation. Typically these are written to a http.Request. */ type GetAlertsParams struct { /* Active. Include active alerts in results. If false, excludes active alerts and returns only suppressed (silenced or inhibited) alerts. Default: true */ Active *bool /* Filter. A matcher expression to filter alerts. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. */ Filter []string /* Inhibited. Include inhibited alerts in results. If false, excludes inhibited alerts. Note that true (default) shows both inhibited and non-inhibited alerts. Default: true */ Inhibited *bool /* Receiver. A regex matching receivers to filter alerts by */ Receiver *string /* Silenced. Include silenced alerts in results. If false, excludes silenced alerts. Note that true (default) shows both silenced and non-silenced alerts. Default: true */ Silenced *bool /* Unprocessed. Include unprocessed alerts in results. If false, excludes unprocessed alerts. Note that true (default) shows both processed and unprocessed alerts. Default: true */ Unprocessed *bool timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the get alerts params (not the query body). // // All values with no default are reset to their zero value. func (o *GetAlertsParams) WithDefaults() *GetAlertsParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the get alerts params (not the query body). // // All values with no default are reset to their zero value. func (o *GetAlertsParams) SetDefaults() { var ( activeDefault = bool(true) inhibitedDefault = bool(true) silencedDefault = bool(true) unprocessedDefault = bool(true) ) val := GetAlertsParams{ Active: &activeDefault, Inhibited: &inhibitedDefault, Silenced: &silencedDefault, Unprocessed: &unprocessedDefault, } val.timeout = o.timeout val.Context = o.Context val.HTTPClient = o.HTTPClient *o = val } // WithTimeout adds the timeout to the get alerts params func (o *GetAlertsParams) WithTimeout(timeout time.Duration) *GetAlertsParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the get alerts params func (o *GetAlertsParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the get alerts params func (o *GetAlertsParams) WithContext(ctx context.Context) *GetAlertsParams { o.SetContext(ctx) return o } // SetContext adds the context to the get alerts params func (o *GetAlertsParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the get alerts params func (o *GetAlertsParams) WithHTTPClient(client *http.Client) *GetAlertsParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the get alerts params func (o *GetAlertsParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WithActive adds the active to the get alerts params func (o *GetAlertsParams) WithActive(active *bool) *GetAlertsParams { o.SetActive(active) return o } // SetActive adds the active to the get alerts params func (o *GetAlertsParams) SetActive(active *bool) { o.Active = active } // WithFilter adds the filter to the get alerts params func (o *GetAlertsParams) WithFilter(filter []string) *GetAlertsParams { o.SetFilter(filter) return o } // SetFilter adds the filter to the get alerts params func (o *GetAlertsParams) SetFilter(filter []string) { o.Filter = filter } // WithInhibited adds the inhibited to the get alerts params func (o *GetAlertsParams) WithInhibited(inhibited *bool) *GetAlertsParams { o.SetInhibited(inhibited) return o } // SetInhibited adds the inhibited to the get alerts params func (o *GetAlertsParams) SetInhibited(inhibited *bool) { o.Inhibited = inhibited } // WithReceiver adds the receiver to the get alerts params func (o *GetAlertsParams) WithReceiver(receiver *string) *GetAlertsParams { o.SetReceiver(receiver) return o } // SetReceiver adds the receiver to the get alerts params func (o *GetAlertsParams) SetReceiver(receiver *string) { o.Receiver = receiver } // WithSilenced adds the silenced to the get alerts params func (o *GetAlertsParams) WithSilenced(silenced *bool) *GetAlertsParams { o.SetSilenced(silenced) return o } // SetSilenced adds the silenced to the get alerts params func (o *GetAlertsParams) SetSilenced(silenced *bool) { o.Silenced = silenced } // WithUnprocessed adds the unprocessed to the get alerts params func (o *GetAlertsParams) WithUnprocessed(unprocessed *bool) *GetAlertsParams { o.SetUnprocessed(unprocessed) return o } // SetUnprocessed adds the unprocessed to the get alerts params func (o *GetAlertsParams) SetUnprocessed(unprocessed *bool) { o.Unprocessed = unprocessed } // WriteToRequest writes these params to a swagger request func (o *GetAlertsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error if o.Active != nil { // query param active var qrActive bool if o.Active != nil { qrActive = *o.Active } qActive := swag.FormatBool(qrActive) if qActive != "" { if err := r.SetQueryParam("active", qActive); err != nil { return err } } } if o.Filter != nil { // binding items for filter joinedFilter := o.bindParamFilter(reg) // query array param filter if err := r.SetQueryParam("filter", joinedFilter...); err != nil { return err } } if o.Inhibited != nil { // query param inhibited var qrInhibited bool if o.Inhibited != nil { qrInhibited = *o.Inhibited } qInhibited := swag.FormatBool(qrInhibited) if qInhibited != "" { if err := r.SetQueryParam("inhibited", qInhibited); err != nil { return err } } } if o.Receiver != nil { // query param receiver var qrReceiver string if o.Receiver != nil { qrReceiver = *o.Receiver } qReceiver := qrReceiver if qReceiver != "" { if err := r.SetQueryParam("receiver", qReceiver); err != nil { return err } } } if o.Silenced != nil { // query param silenced var qrSilenced bool if o.Silenced != nil { qrSilenced = *o.Silenced } qSilenced := swag.FormatBool(qrSilenced) if qSilenced != "" { if err := r.SetQueryParam("silenced", qSilenced); err != nil { return err } } } if o.Unprocessed != nil { // query param unprocessed var qrUnprocessed bool if o.Unprocessed != nil { qrUnprocessed = *o.Unprocessed } qUnprocessed := swag.FormatBool(qrUnprocessed) if qUnprocessed != "" { if err := r.SetQueryParam("unprocessed", qUnprocessed); err != nil { return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // bindParamGetAlerts binds the parameter filter func (o *GetAlertsParams) bindParamFilter(formats strfmt.Registry) []string { filterIR := o.Filter var filterIC []string for _, filterIIR := range filterIR { // explode []string filterIIV := filterIIR // string as string filterIC = append(filterIC, filterIIV) } // items.CollectionFormat: "multi" filterIS := swag.JoinByFormat(filterIC, "multi") return filterIS } ================================================ FILE: api/v2/client/alert/get_alerts_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" ) // GetAlertsReader is a Reader for the GetAlerts structure. type GetAlertsReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *GetAlertsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewGetAlertsOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil case 400: result := NewGetAlertsBadRequest() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result case 500: result := NewGetAlertsInternalServerError() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result default: return nil, runtime.NewAPIError("[GET /alerts] getAlerts", response, response.Code()) } } // NewGetAlertsOK creates a GetAlertsOK with default headers values func NewGetAlertsOK() *GetAlertsOK { return &GetAlertsOK{} } /* GetAlertsOK describes a response with status code 200, with default header values. Get alerts response */ type GetAlertsOK struct { Payload models.GettableAlerts } // IsSuccess returns true when this get alerts o k response has a 2xx status code func (o *GetAlertsOK) IsSuccess() bool { return true } // IsRedirect returns true when this get alerts o k response has a 3xx status code func (o *GetAlertsOK) IsRedirect() bool { return false } // IsClientError returns true when this get alerts o k response has a 4xx status code func (o *GetAlertsOK) IsClientError() bool { return false } // IsServerError returns true when this get alerts o k response has a 5xx status code func (o *GetAlertsOK) IsServerError() bool { return false } // IsCode returns true when this get alerts o k response a status code equal to that given func (o *GetAlertsOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the get alerts o k response func (o *GetAlertsOK) Code() int { return 200 } func (o *GetAlertsOK) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts][%d] getAlertsOK %s", 200, payload) } func (o *GetAlertsOK) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts][%d] getAlertsOK %s", 200, payload) } func (o *GetAlertsOK) GetPayload() models.GettableAlerts { return o.Payload } func (o *GetAlertsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewGetAlertsBadRequest creates a GetAlertsBadRequest with default headers values func NewGetAlertsBadRequest() *GetAlertsBadRequest { return &GetAlertsBadRequest{} } /* GetAlertsBadRequest describes a response with status code 400, with default header values. Bad request */ type GetAlertsBadRequest struct { Payload string } // IsSuccess returns true when this get alerts bad request response has a 2xx status code func (o *GetAlertsBadRequest) IsSuccess() bool { return false } // IsRedirect returns true when this get alerts bad request response has a 3xx status code func (o *GetAlertsBadRequest) IsRedirect() bool { return false } // IsClientError returns true when this get alerts bad request response has a 4xx status code func (o *GetAlertsBadRequest) IsClientError() bool { return true } // IsServerError returns true when this get alerts bad request response has a 5xx status code func (o *GetAlertsBadRequest) IsServerError() bool { return false } // IsCode returns true when this get alerts bad request response a status code equal to that given func (o *GetAlertsBadRequest) IsCode(code int) bool { return code == 400 } // Code gets the status code for the get alerts bad request response func (o *GetAlertsBadRequest) Code() int { return 400 } func (o *GetAlertsBadRequest) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts][%d] getAlertsBadRequest %s", 400, payload) } func (o *GetAlertsBadRequest) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts][%d] getAlertsBadRequest %s", 400, payload) } func (o *GetAlertsBadRequest) GetPayload() string { return o.Payload } func (o *GetAlertsBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewGetAlertsInternalServerError creates a GetAlertsInternalServerError with default headers values func NewGetAlertsInternalServerError() *GetAlertsInternalServerError { return &GetAlertsInternalServerError{} } /* GetAlertsInternalServerError describes a response with status code 500, with default header values. Internal server error */ type GetAlertsInternalServerError struct { Payload string } // IsSuccess returns true when this get alerts internal server error response has a 2xx status code func (o *GetAlertsInternalServerError) IsSuccess() bool { return false } // IsRedirect returns true when this get alerts internal server error response has a 3xx status code func (o *GetAlertsInternalServerError) IsRedirect() bool { return false } // IsClientError returns true when this get alerts internal server error response has a 4xx status code func (o *GetAlertsInternalServerError) IsClientError() bool { return false } // IsServerError returns true when this get alerts internal server error response has a 5xx status code func (o *GetAlertsInternalServerError) IsServerError() bool { return true } // IsCode returns true when this get alerts internal server error response a status code equal to that given func (o *GetAlertsInternalServerError) IsCode(code int) bool { return code == 500 } // Code gets the status code for the get alerts internal server error response func (o *GetAlertsInternalServerError) Code() int { return 500 } func (o *GetAlertsInternalServerError) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts][%d] getAlertsInternalServerError %s", 500, payload) } func (o *GetAlertsInternalServerError) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts][%d] getAlertsInternalServerError %s", 500, payload) } func (o *GetAlertsInternalServerError) GetPayload() string { return o.Payload } func (o *GetAlertsInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } ================================================ FILE: api/v2/client/alert/post_alerts_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" ) // NewPostAlertsParams creates a new PostAlertsParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewPostAlertsParams() *PostAlertsParams { return &PostAlertsParams{ timeout: cr.DefaultTimeout, } } // NewPostAlertsParamsWithTimeout creates a new PostAlertsParams object // with the ability to set a timeout on a request. func NewPostAlertsParamsWithTimeout(timeout time.Duration) *PostAlertsParams { return &PostAlertsParams{ timeout: timeout, } } // NewPostAlertsParamsWithContext creates a new PostAlertsParams object // with the ability to set a context for a request. func NewPostAlertsParamsWithContext(ctx context.Context) *PostAlertsParams { return &PostAlertsParams{ Context: ctx, } } // NewPostAlertsParamsWithHTTPClient creates a new PostAlertsParams object // with the ability to set a custom HTTPClient for a request. func NewPostAlertsParamsWithHTTPClient(client *http.Client) *PostAlertsParams { return &PostAlertsParams{ HTTPClient: client, } } /* PostAlertsParams contains all the parameters to send to the API endpoint for the post alerts operation. Typically these are written to a http.Request. */ type PostAlertsParams struct { /* Alerts. The alerts to create */ Alerts models.PostableAlerts timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the post alerts params (not the query body). // // All values with no default are reset to their zero value. func (o *PostAlertsParams) WithDefaults() *PostAlertsParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the post alerts params (not the query body). // // All values with no default are reset to their zero value. func (o *PostAlertsParams) SetDefaults() { // no default values defined for this parameter } // WithTimeout adds the timeout to the post alerts params func (o *PostAlertsParams) WithTimeout(timeout time.Duration) *PostAlertsParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the post alerts params func (o *PostAlertsParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the post alerts params func (o *PostAlertsParams) WithContext(ctx context.Context) *PostAlertsParams { o.SetContext(ctx) return o } // SetContext adds the context to the post alerts params func (o *PostAlertsParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the post alerts params func (o *PostAlertsParams) WithHTTPClient(client *http.Client) *PostAlertsParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the post alerts params func (o *PostAlertsParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WithAlerts adds the alerts to the post alerts params func (o *PostAlertsParams) WithAlerts(alerts models.PostableAlerts) *PostAlertsParams { o.SetAlerts(alerts) return o } // SetAlerts adds the alerts to the post alerts params func (o *PostAlertsParams) SetAlerts(alerts models.PostableAlerts) { o.Alerts = alerts } // WriteToRequest writes these params to a swagger request func (o *PostAlertsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error if o.Alerts != nil { if err := r.SetBodyParam(o.Alerts); err != nil { return err } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/client/alert/post_alerts_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" ) // PostAlertsReader is a Reader for the PostAlerts structure. type PostAlertsReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *PostAlertsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewPostAlertsOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil case 400: result := NewPostAlertsBadRequest() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result case 500: result := NewPostAlertsInternalServerError() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result default: return nil, runtime.NewAPIError("[POST /alerts] postAlerts", response, response.Code()) } } // NewPostAlertsOK creates a PostAlertsOK with default headers values func NewPostAlertsOK() *PostAlertsOK { return &PostAlertsOK{} } /* PostAlertsOK describes a response with status code 200, with default header values. Create alerts response */ type PostAlertsOK struct { } // IsSuccess returns true when this post alerts o k response has a 2xx status code func (o *PostAlertsOK) IsSuccess() bool { return true } // IsRedirect returns true when this post alerts o k response has a 3xx status code func (o *PostAlertsOK) IsRedirect() bool { return false } // IsClientError returns true when this post alerts o k response has a 4xx status code func (o *PostAlertsOK) IsClientError() bool { return false } // IsServerError returns true when this post alerts o k response has a 5xx status code func (o *PostAlertsOK) IsServerError() bool { return false } // IsCode returns true when this post alerts o k response a status code equal to that given func (o *PostAlertsOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the post alerts o k response func (o *PostAlertsOK) Code() int { return 200 } func (o *PostAlertsOK) Error() string { return fmt.Sprintf("[POST /alerts][%d] postAlertsOK", 200) } func (o *PostAlertsOK) String() string { return fmt.Sprintf("[POST /alerts][%d] postAlertsOK", 200) } func (o *PostAlertsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { return nil } // NewPostAlertsBadRequest creates a PostAlertsBadRequest with default headers values func NewPostAlertsBadRequest() *PostAlertsBadRequest { return &PostAlertsBadRequest{} } /* PostAlertsBadRequest describes a response with status code 400, with default header values. Bad request */ type PostAlertsBadRequest struct { Payload string } // IsSuccess returns true when this post alerts bad request response has a 2xx status code func (o *PostAlertsBadRequest) IsSuccess() bool { return false } // IsRedirect returns true when this post alerts bad request response has a 3xx status code func (o *PostAlertsBadRequest) IsRedirect() bool { return false } // IsClientError returns true when this post alerts bad request response has a 4xx status code func (o *PostAlertsBadRequest) IsClientError() bool { return true } // IsServerError returns true when this post alerts bad request response has a 5xx status code func (o *PostAlertsBadRequest) IsServerError() bool { return false } // IsCode returns true when this post alerts bad request response a status code equal to that given func (o *PostAlertsBadRequest) IsCode(code int) bool { return code == 400 } // Code gets the status code for the post alerts bad request response func (o *PostAlertsBadRequest) Code() int { return 400 } func (o *PostAlertsBadRequest) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /alerts][%d] postAlertsBadRequest %s", 400, payload) } func (o *PostAlertsBadRequest) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /alerts][%d] postAlertsBadRequest %s", 400, payload) } func (o *PostAlertsBadRequest) GetPayload() string { return o.Payload } func (o *PostAlertsBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewPostAlertsInternalServerError creates a PostAlertsInternalServerError with default headers values func NewPostAlertsInternalServerError() *PostAlertsInternalServerError { return &PostAlertsInternalServerError{} } /* PostAlertsInternalServerError describes a response with status code 500, with default header values. Internal server error */ type PostAlertsInternalServerError struct { Payload string } // IsSuccess returns true when this post alerts internal server error response has a 2xx status code func (o *PostAlertsInternalServerError) IsSuccess() bool { return false } // IsRedirect returns true when this post alerts internal server error response has a 3xx status code func (o *PostAlertsInternalServerError) IsRedirect() bool { return false } // IsClientError returns true when this post alerts internal server error response has a 4xx status code func (o *PostAlertsInternalServerError) IsClientError() bool { return false } // IsServerError returns true when this post alerts internal server error response has a 5xx status code func (o *PostAlertsInternalServerError) IsServerError() bool { return true } // IsCode returns true when this post alerts internal server error response a status code equal to that given func (o *PostAlertsInternalServerError) IsCode(code int) bool { return code == 500 } // Code gets the status code for the post alerts internal server error response func (o *PostAlertsInternalServerError) Code() int { return 500 } func (o *PostAlertsInternalServerError) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /alerts][%d] postAlertsInternalServerError %s", 500, payload) } func (o *PostAlertsInternalServerError) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /alerts][%d] postAlertsInternalServerError %s", 500, payload) } func (o *PostAlertsInternalServerError) GetPayload() string { return o.Payload } func (o *PostAlertsInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } ================================================ FILE: api/v2/client/alertgroup/alertgroup_client.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alertgroup // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "fmt" "github.com/go-openapi/runtime" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // New creates a new alertgroup API client. func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService { return &Client{transport: transport, formats: formats} } // New creates a new alertgroup API client with basic auth credentials. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - user: user for basic authentication header. // - password: password for basic authentication header. func NewClientWithBasicAuth(host, basePath, scheme, user, password string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BasicAuth(user, password) return &Client{transport: transport, formats: strfmt.Default} } // New creates a new alertgroup API client with a bearer token for authentication. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - bearerToken: bearer token for Bearer authentication header. func NewClientWithBearerToken(host, basePath, scheme, bearerToken string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BearerToken(bearerToken) return &Client{transport: transport, formats: strfmt.Default} } /* Client for alertgroup API */ type Client struct { transport runtime.ClientTransport formats strfmt.Registry } // ClientOption may be used to customize the behavior of Client methods. type ClientOption func(*runtime.ClientOperation) // ClientService is the interface for Client methods type ClientService interface { GetAlertGroups(params *GetAlertGroupsParams, opts ...ClientOption) (*GetAlertGroupsOK, error) SetTransport(transport runtime.ClientTransport) } /* GetAlertGroups Get a list of alert groups */ func (a *Client) GetAlertGroups(params *GetAlertGroupsParams, opts ...ClientOption) (*GetAlertGroupsOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewGetAlertGroupsParams() } op := &runtime.ClientOperation{ ID: "getAlertGroups", Method: "GET", PathPattern: "/alerts/groups", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &GetAlertGroupsReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*GetAlertGroupsOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for getAlertGroups: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } // SetTransport changes the transport on the client func (a *Client) SetTransport(transport runtime.ClientTransport) { a.transport = transport } ================================================ FILE: api/v2/client/alertgroup/get_alert_groups_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alertgroup // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // NewGetAlertGroupsParams creates a new GetAlertGroupsParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewGetAlertGroupsParams() *GetAlertGroupsParams { return &GetAlertGroupsParams{ timeout: cr.DefaultTimeout, } } // NewGetAlertGroupsParamsWithTimeout creates a new GetAlertGroupsParams object // with the ability to set a timeout on a request. func NewGetAlertGroupsParamsWithTimeout(timeout time.Duration) *GetAlertGroupsParams { return &GetAlertGroupsParams{ timeout: timeout, } } // NewGetAlertGroupsParamsWithContext creates a new GetAlertGroupsParams object // with the ability to set a context for a request. func NewGetAlertGroupsParamsWithContext(ctx context.Context) *GetAlertGroupsParams { return &GetAlertGroupsParams{ Context: ctx, } } // NewGetAlertGroupsParamsWithHTTPClient creates a new GetAlertGroupsParams object // with the ability to set a custom HTTPClient for a request. func NewGetAlertGroupsParamsWithHTTPClient(client *http.Client) *GetAlertGroupsParams { return &GetAlertGroupsParams{ HTTPClient: client, } } /* GetAlertGroupsParams contains all the parameters to send to the API endpoint for the get alert groups operation. Typically these are written to a http.Request. */ type GetAlertGroupsParams struct { /* Active. Include active alerts within the returned groups. If false, excludes active alerts from groups and only shows suppressed (silenced or inhibited) alerts. Default: true */ Active *bool /* Filter. A matcher expression to filter alert groups. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. */ Filter []string /* Inhibited. Include inhibited alerts within the returned groups. If false, excludes inhibited alerts from groups. Note that true (default) shows both inhibited and non-inhibited alerts. Default: true */ Inhibited *bool /* Muted. Include muted (silenced or inhibited) alert groups in results. If false, excludes entire groups where all alerts are muted. Default: true */ Muted *bool /* Receiver. A regex matching receivers to filter alerts by */ Receiver *string /* Silenced. Include silenced alerts within the returned groups. If false, excludes silenced alerts from groups. Note that true (default) shows both silenced and non-silenced alerts. Default: true */ Silenced *bool timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the get alert groups params (not the query body). // // All values with no default are reset to their zero value. func (o *GetAlertGroupsParams) WithDefaults() *GetAlertGroupsParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the get alert groups params (not the query body). // // All values with no default are reset to their zero value. func (o *GetAlertGroupsParams) SetDefaults() { var ( activeDefault = bool(true) inhibitedDefault = bool(true) mutedDefault = bool(true) silencedDefault = bool(true) ) val := GetAlertGroupsParams{ Active: &activeDefault, Inhibited: &inhibitedDefault, Muted: &mutedDefault, Silenced: &silencedDefault, } val.timeout = o.timeout val.Context = o.Context val.HTTPClient = o.HTTPClient *o = val } // WithTimeout adds the timeout to the get alert groups params func (o *GetAlertGroupsParams) WithTimeout(timeout time.Duration) *GetAlertGroupsParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the get alert groups params func (o *GetAlertGroupsParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the get alert groups params func (o *GetAlertGroupsParams) WithContext(ctx context.Context) *GetAlertGroupsParams { o.SetContext(ctx) return o } // SetContext adds the context to the get alert groups params func (o *GetAlertGroupsParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the get alert groups params func (o *GetAlertGroupsParams) WithHTTPClient(client *http.Client) *GetAlertGroupsParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the get alert groups params func (o *GetAlertGroupsParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WithActive adds the active to the get alert groups params func (o *GetAlertGroupsParams) WithActive(active *bool) *GetAlertGroupsParams { o.SetActive(active) return o } // SetActive adds the active to the get alert groups params func (o *GetAlertGroupsParams) SetActive(active *bool) { o.Active = active } // WithFilter adds the filter to the get alert groups params func (o *GetAlertGroupsParams) WithFilter(filter []string) *GetAlertGroupsParams { o.SetFilter(filter) return o } // SetFilter adds the filter to the get alert groups params func (o *GetAlertGroupsParams) SetFilter(filter []string) { o.Filter = filter } // WithInhibited adds the inhibited to the get alert groups params func (o *GetAlertGroupsParams) WithInhibited(inhibited *bool) *GetAlertGroupsParams { o.SetInhibited(inhibited) return o } // SetInhibited adds the inhibited to the get alert groups params func (o *GetAlertGroupsParams) SetInhibited(inhibited *bool) { o.Inhibited = inhibited } // WithMuted adds the muted to the get alert groups params func (o *GetAlertGroupsParams) WithMuted(muted *bool) *GetAlertGroupsParams { o.SetMuted(muted) return o } // SetMuted adds the muted to the get alert groups params func (o *GetAlertGroupsParams) SetMuted(muted *bool) { o.Muted = muted } // WithReceiver adds the receiver to the get alert groups params func (o *GetAlertGroupsParams) WithReceiver(receiver *string) *GetAlertGroupsParams { o.SetReceiver(receiver) return o } // SetReceiver adds the receiver to the get alert groups params func (o *GetAlertGroupsParams) SetReceiver(receiver *string) { o.Receiver = receiver } // WithSilenced adds the silenced to the get alert groups params func (o *GetAlertGroupsParams) WithSilenced(silenced *bool) *GetAlertGroupsParams { o.SetSilenced(silenced) return o } // SetSilenced adds the silenced to the get alert groups params func (o *GetAlertGroupsParams) SetSilenced(silenced *bool) { o.Silenced = silenced } // WriteToRequest writes these params to a swagger request func (o *GetAlertGroupsParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error if o.Active != nil { // query param active var qrActive bool if o.Active != nil { qrActive = *o.Active } qActive := swag.FormatBool(qrActive) if qActive != "" { if err := r.SetQueryParam("active", qActive); err != nil { return err } } } if o.Filter != nil { // binding items for filter joinedFilter := o.bindParamFilter(reg) // query array param filter if err := r.SetQueryParam("filter", joinedFilter...); err != nil { return err } } if o.Inhibited != nil { // query param inhibited var qrInhibited bool if o.Inhibited != nil { qrInhibited = *o.Inhibited } qInhibited := swag.FormatBool(qrInhibited) if qInhibited != "" { if err := r.SetQueryParam("inhibited", qInhibited); err != nil { return err } } } if o.Muted != nil { // query param muted var qrMuted bool if o.Muted != nil { qrMuted = *o.Muted } qMuted := swag.FormatBool(qrMuted) if qMuted != "" { if err := r.SetQueryParam("muted", qMuted); err != nil { return err } } } if o.Receiver != nil { // query param receiver var qrReceiver string if o.Receiver != nil { qrReceiver = *o.Receiver } qReceiver := qrReceiver if qReceiver != "" { if err := r.SetQueryParam("receiver", qReceiver); err != nil { return err } } } if o.Silenced != nil { // query param silenced var qrSilenced bool if o.Silenced != nil { qrSilenced = *o.Silenced } qSilenced := swag.FormatBool(qrSilenced) if qSilenced != "" { if err := r.SetQueryParam("silenced", qSilenced); err != nil { return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // bindParamGetAlertGroups binds the parameter filter func (o *GetAlertGroupsParams) bindParamFilter(formats strfmt.Registry) []string { filterIR := o.Filter var filterIC []string for _, filterIIR := range filterIR { // explode []string filterIIV := filterIIR // string as string filterIC = append(filterIC, filterIIV) } // items.CollectionFormat: "multi" filterIS := swag.JoinByFormat(filterIC, "multi") return filterIS } ================================================ FILE: api/v2/client/alertgroup/get_alert_groups_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alertgroup // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" ) // GetAlertGroupsReader is a Reader for the GetAlertGroups structure. type GetAlertGroupsReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *GetAlertGroupsReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewGetAlertGroupsOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil case 400: result := NewGetAlertGroupsBadRequest() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result case 500: result := NewGetAlertGroupsInternalServerError() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result default: return nil, runtime.NewAPIError("[GET /alerts/groups] getAlertGroups", response, response.Code()) } } // NewGetAlertGroupsOK creates a GetAlertGroupsOK with default headers values func NewGetAlertGroupsOK() *GetAlertGroupsOK { return &GetAlertGroupsOK{} } /* GetAlertGroupsOK describes a response with status code 200, with default header values. Get alert groups response */ type GetAlertGroupsOK struct { Payload models.AlertGroups } // IsSuccess returns true when this get alert groups o k response has a 2xx status code func (o *GetAlertGroupsOK) IsSuccess() bool { return true } // IsRedirect returns true when this get alert groups o k response has a 3xx status code func (o *GetAlertGroupsOK) IsRedirect() bool { return false } // IsClientError returns true when this get alert groups o k response has a 4xx status code func (o *GetAlertGroupsOK) IsClientError() bool { return false } // IsServerError returns true when this get alert groups o k response has a 5xx status code func (o *GetAlertGroupsOK) IsServerError() bool { return false } // IsCode returns true when this get alert groups o k response a status code equal to that given func (o *GetAlertGroupsOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the get alert groups o k response func (o *GetAlertGroupsOK) Code() int { return 200 } func (o *GetAlertGroupsOK) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts/groups][%d] getAlertGroupsOK %s", 200, payload) } func (o *GetAlertGroupsOK) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts/groups][%d] getAlertGroupsOK %s", 200, payload) } func (o *GetAlertGroupsOK) GetPayload() models.AlertGroups { return o.Payload } func (o *GetAlertGroupsOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewGetAlertGroupsBadRequest creates a GetAlertGroupsBadRequest with default headers values func NewGetAlertGroupsBadRequest() *GetAlertGroupsBadRequest { return &GetAlertGroupsBadRequest{} } /* GetAlertGroupsBadRequest describes a response with status code 400, with default header values. Bad request */ type GetAlertGroupsBadRequest struct { Payload string } // IsSuccess returns true when this get alert groups bad request response has a 2xx status code func (o *GetAlertGroupsBadRequest) IsSuccess() bool { return false } // IsRedirect returns true when this get alert groups bad request response has a 3xx status code func (o *GetAlertGroupsBadRequest) IsRedirect() bool { return false } // IsClientError returns true when this get alert groups bad request response has a 4xx status code func (o *GetAlertGroupsBadRequest) IsClientError() bool { return true } // IsServerError returns true when this get alert groups bad request response has a 5xx status code func (o *GetAlertGroupsBadRequest) IsServerError() bool { return false } // IsCode returns true when this get alert groups bad request response a status code equal to that given func (o *GetAlertGroupsBadRequest) IsCode(code int) bool { return code == 400 } // Code gets the status code for the get alert groups bad request response func (o *GetAlertGroupsBadRequest) Code() int { return 400 } func (o *GetAlertGroupsBadRequest) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts/groups][%d] getAlertGroupsBadRequest %s", 400, payload) } func (o *GetAlertGroupsBadRequest) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts/groups][%d] getAlertGroupsBadRequest %s", 400, payload) } func (o *GetAlertGroupsBadRequest) GetPayload() string { return o.Payload } func (o *GetAlertGroupsBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewGetAlertGroupsInternalServerError creates a GetAlertGroupsInternalServerError with default headers values func NewGetAlertGroupsInternalServerError() *GetAlertGroupsInternalServerError { return &GetAlertGroupsInternalServerError{} } /* GetAlertGroupsInternalServerError describes a response with status code 500, with default header values. Internal server error */ type GetAlertGroupsInternalServerError struct { Payload string } // IsSuccess returns true when this get alert groups internal server error response has a 2xx status code func (o *GetAlertGroupsInternalServerError) IsSuccess() bool { return false } // IsRedirect returns true when this get alert groups internal server error response has a 3xx status code func (o *GetAlertGroupsInternalServerError) IsRedirect() bool { return false } // IsClientError returns true when this get alert groups internal server error response has a 4xx status code func (o *GetAlertGroupsInternalServerError) IsClientError() bool { return false } // IsServerError returns true when this get alert groups internal server error response has a 5xx status code func (o *GetAlertGroupsInternalServerError) IsServerError() bool { return true } // IsCode returns true when this get alert groups internal server error response a status code equal to that given func (o *GetAlertGroupsInternalServerError) IsCode(code int) bool { return code == 500 } // Code gets the status code for the get alert groups internal server error response func (o *GetAlertGroupsInternalServerError) Code() int { return 500 } func (o *GetAlertGroupsInternalServerError) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts/groups][%d] getAlertGroupsInternalServerError %s", 500, payload) } func (o *GetAlertGroupsInternalServerError) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /alerts/groups][%d] getAlertGroupsInternalServerError %s", 500, payload) } func (o *GetAlertGroupsInternalServerError) GetPayload() string { return o.Payload } func (o *GetAlertGroupsInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } ================================================ FILE: api/v2/client/alertmanager_api_client.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package client // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "github.com/go-openapi/runtime" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/client/alert" "github.com/prometheus/alertmanager/api/v2/client/alertgroup" "github.com/prometheus/alertmanager/api/v2/client/general" "github.com/prometheus/alertmanager/api/v2/client/receiver" "github.com/prometheus/alertmanager/api/v2/client/silence" ) // Default alertmanager API HTTP client. var Default = NewHTTPClient(nil) const ( // DefaultHost is the default Host // found in Meta (info) section of spec file DefaultHost string = "localhost" // DefaultBasePath is the default BasePath // found in Meta (info) section of spec file DefaultBasePath string = "/api/v2/" ) // DefaultSchemes are the default schemes found in Meta (info) section of spec file var DefaultSchemes = []string{"http"} // NewHTTPClient creates a new alertmanager API HTTP client. func NewHTTPClient(formats strfmt.Registry) *AlertmanagerAPI { return NewHTTPClientWithConfig(formats, nil) } // NewHTTPClientWithConfig creates a new alertmanager API HTTP client, // using a customizable transport config. func NewHTTPClientWithConfig(formats strfmt.Registry, cfg *TransportConfig) *AlertmanagerAPI { // ensure nullable parameters have default if cfg == nil { cfg = DefaultTransportConfig() } // create transport and client transport := httptransport.New(cfg.Host, cfg.BasePath, cfg.Schemes) return New(transport, formats) } // New creates a new alertmanager API client func New(transport runtime.ClientTransport, formats strfmt.Registry) *AlertmanagerAPI { // ensure nullable parameters have default if formats == nil { formats = strfmt.Default } cli := new(AlertmanagerAPI) cli.Transport = transport cli.Alert = alert.New(transport, formats) cli.Alertgroup = alertgroup.New(transport, formats) cli.General = general.New(transport, formats) cli.Receiver = receiver.New(transport, formats) cli.Silence = silence.New(transport, formats) return cli } // DefaultTransportConfig creates a TransportConfig with the // default settings taken from the meta section of the spec file. func DefaultTransportConfig() *TransportConfig { return &TransportConfig{ Host: DefaultHost, BasePath: DefaultBasePath, Schemes: DefaultSchemes, } } // TransportConfig contains the transport related info, // found in the meta section of the spec file. type TransportConfig struct { Host string BasePath string Schemes []string } // WithHost overrides the default host, // provided by the meta section of the spec file. func (cfg *TransportConfig) WithHost(host string) *TransportConfig { cfg.Host = host return cfg } // WithBasePath overrides the default basePath, // provided by the meta section of the spec file. func (cfg *TransportConfig) WithBasePath(basePath string) *TransportConfig { cfg.BasePath = basePath return cfg } // WithSchemes overrides the default schemes, // provided by the meta section of the spec file. func (cfg *TransportConfig) WithSchemes(schemes []string) *TransportConfig { cfg.Schemes = schemes return cfg } // AlertmanagerAPI is a client for alertmanager API type AlertmanagerAPI struct { Alert alert.ClientService Alertgroup alertgroup.ClientService General general.ClientService Receiver receiver.ClientService Silence silence.ClientService Transport runtime.ClientTransport } // SetTransport changes the transport on the client and all its subresources func (c *AlertmanagerAPI) SetTransport(transport runtime.ClientTransport) { c.Transport = transport c.Alert.SetTransport(transport) c.Alertgroup.SetTransport(transport) c.General.SetTransport(transport) c.Receiver.SetTransport(transport) c.Silence.SetTransport(transport) } ================================================ FILE: api/v2/client/general/general_client.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package general // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "fmt" "github.com/go-openapi/runtime" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // New creates a new general API client. func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService { return &Client{transport: transport, formats: formats} } // New creates a new general API client with basic auth credentials. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - user: user for basic authentication header. // - password: password for basic authentication header. func NewClientWithBasicAuth(host, basePath, scheme, user, password string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BasicAuth(user, password) return &Client{transport: transport, formats: strfmt.Default} } // New creates a new general API client with a bearer token for authentication. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - bearerToken: bearer token for Bearer authentication header. func NewClientWithBearerToken(host, basePath, scheme, bearerToken string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BearerToken(bearerToken) return &Client{transport: transport, formats: strfmt.Default} } /* Client for general API */ type Client struct { transport runtime.ClientTransport formats strfmt.Registry } // ClientOption may be used to customize the behavior of Client methods. type ClientOption func(*runtime.ClientOperation) // ClientService is the interface for Client methods type ClientService interface { GetStatus(params *GetStatusParams, opts ...ClientOption) (*GetStatusOK, error) SetTransport(transport runtime.ClientTransport) } /* GetStatus Get current status of an Alertmanager instance and its cluster */ func (a *Client) GetStatus(params *GetStatusParams, opts ...ClientOption) (*GetStatusOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewGetStatusParams() } op := &runtime.ClientOperation{ ID: "getStatus", Method: "GET", PathPattern: "/status", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &GetStatusReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*GetStatusOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for getStatus: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } // SetTransport changes the transport on the client func (a *Client) SetTransport(transport runtime.ClientTransport) { a.transport = transport } ================================================ FILE: api/v2/client/general/get_status_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package general // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // NewGetStatusParams creates a new GetStatusParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewGetStatusParams() *GetStatusParams { return &GetStatusParams{ timeout: cr.DefaultTimeout, } } // NewGetStatusParamsWithTimeout creates a new GetStatusParams object // with the ability to set a timeout on a request. func NewGetStatusParamsWithTimeout(timeout time.Duration) *GetStatusParams { return &GetStatusParams{ timeout: timeout, } } // NewGetStatusParamsWithContext creates a new GetStatusParams object // with the ability to set a context for a request. func NewGetStatusParamsWithContext(ctx context.Context) *GetStatusParams { return &GetStatusParams{ Context: ctx, } } // NewGetStatusParamsWithHTTPClient creates a new GetStatusParams object // with the ability to set a custom HTTPClient for a request. func NewGetStatusParamsWithHTTPClient(client *http.Client) *GetStatusParams { return &GetStatusParams{ HTTPClient: client, } } /* GetStatusParams contains all the parameters to send to the API endpoint for the get status operation. Typically these are written to a http.Request. */ type GetStatusParams struct { timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the get status params (not the query body). // // All values with no default are reset to their zero value. func (o *GetStatusParams) WithDefaults() *GetStatusParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the get status params (not the query body). // // All values with no default are reset to their zero value. func (o *GetStatusParams) SetDefaults() { // no default values defined for this parameter } // WithTimeout adds the timeout to the get status params func (o *GetStatusParams) WithTimeout(timeout time.Duration) *GetStatusParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the get status params func (o *GetStatusParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the get status params func (o *GetStatusParams) WithContext(ctx context.Context) *GetStatusParams { o.SetContext(ctx) return o } // SetContext adds the context to the get status params func (o *GetStatusParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the get status params func (o *GetStatusParams) WithHTTPClient(client *http.Client) *GetStatusParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the get status params func (o *GetStatusParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WriteToRequest writes these params to a swagger request func (o *GetStatusParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/client/general/get_status_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package general // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" ) // GetStatusReader is a Reader for the GetStatus structure. type GetStatusReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *GetStatusReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewGetStatusOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil default: return nil, runtime.NewAPIError("[GET /status] getStatus", response, response.Code()) } } // NewGetStatusOK creates a GetStatusOK with default headers values func NewGetStatusOK() *GetStatusOK { return &GetStatusOK{} } /* GetStatusOK describes a response with status code 200, with default header values. Get status response */ type GetStatusOK struct { Payload *models.AlertmanagerStatus } // IsSuccess returns true when this get status o k response has a 2xx status code func (o *GetStatusOK) IsSuccess() bool { return true } // IsRedirect returns true when this get status o k response has a 3xx status code func (o *GetStatusOK) IsRedirect() bool { return false } // IsClientError returns true when this get status o k response has a 4xx status code func (o *GetStatusOK) IsClientError() bool { return false } // IsServerError returns true when this get status o k response has a 5xx status code func (o *GetStatusOK) IsServerError() bool { return false } // IsCode returns true when this get status o k response a status code equal to that given func (o *GetStatusOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the get status o k response func (o *GetStatusOK) Code() int { return 200 } func (o *GetStatusOK) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /status][%d] getStatusOK %s", 200, payload) } func (o *GetStatusOK) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /status][%d] getStatusOK %s", 200, payload) } func (o *GetStatusOK) GetPayload() *models.AlertmanagerStatus { return o.Payload } func (o *GetStatusOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { o.Payload = new(models.AlertmanagerStatus) // response payload if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } ================================================ FILE: api/v2/client/receiver/get_receivers_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package receiver // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // NewGetReceiversParams creates a new GetReceiversParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewGetReceiversParams() *GetReceiversParams { return &GetReceiversParams{ timeout: cr.DefaultTimeout, } } // NewGetReceiversParamsWithTimeout creates a new GetReceiversParams object // with the ability to set a timeout on a request. func NewGetReceiversParamsWithTimeout(timeout time.Duration) *GetReceiversParams { return &GetReceiversParams{ timeout: timeout, } } // NewGetReceiversParamsWithContext creates a new GetReceiversParams object // with the ability to set a context for a request. func NewGetReceiversParamsWithContext(ctx context.Context) *GetReceiversParams { return &GetReceiversParams{ Context: ctx, } } // NewGetReceiversParamsWithHTTPClient creates a new GetReceiversParams object // with the ability to set a custom HTTPClient for a request. func NewGetReceiversParamsWithHTTPClient(client *http.Client) *GetReceiversParams { return &GetReceiversParams{ HTTPClient: client, } } /* GetReceiversParams contains all the parameters to send to the API endpoint for the get receivers operation. Typically these are written to a http.Request. */ type GetReceiversParams struct { timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the get receivers params (not the query body). // // All values with no default are reset to their zero value. func (o *GetReceiversParams) WithDefaults() *GetReceiversParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the get receivers params (not the query body). // // All values with no default are reset to their zero value. func (o *GetReceiversParams) SetDefaults() { // no default values defined for this parameter } // WithTimeout adds the timeout to the get receivers params func (o *GetReceiversParams) WithTimeout(timeout time.Duration) *GetReceiversParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the get receivers params func (o *GetReceiversParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the get receivers params func (o *GetReceiversParams) WithContext(ctx context.Context) *GetReceiversParams { o.SetContext(ctx) return o } // SetContext adds the context to the get receivers params func (o *GetReceiversParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the get receivers params func (o *GetReceiversParams) WithHTTPClient(client *http.Client) *GetReceiversParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the get receivers params func (o *GetReceiversParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WriteToRequest writes these params to a swagger request func (o *GetReceiversParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/client/receiver/get_receivers_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package receiver // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" ) // GetReceiversReader is a Reader for the GetReceivers structure. type GetReceiversReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *GetReceiversReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewGetReceiversOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil default: return nil, runtime.NewAPIError("[GET /receivers] getReceivers", response, response.Code()) } } // NewGetReceiversOK creates a GetReceiversOK with default headers values func NewGetReceiversOK() *GetReceiversOK { return &GetReceiversOK{} } /* GetReceiversOK describes a response with status code 200, with default header values. Get receivers response */ type GetReceiversOK struct { Payload []*models.Receiver } // IsSuccess returns true when this get receivers o k response has a 2xx status code func (o *GetReceiversOK) IsSuccess() bool { return true } // IsRedirect returns true when this get receivers o k response has a 3xx status code func (o *GetReceiversOK) IsRedirect() bool { return false } // IsClientError returns true when this get receivers o k response has a 4xx status code func (o *GetReceiversOK) IsClientError() bool { return false } // IsServerError returns true when this get receivers o k response has a 5xx status code func (o *GetReceiversOK) IsServerError() bool { return false } // IsCode returns true when this get receivers o k response a status code equal to that given func (o *GetReceiversOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the get receivers o k response func (o *GetReceiversOK) Code() int { return 200 } func (o *GetReceiversOK) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /receivers][%d] getReceiversOK %s", 200, payload) } func (o *GetReceiversOK) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /receivers][%d] getReceiversOK %s", 200, payload) } func (o *GetReceiversOK) GetPayload() []*models.Receiver { return o.Payload } func (o *GetReceiversOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } ================================================ FILE: api/v2/client/receiver/receiver_client.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package receiver // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "fmt" "github.com/go-openapi/runtime" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // New creates a new receiver API client. func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService { return &Client{transport: transport, formats: formats} } // New creates a new receiver API client with basic auth credentials. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - user: user for basic authentication header. // - password: password for basic authentication header. func NewClientWithBasicAuth(host, basePath, scheme, user, password string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BasicAuth(user, password) return &Client{transport: transport, formats: strfmt.Default} } // New creates a new receiver API client with a bearer token for authentication. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - bearerToken: bearer token for Bearer authentication header. func NewClientWithBearerToken(host, basePath, scheme, bearerToken string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BearerToken(bearerToken) return &Client{transport: transport, formats: strfmt.Default} } /* Client for receiver API */ type Client struct { transport runtime.ClientTransport formats strfmt.Registry } // ClientOption may be used to customize the behavior of Client methods. type ClientOption func(*runtime.ClientOperation) // ClientService is the interface for Client methods type ClientService interface { GetReceivers(params *GetReceiversParams, opts ...ClientOption) (*GetReceiversOK, error) SetTransport(transport runtime.ClientTransport) } /* GetReceivers Get list of all receivers (name of notification integrations) */ func (a *Client) GetReceivers(params *GetReceiversParams, opts ...ClientOption) (*GetReceiversOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewGetReceiversParams() } op := &runtime.ClientOperation{ ID: "getReceivers", Method: "GET", PathPattern: "/receivers", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &GetReceiversReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*GetReceiversOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for getReceivers: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } // SetTransport changes the transport on the client func (a *Client) SetTransport(transport runtime.ClientTransport) { a.transport = transport } ================================================ FILE: api/v2/client/silence/delete_silence_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // NewDeleteSilenceParams creates a new DeleteSilenceParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewDeleteSilenceParams() *DeleteSilenceParams { return &DeleteSilenceParams{ timeout: cr.DefaultTimeout, } } // NewDeleteSilenceParamsWithTimeout creates a new DeleteSilenceParams object // with the ability to set a timeout on a request. func NewDeleteSilenceParamsWithTimeout(timeout time.Duration) *DeleteSilenceParams { return &DeleteSilenceParams{ timeout: timeout, } } // NewDeleteSilenceParamsWithContext creates a new DeleteSilenceParams object // with the ability to set a context for a request. func NewDeleteSilenceParamsWithContext(ctx context.Context) *DeleteSilenceParams { return &DeleteSilenceParams{ Context: ctx, } } // NewDeleteSilenceParamsWithHTTPClient creates a new DeleteSilenceParams object // with the ability to set a custom HTTPClient for a request. func NewDeleteSilenceParamsWithHTTPClient(client *http.Client) *DeleteSilenceParams { return &DeleteSilenceParams{ HTTPClient: client, } } /* DeleteSilenceParams contains all the parameters to send to the API endpoint for the delete silence operation. Typically these are written to a http.Request. */ type DeleteSilenceParams struct { /* SilenceID. ID of the silence to get Format: uuid */ SilenceID strfmt.UUID timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the delete silence params (not the query body). // // All values with no default are reset to their zero value. func (o *DeleteSilenceParams) WithDefaults() *DeleteSilenceParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the delete silence params (not the query body). // // All values with no default are reset to their zero value. func (o *DeleteSilenceParams) SetDefaults() { // no default values defined for this parameter } // WithTimeout adds the timeout to the delete silence params func (o *DeleteSilenceParams) WithTimeout(timeout time.Duration) *DeleteSilenceParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the delete silence params func (o *DeleteSilenceParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the delete silence params func (o *DeleteSilenceParams) WithContext(ctx context.Context) *DeleteSilenceParams { o.SetContext(ctx) return o } // SetContext adds the context to the delete silence params func (o *DeleteSilenceParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the delete silence params func (o *DeleteSilenceParams) WithHTTPClient(client *http.Client) *DeleteSilenceParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the delete silence params func (o *DeleteSilenceParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WithSilenceID adds the silenceID to the delete silence params func (o *DeleteSilenceParams) WithSilenceID(silenceID strfmt.UUID) *DeleteSilenceParams { o.SetSilenceID(silenceID) return o } // SetSilenceID adds the silenceId to the delete silence params func (o *DeleteSilenceParams) SetSilenceID(silenceID strfmt.UUID) { o.SilenceID = silenceID } // WriteToRequest writes these params to a swagger request func (o *DeleteSilenceParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error // path param silenceID if err := r.SetPathParam("silenceID", o.SilenceID.String()); err != nil { return err } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/client/silence/delete_silence_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" ) // DeleteSilenceReader is a Reader for the DeleteSilence structure. type DeleteSilenceReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *DeleteSilenceReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewDeleteSilenceOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil case 404: result := NewDeleteSilenceNotFound() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result case 500: result := NewDeleteSilenceInternalServerError() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result default: return nil, runtime.NewAPIError("[DELETE /silence/{silenceID}] deleteSilence", response, response.Code()) } } // NewDeleteSilenceOK creates a DeleteSilenceOK with default headers values func NewDeleteSilenceOK() *DeleteSilenceOK { return &DeleteSilenceOK{} } /* DeleteSilenceOK describes a response with status code 200, with default header values. Delete silence response */ type DeleteSilenceOK struct { } // IsSuccess returns true when this delete silence o k response has a 2xx status code func (o *DeleteSilenceOK) IsSuccess() bool { return true } // IsRedirect returns true when this delete silence o k response has a 3xx status code func (o *DeleteSilenceOK) IsRedirect() bool { return false } // IsClientError returns true when this delete silence o k response has a 4xx status code func (o *DeleteSilenceOK) IsClientError() bool { return false } // IsServerError returns true when this delete silence o k response has a 5xx status code func (o *DeleteSilenceOK) IsServerError() bool { return false } // IsCode returns true when this delete silence o k response a status code equal to that given func (o *DeleteSilenceOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the delete silence o k response func (o *DeleteSilenceOK) Code() int { return 200 } func (o *DeleteSilenceOK) Error() string { return fmt.Sprintf("[DELETE /silence/{silenceID}][%d] deleteSilenceOK", 200) } func (o *DeleteSilenceOK) String() string { return fmt.Sprintf("[DELETE /silence/{silenceID}][%d] deleteSilenceOK", 200) } func (o *DeleteSilenceOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { return nil } // NewDeleteSilenceNotFound creates a DeleteSilenceNotFound with default headers values func NewDeleteSilenceNotFound() *DeleteSilenceNotFound { return &DeleteSilenceNotFound{} } /* DeleteSilenceNotFound describes a response with status code 404, with default header values. A silence with the specified ID was not found */ type DeleteSilenceNotFound struct { } // IsSuccess returns true when this delete silence not found response has a 2xx status code func (o *DeleteSilenceNotFound) IsSuccess() bool { return false } // IsRedirect returns true when this delete silence not found response has a 3xx status code func (o *DeleteSilenceNotFound) IsRedirect() bool { return false } // IsClientError returns true when this delete silence not found response has a 4xx status code func (o *DeleteSilenceNotFound) IsClientError() bool { return true } // IsServerError returns true when this delete silence not found response has a 5xx status code func (o *DeleteSilenceNotFound) IsServerError() bool { return false } // IsCode returns true when this delete silence not found response a status code equal to that given func (o *DeleteSilenceNotFound) IsCode(code int) bool { return code == 404 } // Code gets the status code for the delete silence not found response func (o *DeleteSilenceNotFound) Code() int { return 404 } func (o *DeleteSilenceNotFound) Error() string { return fmt.Sprintf("[DELETE /silence/{silenceID}][%d] deleteSilenceNotFound", 404) } func (o *DeleteSilenceNotFound) String() string { return fmt.Sprintf("[DELETE /silence/{silenceID}][%d] deleteSilenceNotFound", 404) } func (o *DeleteSilenceNotFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { return nil } // NewDeleteSilenceInternalServerError creates a DeleteSilenceInternalServerError with default headers values func NewDeleteSilenceInternalServerError() *DeleteSilenceInternalServerError { return &DeleteSilenceInternalServerError{} } /* DeleteSilenceInternalServerError describes a response with status code 500, with default header values. Internal server error */ type DeleteSilenceInternalServerError struct { Payload string } // IsSuccess returns true when this delete silence internal server error response has a 2xx status code func (o *DeleteSilenceInternalServerError) IsSuccess() bool { return false } // IsRedirect returns true when this delete silence internal server error response has a 3xx status code func (o *DeleteSilenceInternalServerError) IsRedirect() bool { return false } // IsClientError returns true when this delete silence internal server error response has a 4xx status code func (o *DeleteSilenceInternalServerError) IsClientError() bool { return false } // IsServerError returns true when this delete silence internal server error response has a 5xx status code func (o *DeleteSilenceInternalServerError) IsServerError() bool { return true } // IsCode returns true when this delete silence internal server error response a status code equal to that given func (o *DeleteSilenceInternalServerError) IsCode(code int) bool { return code == 500 } // Code gets the status code for the delete silence internal server error response func (o *DeleteSilenceInternalServerError) Code() int { return 500 } func (o *DeleteSilenceInternalServerError) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[DELETE /silence/{silenceID}][%d] deleteSilenceInternalServerError %s", 500, payload) } func (o *DeleteSilenceInternalServerError) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[DELETE /silence/{silenceID}][%d] deleteSilenceInternalServerError %s", 500, payload) } func (o *DeleteSilenceInternalServerError) GetPayload() string { return o.Payload } func (o *DeleteSilenceInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } ================================================ FILE: api/v2/client/silence/get_silence_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // NewGetSilenceParams creates a new GetSilenceParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewGetSilenceParams() *GetSilenceParams { return &GetSilenceParams{ timeout: cr.DefaultTimeout, } } // NewGetSilenceParamsWithTimeout creates a new GetSilenceParams object // with the ability to set a timeout on a request. func NewGetSilenceParamsWithTimeout(timeout time.Duration) *GetSilenceParams { return &GetSilenceParams{ timeout: timeout, } } // NewGetSilenceParamsWithContext creates a new GetSilenceParams object // with the ability to set a context for a request. func NewGetSilenceParamsWithContext(ctx context.Context) *GetSilenceParams { return &GetSilenceParams{ Context: ctx, } } // NewGetSilenceParamsWithHTTPClient creates a new GetSilenceParams object // with the ability to set a custom HTTPClient for a request. func NewGetSilenceParamsWithHTTPClient(client *http.Client) *GetSilenceParams { return &GetSilenceParams{ HTTPClient: client, } } /* GetSilenceParams contains all the parameters to send to the API endpoint for the get silence operation. Typically these are written to a http.Request. */ type GetSilenceParams struct { /* SilenceID. ID of the silence to get Format: uuid */ SilenceID strfmt.UUID timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the get silence params (not the query body). // // All values with no default are reset to their zero value. func (o *GetSilenceParams) WithDefaults() *GetSilenceParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the get silence params (not the query body). // // All values with no default are reset to their zero value. func (o *GetSilenceParams) SetDefaults() { // no default values defined for this parameter } // WithTimeout adds the timeout to the get silence params func (o *GetSilenceParams) WithTimeout(timeout time.Duration) *GetSilenceParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the get silence params func (o *GetSilenceParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the get silence params func (o *GetSilenceParams) WithContext(ctx context.Context) *GetSilenceParams { o.SetContext(ctx) return o } // SetContext adds the context to the get silence params func (o *GetSilenceParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the get silence params func (o *GetSilenceParams) WithHTTPClient(client *http.Client) *GetSilenceParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the get silence params func (o *GetSilenceParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WithSilenceID adds the silenceID to the get silence params func (o *GetSilenceParams) WithSilenceID(silenceID strfmt.UUID) *GetSilenceParams { o.SetSilenceID(silenceID) return o } // SetSilenceID adds the silenceId to the get silence params func (o *GetSilenceParams) SetSilenceID(silenceID strfmt.UUID) { o.SilenceID = silenceID } // WriteToRequest writes these params to a swagger request func (o *GetSilenceParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error // path param silenceID if err := r.SetPathParam("silenceID", o.SilenceID.String()); err != nil { return err } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/client/silence/get_silence_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" ) // GetSilenceReader is a Reader for the GetSilence structure. type GetSilenceReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *GetSilenceReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewGetSilenceOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil case 404: result := NewGetSilenceNotFound() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result case 500: result := NewGetSilenceInternalServerError() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result default: return nil, runtime.NewAPIError("[GET /silence/{silenceID}] getSilence", response, response.Code()) } } // NewGetSilenceOK creates a GetSilenceOK with default headers values func NewGetSilenceOK() *GetSilenceOK { return &GetSilenceOK{} } /* GetSilenceOK describes a response with status code 200, with default header values. Get silence response */ type GetSilenceOK struct { Payload *models.GettableSilence } // IsSuccess returns true when this get silence o k response has a 2xx status code func (o *GetSilenceOK) IsSuccess() bool { return true } // IsRedirect returns true when this get silence o k response has a 3xx status code func (o *GetSilenceOK) IsRedirect() bool { return false } // IsClientError returns true when this get silence o k response has a 4xx status code func (o *GetSilenceOK) IsClientError() bool { return false } // IsServerError returns true when this get silence o k response has a 5xx status code func (o *GetSilenceOK) IsServerError() bool { return false } // IsCode returns true when this get silence o k response a status code equal to that given func (o *GetSilenceOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the get silence o k response func (o *GetSilenceOK) Code() int { return 200 } func (o *GetSilenceOK) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silence/{silenceID}][%d] getSilenceOK %s", 200, payload) } func (o *GetSilenceOK) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silence/{silenceID}][%d] getSilenceOK %s", 200, payload) } func (o *GetSilenceOK) GetPayload() *models.GettableSilence { return o.Payload } func (o *GetSilenceOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { o.Payload = new(models.GettableSilence) // response payload if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewGetSilenceNotFound creates a GetSilenceNotFound with default headers values func NewGetSilenceNotFound() *GetSilenceNotFound { return &GetSilenceNotFound{} } /* GetSilenceNotFound describes a response with status code 404, with default header values. A silence with the specified ID was not found */ type GetSilenceNotFound struct { } // IsSuccess returns true when this get silence not found response has a 2xx status code func (o *GetSilenceNotFound) IsSuccess() bool { return false } // IsRedirect returns true when this get silence not found response has a 3xx status code func (o *GetSilenceNotFound) IsRedirect() bool { return false } // IsClientError returns true when this get silence not found response has a 4xx status code func (o *GetSilenceNotFound) IsClientError() bool { return true } // IsServerError returns true when this get silence not found response has a 5xx status code func (o *GetSilenceNotFound) IsServerError() bool { return false } // IsCode returns true when this get silence not found response a status code equal to that given func (o *GetSilenceNotFound) IsCode(code int) bool { return code == 404 } // Code gets the status code for the get silence not found response func (o *GetSilenceNotFound) Code() int { return 404 } func (o *GetSilenceNotFound) Error() string { return fmt.Sprintf("[GET /silence/{silenceID}][%d] getSilenceNotFound", 404) } func (o *GetSilenceNotFound) String() string { return fmt.Sprintf("[GET /silence/{silenceID}][%d] getSilenceNotFound", 404) } func (o *GetSilenceNotFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { return nil } // NewGetSilenceInternalServerError creates a GetSilenceInternalServerError with default headers values func NewGetSilenceInternalServerError() *GetSilenceInternalServerError { return &GetSilenceInternalServerError{} } /* GetSilenceInternalServerError describes a response with status code 500, with default header values. Internal server error */ type GetSilenceInternalServerError struct { Payload string } // IsSuccess returns true when this get silence internal server error response has a 2xx status code func (o *GetSilenceInternalServerError) IsSuccess() bool { return false } // IsRedirect returns true when this get silence internal server error response has a 3xx status code func (o *GetSilenceInternalServerError) IsRedirect() bool { return false } // IsClientError returns true when this get silence internal server error response has a 4xx status code func (o *GetSilenceInternalServerError) IsClientError() bool { return false } // IsServerError returns true when this get silence internal server error response has a 5xx status code func (o *GetSilenceInternalServerError) IsServerError() bool { return true } // IsCode returns true when this get silence internal server error response a status code equal to that given func (o *GetSilenceInternalServerError) IsCode(code int) bool { return code == 500 } // Code gets the status code for the get silence internal server error response func (o *GetSilenceInternalServerError) Code() int { return 500 } func (o *GetSilenceInternalServerError) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silence/{silenceID}][%d] getSilenceInternalServerError %s", 500, payload) } func (o *GetSilenceInternalServerError) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silence/{silenceID}][%d] getSilenceInternalServerError %s", 500, payload) } func (o *GetSilenceInternalServerError) GetPayload() string { return o.Payload } func (o *GetSilenceInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } ================================================ FILE: api/v2/client/silence/get_silences_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // NewGetSilencesParams creates a new GetSilencesParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewGetSilencesParams() *GetSilencesParams { return &GetSilencesParams{ timeout: cr.DefaultTimeout, } } // NewGetSilencesParamsWithTimeout creates a new GetSilencesParams object // with the ability to set a timeout on a request. func NewGetSilencesParamsWithTimeout(timeout time.Duration) *GetSilencesParams { return &GetSilencesParams{ timeout: timeout, } } // NewGetSilencesParamsWithContext creates a new GetSilencesParams object // with the ability to set a context for a request. func NewGetSilencesParamsWithContext(ctx context.Context) *GetSilencesParams { return &GetSilencesParams{ Context: ctx, } } // NewGetSilencesParamsWithHTTPClient creates a new GetSilencesParams object // with the ability to set a custom HTTPClient for a request. func NewGetSilencesParamsWithHTTPClient(client *http.Client) *GetSilencesParams { return &GetSilencesParams{ HTTPClient: client, } } /* GetSilencesParams contains all the parameters to send to the API endpoint for the get silences operation. Typically these are written to a http.Request. */ type GetSilencesParams struct { /* Filter. A matcher expression to filter silences. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. */ Filter []string timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the get silences params (not the query body). // // All values with no default are reset to their zero value. func (o *GetSilencesParams) WithDefaults() *GetSilencesParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the get silences params (not the query body). // // All values with no default are reset to their zero value. func (o *GetSilencesParams) SetDefaults() { // no default values defined for this parameter } // WithTimeout adds the timeout to the get silences params func (o *GetSilencesParams) WithTimeout(timeout time.Duration) *GetSilencesParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the get silences params func (o *GetSilencesParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the get silences params func (o *GetSilencesParams) WithContext(ctx context.Context) *GetSilencesParams { o.SetContext(ctx) return o } // SetContext adds the context to the get silences params func (o *GetSilencesParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the get silences params func (o *GetSilencesParams) WithHTTPClient(client *http.Client) *GetSilencesParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the get silences params func (o *GetSilencesParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WithFilter adds the filter to the get silences params func (o *GetSilencesParams) WithFilter(filter []string) *GetSilencesParams { o.SetFilter(filter) return o } // SetFilter adds the filter to the get silences params func (o *GetSilencesParams) SetFilter(filter []string) { o.Filter = filter } // WriteToRequest writes these params to a swagger request func (o *GetSilencesParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error if o.Filter != nil { // binding items for filter joinedFilter := o.bindParamFilter(reg) // query array param filter if err := r.SetQueryParam("filter", joinedFilter...); err != nil { return err } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // bindParamGetSilences binds the parameter filter func (o *GetSilencesParams) bindParamFilter(formats strfmt.Registry) []string { filterIR := o.Filter var filterIC []string for _, filterIIR := range filterIR { // explode []string filterIIV := filterIIR // string as string filterIC = append(filterIC, filterIIV) } // items.CollectionFormat: "multi" filterIS := swag.JoinByFormat(filterIC, "multi") return filterIS } ================================================ FILE: api/v2/client/silence/get_silences_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" ) // GetSilencesReader is a Reader for the GetSilences structure. type GetSilencesReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *GetSilencesReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewGetSilencesOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil case 400: result := NewGetSilencesBadRequest() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result case 500: result := NewGetSilencesInternalServerError() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result default: return nil, runtime.NewAPIError("[GET /silences] getSilences", response, response.Code()) } } // NewGetSilencesOK creates a GetSilencesOK with default headers values func NewGetSilencesOK() *GetSilencesOK { return &GetSilencesOK{} } /* GetSilencesOK describes a response with status code 200, with default header values. Get silences response */ type GetSilencesOK struct { Payload models.GettableSilences } // IsSuccess returns true when this get silences o k response has a 2xx status code func (o *GetSilencesOK) IsSuccess() bool { return true } // IsRedirect returns true when this get silences o k response has a 3xx status code func (o *GetSilencesOK) IsRedirect() bool { return false } // IsClientError returns true when this get silences o k response has a 4xx status code func (o *GetSilencesOK) IsClientError() bool { return false } // IsServerError returns true when this get silences o k response has a 5xx status code func (o *GetSilencesOK) IsServerError() bool { return false } // IsCode returns true when this get silences o k response a status code equal to that given func (o *GetSilencesOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the get silences o k response func (o *GetSilencesOK) Code() int { return 200 } func (o *GetSilencesOK) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silences][%d] getSilencesOK %s", 200, payload) } func (o *GetSilencesOK) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silences][%d] getSilencesOK %s", 200, payload) } func (o *GetSilencesOK) GetPayload() models.GettableSilences { return o.Payload } func (o *GetSilencesOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewGetSilencesBadRequest creates a GetSilencesBadRequest with default headers values func NewGetSilencesBadRequest() *GetSilencesBadRequest { return &GetSilencesBadRequest{} } /* GetSilencesBadRequest describes a response with status code 400, with default header values. Bad request */ type GetSilencesBadRequest struct { Payload string } // IsSuccess returns true when this get silences bad request response has a 2xx status code func (o *GetSilencesBadRequest) IsSuccess() bool { return false } // IsRedirect returns true when this get silences bad request response has a 3xx status code func (o *GetSilencesBadRequest) IsRedirect() bool { return false } // IsClientError returns true when this get silences bad request response has a 4xx status code func (o *GetSilencesBadRequest) IsClientError() bool { return true } // IsServerError returns true when this get silences bad request response has a 5xx status code func (o *GetSilencesBadRequest) IsServerError() bool { return false } // IsCode returns true when this get silences bad request response a status code equal to that given func (o *GetSilencesBadRequest) IsCode(code int) bool { return code == 400 } // Code gets the status code for the get silences bad request response func (o *GetSilencesBadRequest) Code() int { return 400 } func (o *GetSilencesBadRequest) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silences][%d] getSilencesBadRequest %s", 400, payload) } func (o *GetSilencesBadRequest) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silences][%d] getSilencesBadRequest %s", 400, payload) } func (o *GetSilencesBadRequest) GetPayload() string { return o.Payload } func (o *GetSilencesBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewGetSilencesInternalServerError creates a GetSilencesInternalServerError with default headers values func NewGetSilencesInternalServerError() *GetSilencesInternalServerError { return &GetSilencesInternalServerError{} } /* GetSilencesInternalServerError describes a response with status code 500, with default header values. Internal server error */ type GetSilencesInternalServerError struct { Payload string } // IsSuccess returns true when this get silences internal server error response has a 2xx status code func (o *GetSilencesInternalServerError) IsSuccess() bool { return false } // IsRedirect returns true when this get silences internal server error response has a 3xx status code func (o *GetSilencesInternalServerError) IsRedirect() bool { return false } // IsClientError returns true when this get silences internal server error response has a 4xx status code func (o *GetSilencesInternalServerError) IsClientError() bool { return false } // IsServerError returns true when this get silences internal server error response has a 5xx status code func (o *GetSilencesInternalServerError) IsServerError() bool { return true } // IsCode returns true when this get silences internal server error response a status code equal to that given func (o *GetSilencesInternalServerError) IsCode(code int) bool { return code == 500 } // Code gets the status code for the get silences internal server error response func (o *GetSilencesInternalServerError) Code() int { return 500 } func (o *GetSilencesInternalServerError) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silences][%d] getSilencesInternalServerError %s", 500, payload) } func (o *GetSilencesInternalServerError) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[GET /silences][%d] getSilencesInternalServerError %s", 500, payload) } func (o *GetSilencesInternalServerError) GetPayload() string { return o.Payload } func (o *GetSilencesInternalServerError) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } ================================================ FILE: api/v2/client/silence/post_silences_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "net/http" "time" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" cr "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" ) // NewPostSilencesParams creates a new PostSilencesParams object, // with the default timeout for this client. // // Default values are not hydrated, since defaults are normally applied by the API server side. // // To enforce default values in parameter, use SetDefaults or WithDefaults. func NewPostSilencesParams() *PostSilencesParams { return &PostSilencesParams{ timeout: cr.DefaultTimeout, } } // NewPostSilencesParamsWithTimeout creates a new PostSilencesParams object // with the ability to set a timeout on a request. func NewPostSilencesParamsWithTimeout(timeout time.Duration) *PostSilencesParams { return &PostSilencesParams{ timeout: timeout, } } // NewPostSilencesParamsWithContext creates a new PostSilencesParams object // with the ability to set a context for a request. func NewPostSilencesParamsWithContext(ctx context.Context) *PostSilencesParams { return &PostSilencesParams{ Context: ctx, } } // NewPostSilencesParamsWithHTTPClient creates a new PostSilencesParams object // with the ability to set a custom HTTPClient for a request. func NewPostSilencesParamsWithHTTPClient(client *http.Client) *PostSilencesParams { return &PostSilencesParams{ HTTPClient: client, } } /* PostSilencesParams contains all the parameters to send to the API endpoint for the post silences operation. Typically these are written to a http.Request. */ type PostSilencesParams struct { /* Silence. The silence to create */ Silence *models.PostableSilence timeout time.Duration Context context.Context HTTPClient *http.Client } // WithDefaults hydrates default values in the post silences params (not the query body). // // All values with no default are reset to their zero value. func (o *PostSilencesParams) WithDefaults() *PostSilencesParams { o.SetDefaults() return o } // SetDefaults hydrates default values in the post silences params (not the query body). // // All values with no default are reset to their zero value. func (o *PostSilencesParams) SetDefaults() { // no default values defined for this parameter } // WithTimeout adds the timeout to the post silences params func (o *PostSilencesParams) WithTimeout(timeout time.Duration) *PostSilencesParams { o.SetTimeout(timeout) return o } // SetTimeout adds the timeout to the post silences params func (o *PostSilencesParams) SetTimeout(timeout time.Duration) { o.timeout = timeout } // WithContext adds the context to the post silences params func (o *PostSilencesParams) WithContext(ctx context.Context) *PostSilencesParams { o.SetContext(ctx) return o } // SetContext adds the context to the post silences params func (o *PostSilencesParams) SetContext(ctx context.Context) { o.Context = ctx } // WithHTTPClient adds the HTTPClient to the post silences params func (o *PostSilencesParams) WithHTTPClient(client *http.Client) *PostSilencesParams { o.SetHTTPClient(client) return o } // SetHTTPClient adds the HTTPClient to the post silences params func (o *PostSilencesParams) SetHTTPClient(client *http.Client) { o.HTTPClient = client } // WithSilence adds the silence to the post silences params func (o *PostSilencesParams) WithSilence(silence *models.PostableSilence) *PostSilencesParams { o.SetSilence(silence) return o } // SetSilence adds the silence to the post silences params func (o *PostSilencesParams) SetSilence(silence *models.PostableSilence) { o.Silence = silence } // WriteToRequest writes these params to a swagger request func (o *PostSilencesParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { if err := r.SetTimeout(o.timeout); err != nil { return err } var res []error if o.Silence != nil { if err := r.SetBodyParam(o.Silence); err != nil { return err } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/client/silence/post_silences_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "encoding/json" stderrors "errors" "fmt" "io" "github.com/go-openapi/runtime" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // PostSilencesReader is a Reader for the PostSilences structure. type PostSilencesReader struct { formats strfmt.Registry } // ReadResponse reads a server response into the received o. func (o *PostSilencesReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (any, error) { switch response.Code() { case 200: result := NewPostSilencesOK() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return result, nil case 400: result := NewPostSilencesBadRequest() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result case 404: result := NewPostSilencesNotFound() if err := result.readResponse(response, consumer, o.formats); err != nil { return nil, err } return nil, result default: return nil, runtime.NewAPIError("[POST /silences] postSilences", response, response.Code()) } } // NewPostSilencesOK creates a PostSilencesOK with default headers values func NewPostSilencesOK() *PostSilencesOK { return &PostSilencesOK{} } /* PostSilencesOK describes a response with status code 200, with default header values. Create / update silence response */ type PostSilencesOK struct { Payload *PostSilencesOKBody } // IsSuccess returns true when this post silences o k response has a 2xx status code func (o *PostSilencesOK) IsSuccess() bool { return true } // IsRedirect returns true when this post silences o k response has a 3xx status code func (o *PostSilencesOK) IsRedirect() bool { return false } // IsClientError returns true when this post silences o k response has a 4xx status code func (o *PostSilencesOK) IsClientError() bool { return false } // IsServerError returns true when this post silences o k response has a 5xx status code func (o *PostSilencesOK) IsServerError() bool { return false } // IsCode returns true when this post silences o k response a status code equal to that given func (o *PostSilencesOK) IsCode(code int) bool { return code == 200 } // Code gets the status code for the post silences o k response func (o *PostSilencesOK) Code() int { return 200 } func (o *PostSilencesOK) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /silences][%d] postSilencesOK %s", 200, payload) } func (o *PostSilencesOK) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /silences][%d] postSilencesOK %s", 200, payload) } func (o *PostSilencesOK) GetPayload() *PostSilencesOKBody { return o.Payload } func (o *PostSilencesOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { o.Payload = new(PostSilencesOKBody) // response payload if err := consumer.Consume(response.Body(), o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewPostSilencesBadRequest creates a PostSilencesBadRequest with default headers values func NewPostSilencesBadRequest() *PostSilencesBadRequest { return &PostSilencesBadRequest{} } /* PostSilencesBadRequest describes a response with status code 400, with default header values. Bad request */ type PostSilencesBadRequest struct { Payload string } // IsSuccess returns true when this post silences bad request response has a 2xx status code func (o *PostSilencesBadRequest) IsSuccess() bool { return false } // IsRedirect returns true when this post silences bad request response has a 3xx status code func (o *PostSilencesBadRequest) IsRedirect() bool { return false } // IsClientError returns true when this post silences bad request response has a 4xx status code func (o *PostSilencesBadRequest) IsClientError() bool { return true } // IsServerError returns true when this post silences bad request response has a 5xx status code func (o *PostSilencesBadRequest) IsServerError() bool { return false } // IsCode returns true when this post silences bad request response a status code equal to that given func (o *PostSilencesBadRequest) IsCode(code int) bool { return code == 400 } // Code gets the status code for the post silences bad request response func (o *PostSilencesBadRequest) Code() int { return 400 } func (o *PostSilencesBadRequest) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /silences][%d] postSilencesBadRequest %s", 400, payload) } func (o *PostSilencesBadRequest) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /silences][%d] postSilencesBadRequest %s", 400, payload) } func (o *PostSilencesBadRequest) GetPayload() string { return o.Payload } func (o *PostSilencesBadRequest) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } // NewPostSilencesNotFound creates a PostSilencesNotFound with default headers values func NewPostSilencesNotFound() *PostSilencesNotFound { return &PostSilencesNotFound{} } /* PostSilencesNotFound describes a response with status code 404, with default header values. A silence with the specified ID was not found */ type PostSilencesNotFound struct { Payload string } // IsSuccess returns true when this post silences not found response has a 2xx status code func (o *PostSilencesNotFound) IsSuccess() bool { return false } // IsRedirect returns true when this post silences not found response has a 3xx status code func (o *PostSilencesNotFound) IsRedirect() bool { return false } // IsClientError returns true when this post silences not found response has a 4xx status code func (o *PostSilencesNotFound) IsClientError() bool { return true } // IsServerError returns true when this post silences not found response has a 5xx status code func (o *PostSilencesNotFound) IsServerError() bool { return false } // IsCode returns true when this post silences not found response a status code equal to that given func (o *PostSilencesNotFound) IsCode(code int) bool { return code == 404 } // Code gets the status code for the post silences not found response func (o *PostSilencesNotFound) Code() int { return 404 } func (o *PostSilencesNotFound) Error() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /silences][%d] postSilencesNotFound %s", 404, payload) } func (o *PostSilencesNotFound) String() string { payload, _ := json.Marshal(o.Payload) return fmt.Sprintf("[POST /silences][%d] postSilencesNotFound %s", 404, payload) } func (o *PostSilencesNotFound) GetPayload() string { return o.Payload } func (o *PostSilencesNotFound) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { // response payload if err := consumer.Consume(response.Body(), &o.Payload); err != nil && !stderrors.Is(err, io.EOF) { return err } return nil } /* PostSilencesOKBody post silences o k body swagger:model PostSilencesOKBody */ type PostSilencesOKBody struct { // silence ID SilenceID string `json:"silenceID,omitempty"` } // Validate validates this post silences o k body func (o *PostSilencesOKBody) Validate(formats strfmt.Registry) error { return nil } // ContextValidate validates this post silences o k body based on context it is used func (o *PostSilencesOKBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (o *PostSilencesOKBody) MarshalBinary() ([]byte, error) { if o == nil { return nil, nil } return swag.WriteJSON(o) } // UnmarshalBinary interface implementation func (o *PostSilencesOKBody) UnmarshalBinary(b []byte) error { var res PostSilencesOKBody if err := swag.ReadJSON(b, &res); err != nil { return err } *o = res return nil } ================================================ FILE: api/v2/client/silence/silence_client.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "fmt" "github.com/go-openapi/runtime" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // New creates a new silence API client. func New(transport runtime.ClientTransport, formats strfmt.Registry) ClientService { return &Client{transport: transport, formats: formats} } // New creates a new silence API client with basic auth credentials. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - user: user for basic authentication header. // - password: password for basic authentication header. func NewClientWithBasicAuth(host, basePath, scheme, user, password string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BasicAuth(user, password) return &Client{transport: transport, formats: strfmt.Default} } // New creates a new silence API client with a bearer token for authentication. // It takes the following parameters: // - host: http host (github.com). // - basePath: any base path for the API client ("/v1", "/v3"). // - scheme: http scheme ("http", "https"). // - bearerToken: bearer token for Bearer authentication header. func NewClientWithBearerToken(host, basePath, scheme, bearerToken string) ClientService { transport := httptransport.New(host, basePath, []string{scheme}) transport.DefaultAuthentication = httptransport.BearerToken(bearerToken) return &Client{transport: transport, formats: strfmt.Default} } /* Client for silence API */ type Client struct { transport runtime.ClientTransport formats strfmt.Registry } // ClientOption may be used to customize the behavior of Client methods. type ClientOption func(*runtime.ClientOperation) // ClientService is the interface for Client methods type ClientService interface { DeleteSilence(params *DeleteSilenceParams, opts ...ClientOption) (*DeleteSilenceOK, error) GetSilence(params *GetSilenceParams, opts ...ClientOption) (*GetSilenceOK, error) GetSilences(params *GetSilencesParams, opts ...ClientOption) (*GetSilencesOK, error) PostSilences(params *PostSilencesParams, opts ...ClientOption) (*PostSilencesOK, error) SetTransport(transport runtime.ClientTransport) } /* DeleteSilence Delete a silence by its ID */ func (a *Client) DeleteSilence(params *DeleteSilenceParams, opts ...ClientOption) (*DeleteSilenceOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewDeleteSilenceParams() } op := &runtime.ClientOperation{ ID: "deleteSilence", Method: "DELETE", PathPattern: "/silence/{silenceID}", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &DeleteSilenceReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*DeleteSilenceOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for deleteSilence: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } /* GetSilence Get a silence by its ID */ func (a *Client) GetSilence(params *GetSilenceParams, opts ...ClientOption) (*GetSilenceOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewGetSilenceParams() } op := &runtime.ClientOperation{ ID: "getSilence", Method: "GET", PathPattern: "/silence/{silenceID}", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &GetSilenceReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*GetSilenceOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for getSilence: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } /* GetSilences Get a list of silences */ func (a *Client) GetSilences(params *GetSilencesParams, opts ...ClientOption) (*GetSilencesOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewGetSilencesParams() } op := &runtime.ClientOperation{ ID: "getSilences", Method: "GET", PathPattern: "/silences", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &GetSilencesReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*GetSilencesOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for getSilences: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } /* PostSilences Post a new silence or update an existing one */ func (a *Client) PostSilences(params *PostSilencesParams, opts ...ClientOption) (*PostSilencesOK, error) { // NOTE: parameters are not validated before sending if params == nil { params = NewPostSilencesParams() } op := &runtime.ClientOperation{ ID: "postSilences", Method: "POST", PathPattern: "/silences", ProducesMediaTypes: []string{"application/json"}, ConsumesMediaTypes: []string{"application/json"}, Schemes: []string{"http"}, Params: params, Reader: &PostSilencesReader{formats: a.formats}, Context: params.Context, Client: params.HTTPClient, } for _, opt := range opts { opt(op) } result, err := a.transport.Submit(op) if err != nil { return nil, err } // only one success response has to be checked success, ok := result.(*PostSilencesOK) if ok { return success, nil } // unexpected success response. // no default response is defined. // // safeguard: normally, in the absence of a default response, unknown success responses return an error above: so this is a codegen issue msg := fmt.Sprintf("unexpected success response for postSilences: API contract not enforced by server. Client expected to get an error, but got: %T", result) panic(msg) } // SetTransport changes the transport on the client func (a *Client) SetTransport(transport runtime.ClientTransport) { a.transport = transport } ================================================ FILE: api/v2/compat.go ================================================ // Copyright 2021 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v2 import ( "context" "fmt" "time" "github.com/go-openapi/strfmt" prometheus_model "github.com/prometheus/common/model" "google.golang.org/protobuf/types/known/timestamppb" open_api_models "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/alertmanager/types" ) // GettableSilenceFromProto converts *silencepb.Silence to open_api_models.GettableSilence. func GettableSilenceFromProto(s *silencepb.Silence) (open_api_models.GettableSilence, error) { start := strfmt.DateTime(s.StartsAt.AsTime()) end := strfmt.DateTime(s.EndsAt.AsTime()) updated := strfmt.DateTime(s.UpdatedAt.AsTime()) state := string(silence.CurrentState(s.StartsAt.AsTime(), s.EndsAt.AsTime())) sil := open_api_models.GettableSilence{ Silence: open_api_models.Silence{ StartsAt: &start, EndsAt: &end, Comment: &s.Comment, CreatedBy: &s.CreatedBy, Annotations: s.Annotations, }, ID: &s.Id, UpdatedAt: &updated, Status: &open_api_models.SilenceStatus{ State: &state, }, } // For backward compatibility, only return silences with a single matcher set if len(s.MatcherSets) > 1 { return sil, fmt.Errorf("silence '%v' has multiple matcher sets which is not supported by this API version", s.Id) } if len(s.MatcherSets) == 0 { return sil, nil } for _, m := range s.MatcherSets[0].Matchers { matcher := &open_api_models.Matcher{ Name: &m.Name, Value: &m.Pattern, } f := false t := true switch m.Type { case silencepb.Matcher_EQUAL: matcher.IsEqual = &t matcher.IsRegex = &f case silencepb.Matcher_NOT_EQUAL: matcher.IsEqual = &f matcher.IsRegex = &f case silencepb.Matcher_REGEXP: matcher.IsEqual = &t matcher.IsRegex = &t case silencepb.Matcher_NOT_REGEXP: matcher.IsEqual = &f matcher.IsRegex = &t default: return sil, fmt.Errorf( "unknown matcher type for matcher '%v' in silence '%v'", m.Name, s.Id, ) } sil.Matchers = append(sil.Matchers, matcher) } return sil, nil } // PostableSilenceToProto converts *open_api_models.PostableSilenc to *silencepb.Silence. func PostableSilenceToProto(s *open_api_models.PostableSilence) (*silencepb.Silence, error) { sil := &silencepb.Silence{ Id: s.ID, StartsAt: timestamppb.New(time.Time(*s.StartsAt)), EndsAt: timestamppb.New(time.Time(*s.EndsAt)), Comment: *s.Comment, CreatedBy: *s.CreatedBy, Annotations: map[string]string{}, } matcherSet := &silencepb.MatcherSet{} for _, m := range s.Matchers { matcher := &silencepb.Matcher{ Name: *m.Name, Pattern: *m.Value, } isEqual := true if m.IsEqual != nil { isEqual = *m.IsEqual } isRegex := false if m.IsRegex != nil { isRegex = *m.IsRegex } switch { case isEqual && !isRegex: matcher.Type = silencepb.Matcher_EQUAL case !isEqual && !isRegex: matcher.Type = silencepb.Matcher_NOT_EQUAL case isEqual && isRegex: matcher.Type = silencepb.Matcher_REGEXP case !isEqual && isRegex: matcher.Type = silencepb.Matcher_NOT_REGEXP } matcherSet.Matchers = append(matcherSet.Matchers, matcher) } sil.MatcherSets = append(sil.MatcherSets, matcherSet) if s.Annotations != nil { sil.Annotations = s.Annotations } return sil, nil } // AlertToOpenAPIAlert converts internal alerts, alert types, and receivers to *open_api_models.GettableAlert. func AlertToOpenAPIAlert(alert *types.Alert, status types.AlertStatus, receivers, mutedBy []string) *open_api_models.GettableAlert { startsAt := strfmt.DateTime(alert.StartsAt) updatedAt := strfmt.DateTime(alert.UpdatedAt) endsAt := strfmt.DateTime(alert.EndsAt) apiReceivers := make([]*open_api_models.Receiver, 0, len(receivers)) for i := range receivers { apiReceivers = append(apiReceivers, &open_api_models.Receiver{Name: &receivers[i]}) } fp := alert.Fingerprint().String() state := string(status.State) if len(mutedBy) > 0 { // If the alert is muted, change the state to suppressed. state = open_api_models.AlertStatusStateSuppressed } aa := &open_api_models.GettableAlert{ Alert: open_api_models.Alert{ GeneratorURL: strfmt.URI(alert.GeneratorURL), Labels: ModelLabelSetToAPILabelSet(alert.Labels), }, Annotations: ModelLabelSetToAPILabelSet(alert.Annotations), StartsAt: &startsAt, UpdatedAt: &updatedAt, EndsAt: &endsAt, Fingerprint: &fp, Receivers: apiReceivers, Status: &open_api_models.AlertStatus{ State: &state, SilencedBy: status.SilencedBy, InhibitedBy: status.InhibitedBy, MutedBy: mutedBy, }, } if aa.Status.SilencedBy == nil { aa.Status.SilencedBy = []string{} } if aa.Status.InhibitedBy == nil { aa.Status.InhibitedBy = []string{} } if aa.Status.MutedBy == nil { aa.Status.MutedBy = []string{} } return aa } // OpenAPIAlertsToAlerts converts open_api_models.PostableAlerts to []*types.Alert. func OpenAPIAlertsToAlerts(ctx context.Context, apiAlerts open_api_models.PostableAlerts) []*types.Alert { _, span := tracer.Start(ctx, "OpenAPIAlertsToAlerts") defer span.End() alerts := make([]*types.Alert, 0, len(apiAlerts)) for _, apiAlert := range apiAlerts { alerts = append(alerts, &types.Alert{ Alert: prometheus_model.Alert{ Labels: APILabelSetToModelLabelSet(apiAlert.Labels), Annotations: APILabelSetToModelLabelSet(apiAlert.Annotations), StartsAt: time.Time(apiAlert.StartsAt), EndsAt: time.Time(apiAlert.EndsAt), GeneratorURL: string(apiAlert.GeneratorURL), }, }) } return alerts } // ModelLabelSetToAPILabelSet converts prometheus_model.LabelSet to open_api_models.LabelSet. func ModelLabelSetToAPILabelSet(modelLabelSet prometheus_model.LabelSet) open_api_models.LabelSet { apiLabelSet := open_api_models.LabelSet{} for key, value := range modelLabelSet { apiLabelSet[string(key)] = string(value) } return apiLabelSet } // APILabelSetToModelLabelSet converts open_api_models.LabelSet to prometheus_model.LabelSet. func APILabelSetToModelLabelSet(apiLabelSet open_api_models.LabelSet) prometheus_model.LabelSet { modelLabelSet := prometheus_model.LabelSet{} for key, value := range apiLabelSet { modelLabelSet[prometheus_model.LabelName(key)] = prometheus_model.LabelValue(value) } return modelLabelSet } ================================================ FILE: api/v2/models/alert.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // Alert alert // // swagger:model alert type Alert struct { // generator URL // Format: uri GeneratorURL strfmt.URI `json:"generatorURL,omitempty"` // labels // Required: true Labels LabelSet `json:"labels"` } // Validate validates this alert func (m *Alert) Validate(formats strfmt.Registry) error { var res []error if err := m.validateGeneratorURL(formats); err != nil { res = append(res, err) } if err := m.validateLabels(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *Alert) validateGeneratorURL(formats strfmt.Registry) error { if swag.IsZero(m.GeneratorURL) { // not required return nil } if err := validate.FormatOf("generatorURL", "body", "uri", m.GeneratorURL.String(), formats); err != nil { return err } return nil } func (m *Alert) validateLabels(formats strfmt.Registry) error { if err := validate.Required("labels", "body", m.Labels); err != nil { return err } if m.Labels != nil { if err := m.Labels.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("labels") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("labels") } return err } } return nil } // ContextValidate validate this alert based on the context it is used func (m *Alert) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error if err := m.contextValidateLabels(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *Alert) contextValidateLabels(ctx context.Context, formats strfmt.Registry) error { if err := m.Labels.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("labels") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("labels") } return err } return nil } // MarshalBinary interface implementation func (m *Alert) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *Alert) UnmarshalBinary(b []byte) error { var res Alert if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/alert_group.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "strconv" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // AlertGroup alert group // // swagger:model alertGroup type AlertGroup struct { // alerts // Required: true Alerts []*GettableAlert `json:"alerts"` // labels // Required: true Labels LabelSet `json:"labels"` // receiver // Required: true Receiver *Receiver `json:"receiver"` } // Validate validates this alert group func (m *AlertGroup) Validate(formats strfmt.Registry) error { var res []error if err := m.validateAlerts(formats); err != nil { res = append(res, err) } if err := m.validateLabels(formats); err != nil { res = append(res, err) } if err := m.validateReceiver(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *AlertGroup) validateAlerts(formats strfmt.Registry) error { if err := validate.Required("alerts", "body", m.Alerts); err != nil { return err } for i := 0; i < len(m.Alerts); i++ { if swag.IsZero(m.Alerts[i]) { // not required continue } if m.Alerts[i] != nil { if err := m.Alerts[i].Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("alerts" + "." + strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("alerts" + "." + strconv.Itoa(i)) } return err } } } return nil } func (m *AlertGroup) validateLabels(formats strfmt.Registry) error { if err := validate.Required("labels", "body", m.Labels); err != nil { return err } if m.Labels != nil { if err := m.Labels.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("labels") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("labels") } return err } } return nil } func (m *AlertGroup) validateReceiver(formats strfmt.Registry) error { if err := validate.Required("receiver", "body", m.Receiver); err != nil { return err } if m.Receiver != nil { if err := m.Receiver.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("receiver") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("receiver") } return err } } return nil } // ContextValidate validate this alert group based on the context it is used func (m *AlertGroup) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error if err := m.contextValidateAlerts(ctx, formats); err != nil { res = append(res, err) } if err := m.contextValidateLabels(ctx, formats); err != nil { res = append(res, err) } if err := m.contextValidateReceiver(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *AlertGroup) contextValidateAlerts(ctx context.Context, formats strfmt.Registry) error { for i := 0; i < len(m.Alerts); i++ { if m.Alerts[i] != nil { if swag.IsZero(m.Alerts[i]) { // not required return nil } if err := m.Alerts[i].ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("alerts" + "." + strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("alerts" + "." + strconv.Itoa(i)) } return err } } } return nil } func (m *AlertGroup) contextValidateLabels(ctx context.Context, formats strfmt.Registry) error { if err := m.Labels.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("labels") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("labels") } return err } return nil } func (m *AlertGroup) contextValidateReceiver(ctx context.Context, formats strfmt.Registry) error { if m.Receiver != nil { if err := m.Receiver.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("receiver") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("receiver") } return err } } return nil } // MarshalBinary interface implementation func (m *AlertGroup) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *AlertGroup) UnmarshalBinary(b []byte) error { var res AlertGroup if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/alert_groups.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "strconv" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // AlertGroups alert groups // // swagger:model alertGroups type AlertGroups []*AlertGroup // Validate validates this alert groups func (m AlertGroups) Validate(formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if swag.IsZero(m[i]) { // not required continue } if m[i] != nil { if err := m[i].Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // ContextValidate validate this alert groups based on the context it is used func (m AlertGroups) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if m[i] != nil { if swag.IsZero(m[i]) { // not required return nil } if err := m[i].ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/models/alert_status.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "encoding/json" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // AlertStatus alert status // // swagger:model alertStatus type AlertStatus struct { // inhibited by // Required: true InhibitedBy []string `json:"inhibitedBy"` // muted by // Required: true MutedBy []string `json:"mutedBy"` // silenced by // Required: true SilencedBy []string `json:"silencedBy"` // state // Required: true // Enum: ["unprocessed","active","suppressed"] State *string `json:"state"` } // Validate validates this alert status func (m *AlertStatus) Validate(formats strfmt.Registry) error { var res []error if err := m.validateInhibitedBy(formats); err != nil { res = append(res, err) } if err := m.validateMutedBy(formats); err != nil { res = append(res, err) } if err := m.validateSilencedBy(formats); err != nil { res = append(res, err) } if err := m.validateState(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *AlertStatus) validateInhibitedBy(formats strfmt.Registry) error { if err := validate.Required("inhibitedBy", "body", m.InhibitedBy); err != nil { return err } return nil } func (m *AlertStatus) validateMutedBy(formats strfmt.Registry) error { if err := validate.Required("mutedBy", "body", m.MutedBy); err != nil { return err } return nil } func (m *AlertStatus) validateSilencedBy(formats strfmt.Registry) error { if err := validate.Required("silencedBy", "body", m.SilencedBy); err != nil { return err } return nil } var alertStatusTypeStatePropEnum []any func init() { var res []string if err := json.Unmarshal([]byte(`["unprocessed","active","suppressed"]`), &res); err != nil { panic(err) } for _, v := range res { alertStatusTypeStatePropEnum = append(alertStatusTypeStatePropEnum, v) } } const ( // AlertStatusStateUnprocessed captures enum value "unprocessed" AlertStatusStateUnprocessed string = "unprocessed" // AlertStatusStateActive captures enum value "active" AlertStatusStateActive string = "active" // AlertStatusStateSuppressed captures enum value "suppressed" AlertStatusStateSuppressed string = "suppressed" ) // prop value enum func (m *AlertStatus) validateStateEnum(path, location string, value string) error { if err := validate.EnumCase(path, location, value, alertStatusTypeStatePropEnum, true); err != nil { return err } return nil } func (m *AlertStatus) validateState(formats strfmt.Registry) error { if err := validate.Required("state", "body", m.State); err != nil { return err } // value enum if err := m.validateStateEnum("state", "body", *m.State); err != nil { return err } return nil } // ContextValidate validates this alert status based on context it is used func (m *AlertStatus) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (m *AlertStatus) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *AlertStatus) UnmarshalBinary(b []byte) error { var res AlertStatus if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/alertmanager_config.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // AlertmanagerConfig alertmanager config // // swagger:model alertmanagerConfig type AlertmanagerConfig struct { // original // Required: true Original *string `json:"original"` } // Validate validates this alertmanager config func (m *AlertmanagerConfig) Validate(formats strfmt.Registry) error { var res []error if err := m.validateOriginal(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *AlertmanagerConfig) validateOriginal(formats strfmt.Registry) error { if err := validate.Required("original", "body", m.Original); err != nil { return err } return nil } // ContextValidate validates this alertmanager config based on context it is used func (m *AlertmanagerConfig) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (m *AlertmanagerConfig) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *AlertmanagerConfig) UnmarshalBinary(b []byte) error { var res AlertmanagerConfig if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/alertmanager_status.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // AlertmanagerStatus alertmanager status // // swagger:model alertmanagerStatus type AlertmanagerStatus struct { // cluster // Required: true Cluster *ClusterStatus `json:"cluster"` // config // Required: true Config *AlertmanagerConfig `json:"config"` // uptime // Required: true // Format: date-time Uptime *strfmt.DateTime `json:"uptime"` // version info // Required: true VersionInfo *VersionInfo `json:"versionInfo"` } // Validate validates this alertmanager status func (m *AlertmanagerStatus) Validate(formats strfmt.Registry) error { var res []error if err := m.validateCluster(formats); err != nil { res = append(res, err) } if err := m.validateConfig(formats); err != nil { res = append(res, err) } if err := m.validateUptime(formats); err != nil { res = append(res, err) } if err := m.validateVersionInfo(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *AlertmanagerStatus) validateCluster(formats strfmt.Registry) error { if err := validate.Required("cluster", "body", m.Cluster); err != nil { return err } if m.Cluster != nil { if err := m.Cluster.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("cluster") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("cluster") } return err } } return nil } func (m *AlertmanagerStatus) validateConfig(formats strfmt.Registry) error { if err := validate.Required("config", "body", m.Config); err != nil { return err } if m.Config != nil { if err := m.Config.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("config") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("config") } return err } } return nil } func (m *AlertmanagerStatus) validateUptime(formats strfmt.Registry) error { if err := validate.Required("uptime", "body", m.Uptime); err != nil { return err } if err := validate.FormatOf("uptime", "body", "date-time", m.Uptime.String(), formats); err != nil { return err } return nil } func (m *AlertmanagerStatus) validateVersionInfo(formats strfmt.Registry) error { if err := validate.Required("versionInfo", "body", m.VersionInfo); err != nil { return err } if m.VersionInfo != nil { if err := m.VersionInfo.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("versionInfo") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("versionInfo") } return err } } return nil } // ContextValidate validate this alertmanager status based on the context it is used func (m *AlertmanagerStatus) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error if err := m.contextValidateCluster(ctx, formats); err != nil { res = append(res, err) } if err := m.contextValidateConfig(ctx, formats); err != nil { res = append(res, err) } if err := m.contextValidateVersionInfo(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *AlertmanagerStatus) contextValidateCluster(ctx context.Context, formats strfmt.Registry) error { if m.Cluster != nil { if err := m.Cluster.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("cluster") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("cluster") } return err } } return nil } func (m *AlertmanagerStatus) contextValidateConfig(ctx context.Context, formats strfmt.Registry) error { if m.Config != nil { if err := m.Config.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("config") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("config") } return err } } return nil } func (m *AlertmanagerStatus) contextValidateVersionInfo(ctx context.Context, formats strfmt.Registry) error { if m.VersionInfo != nil { if err := m.VersionInfo.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("versionInfo") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("versionInfo") } return err } } return nil } // MarshalBinary interface implementation func (m *AlertmanagerStatus) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *AlertmanagerStatus) UnmarshalBinary(b []byte) error { var res AlertmanagerStatus if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/cluster_status.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "encoding/json" stderrors "errors" "strconv" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // ClusterStatus cluster status // // swagger:model clusterStatus type ClusterStatus struct { // name Name string `json:"name,omitempty"` // peers Peers []*PeerStatus `json:"peers"` // status // Required: true // Enum: ["ready","settling","disabled"] Status *string `json:"status"` } // Validate validates this cluster status func (m *ClusterStatus) Validate(formats strfmt.Registry) error { var res []error if err := m.validatePeers(formats); err != nil { res = append(res, err) } if err := m.validateStatus(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *ClusterStatus) validatePeers(formats strfmt.Registry) error { if swag.IsZero(m.Peers) { // not required return nil } for i := 0; i < len(m.Peers); i++ { if swag.IsZero(m.Peers[i]) { // not required continue } if m.Peers[i] != nil { if err := m.Peers[i].Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("peers" + "." + strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("peers" + "." + strconv.Itoa(i)) } return err } } } return nil } var clusterStatusTypeStatusPropEnum []any func init() { var res []string if err := json.Unmarshal([]byte(`["ready","settling","disabled"]`), &res); err != nil { panic(err) } for _, v := range res { clusterStatusTypeStatusPropEnum = append(clusterStatusTypeStatusPropEnum, v) } } const ( // ClusterStatusStatusReady captures enum value "ready" ClusterStatusStatusReady string = "ready" // ClusterStatusStatusSettling captures enum value "settling" ClusterStatusStatusSettling string = "settling" // ClusterStatusStatusDisabled captures enum value "disabled" ClusterStatusStatusDisabled string = "disabled" ) // prop value enum func (m *ClusterStatus) validateStatusEnum(path, location string, value string) error { if err := validate.EnumCase(path, location, value, clusterStatusTypeStatusPropEnum, true); err != nil { return err } return nil } func (m *ClusterStatus) validateStatus(formats strfmt.Registry) error { if err := validate.Required("status", "body", m.Status); err != nil { return err } // value enum if err := m.validateStatusEnum("status", "body", *m.Status); err != nil { return err } return nil } // ContextValidate validate this cluster status based on the context it is used func (m *ClusterStatus) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error if err := m.contextValidatePeers(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *ClusterStatus) contextValidatePeers(ctx context.Context, formats strfmt.Registry) error { for i := 0; i < len(m.Peers); i++ { if m.Peers[i] != nil { if swag.IsZero(m.Peers[i]) { // not required return nil } if err := m.Peers[i].ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("peers" + "." + strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("peers" + "." + strconv.Itoa(i)) } return err } } } return nil } // MarshalBinary interface implementation func (m *ClusterStatus) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *ClusterStatus) UnmarshalBinary(b []byte) error { var res ClusterStatus if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/gettable_alert.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "strconv" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // GettableAlert gettable alert // // swagger:model gettableAlert type GettableAlert struct { // annotations // Required: true Annotations LabelSet `json:"annotations"` // ends at // Required: true // Format: date-time EndsAt *strfmt.DateTime `json:"endsAt"` // fingerprint // Required: true Fingerprint *string `json:"fingerprint"` // receivers // Required: true Receivers []*Receiver `json:"receivers"` // starts at // Required: true // Format: date-time StartsAt *strfmt.DateTime `json:"startsAt"` // status // Required: true Status *AlertStatus `json:"status"` // updated at // Required: true // Format: date-time UpdatedAt *strfmt.DateTime `json:"updatedAt"` Alert } // UnmarshalJSON unmarshals this object from a JSON structure func (m *GettableAlert) UnmarshalJSON(raw []byte) error { // AO0 var dataAO0 struct { Annotations LabelSet `json:"annotations"` EndsAt *strfmt.DateTime `json:"endsAt"` Fingerprint *string `json:"fingerprint"` Receivers []*Receiver `json:"receivers"` StartsAt *strfmt.DateTime `json:"startsAt"` Status *AlertStatus `json:"status"` UpdatedAt *strfmt.DateTime `json:"updatedAt"` } if err := swag.ReadJSON(raw, &dataAO0); err != nil { return err } m.Annotations = dataAO0.Annotations m.EndsAt = dataAO0.EndsAt m.Fingerprint = dataAO0.Fingerprint m.Receivers = dataAO0.Receivers m.StartsAt = dataAO0.StartsAt m.Status = dataAO0.Status m.UpdatedAt = dataAO0.UpdatedAt // AO1 var aO1 Alert if err := swag.ReadJSON(raw, &aO1); err != nil { return err } m.Alert = aO1 return nil } // MarshalJSON marshals this object to a JSON structure func (m GettableAlert) MarshalJSON() ([]byte, error) { _parts := make([][]byte, 0, 2) var dataAO0 struct { Annotations LabelSet `json:"annotations"` EndsAt *strfmt.DateTime `json:"endsAt"` Fingerprint *string `json:"fingerprint"` Receivers []*Receiver `json:"receivers"` StartsAt *strfmt.DateTime `json:"startsAt"` Status *AlertStatus `json:"status"` UpdatedAt *strfmt.DateTime `json:"updatedAt"` } dataAO0.Annotations = m.Annotations dataAO0.EndsAt = m.EndsAt dataAO0.Fingerprint = m.Fingerprint dataAO0.Receivers = m.Receivers dataAO0.StartsAt = m.StartsAt dataAO0.Status = m.Status dataAO0.UpdatedAt = m.UpdatedAt jsonDataAO0, errAO0 := swag.WriteJSON(dataAO0) if errAO0 != nil { return nil, errAO0 } _parts = append(_parts, jsonDataAO0) aO1, err := swag.WriteJSON(m.Alert) if err != nil { return nil, err } _parts = append(_parts, aO1) return swag.ConcatJSON(_parts...), nil } // Validate validates this gettable alert func (m *GettableAlert) Validate(formats strfmt.Registry) error { var res []error if err := m.validateAnnotations(formats); err != nil { res = append(res, err) } if err := m.validateEndsAt(formats); err != nil { res = append(res, err) } if err := m.validateFingerprint(formats); err != nil { res = append(res, err) } if err := m.validateReceivers(formats); err != nil { res = append(res, err) } if err := m.validateStartsAt(formats); err != nil { res = append(res, err) } if err := m.validateStatus(formats); err != nil { res = append(res, err) } if err := m.validateUpdatedAt(formats); err != nil { res = append(res, err) } // validation for a type composition with Alert if err := m.Alert.Validate(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *GettableAlert) validateAnnotations(formats strfmt.Registry) error { if err := validate.Required("annotations", "body", m.Annotations); err != nil { return err } if m.Annotations != nil { if err := m.Annotations.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("annotations") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("annotations") } return err } } return nil } func (m *GettableAlert) validateEndsAt(formats strfmt.Registry) error { if err := validate.Required("endsAt", "body", m.EndsAt); err != nil { return err } if err := validate.FormatOf("endsAt", "body", "date-time", m.EndsAt.String(), formats); err != nil { return err } return nil } func (m *GettableAlert) validateFingerprint(formats strfmt.Registry) error { if err := validate.Required("fingerprint", "body", m.Fingerprint); err != nil { return err } return nil } func (m *GettableAlert) validateReceivers(formats strfmt.Registry) error { if err := validate.Required("receivers", "body", m.Receivers); err != nil { return err } for i := 0; i < len(m.Receivers); i++ { if swag.IsZero(m.Receivers[i]) { // not required continue } if m.Receivers[i] != nil { if err := m.Receivers[i].Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("receivers" + "." + strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("receivers" + "." + strconv.Itoa(i)) } return err } } } return nil } func (m *GettableAlert) validateStartsAt(formats strfmt.Registry) error { if err := validate.Required("startsAt", "body", m.StartsAt); err != nil { return err } if err := validate.FormatOf("startsAt", "body", "date-time", m.StartsAt.String(), formats); err != nil { return err } return nil } func (m *GettableAlert) validateStatus(formats strfmt.Registry) error { if err := validate.Required("status", "body", m.Status); err != nil { return err } if m.Status != nil { if err := m.Status.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("status") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("status") } return err } } return nil } func (m *GettableAlert) validateUpdatedAt(formats strfmt.Registry) error { if err := validate.Required("updatedAt", "body", m.UpdatedAt); err != nil { return err } if err := validate.FormatOf("updatedAt", "body", "date-time", m.UpdatedAt.String(), formats); err != nil { return err } return nil } // ContextValidate validate this gettable alert based on the context it is used func (m *GettableAlert) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error if err := m.contextValidateAnnotations(ctx, formats); err != nil { res = append(res, err) } if err := m.contextValidateReceivers(ctx, formats); err != nil { res = append(res, err) } if err := m.contextValidateStatus(ctx, formats); err != nil { res = append(res, err) } // validation for a type composition with Alert if err := m.Alert.ContextValidate(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *GettableAlert) contextValidateAnnotations(ctx context.Context, formats strfmt.Registry) error { if err := m.Annotations.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("annotations") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("annotations") } return err } return nil } func (m *GettableAlert) contextValidateReceivers(ctx context.Context, formats strfmt.Registry) error { for i := 0; i < len(m.Receivers); i++ { if m.Receivers[i] != nil { if swag.IsZero(m.Receivers[i]) { // not required return nil } if err := m.Receivers[i].ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("receivers" + "." + strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("receivers" + "." + strconv.Itoa(i)) } return err } } } return nil } func (m *GettableAlert) contextValidateStatus(ctx context.Context, formats strfmt.Registry) error { if m.Status != nil { if err := m.Status.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("status") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("status") } return err } } return nil } // MarshalBinary interface implementation func (m *GettableAlert) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *GettableAlert) UnmarshalBinary(b []byte) error { var res GettableAlert if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/gettable_alerts.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "strconv" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // GettableAlerts gettable alerts // // swagger:model gettableAlerts type GettableAlerts []*GettableAlert // Validate validates this gettable alerts func (m GettableAlerts) Validate(formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if swag.IsZero(m[i]) { // not required continue } if m[i] != nil { if err := m[i].Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // ContextValidate validate this gettable alerts based on the context it is used func (m GettableAlerts) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if m[i] != nil { if swag.IsZero(m[i]) { // not required return nil } if err := m[i].ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/models/gettable_silence.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // GettableSilence gettable silence // // swagger:model gettableSilence type GettableSilence struct { // id // Required: true ID *string `json:"id"` // status // Required: true Status *SilenceStatus `json:"status"` // updated at // Required: true // Format: date-time UpdatedAt *strfmt.DateTime `json:"updatedAt"` Silence } // UnmarshalJSON unmarshals this object from a JSON structure func (m *GettableSilence) UnmarshalJSON(raw []byte) error { // AO0 var dataAO0 struct { ID *string `json:"id"` Status *SilenceStatus `json:"status"` UpdatedAt *strfmt.DateTime `json:"updatedAt"` } if err := swag.ReadJSON(raw, &dataAO0); err != nil { return err } m.ID = dataAO0.ID m.Status = dataAO0.Status m.UpdatedAt = dataAO0.UpdatedAt // AO1 var aO1 Silence if err := swag.ReadJSON(raw, &aO1); err != nil { return err } m.Silence = aO1 return nil } // MarshalJSON marshals this object to a JSON structure func (m GettableSilence) MarshalJSON() ([]byte, error) { _parts := make([][]byte, 0, 2) var dataAO0 struct { ID *string `json:"id"` Status *SilenceStatus `json:"status"` UpdatedAt *strfmt.DateTime `json:"updatedAt"` } dataAO0.ID = m.ID dataAO0.Status = m.Status dataAO0.UpdatedAt = m.UpdatedAt jsonDataAO0, errAO0 := swag.WriteJSON(dataAO0) if errAO0 != nil { return nil, errAO0 } _parts = append(_parts, jsonDataAO0) aO1, err := swag.WriteJSON(m.Silence) if err != nil { return nil, err } _parts = append(_parts, aO1) return swag.ConcatJSON(_parts...), nil } // Validate validates this gettable silence func (m *GettableSilence) Validate(formats strfmt.Registry) error { var res []error if err := m.validateID(formats); err != nil { res = append(res, err) } if err := m.validateStatus(formats); err != nil { res = append(res, err) } if err := m.validateUpdatedAt(formats); err != nil { res = append(res, err) } // validation for a type composition with Silence if err := m.Silence.Validate(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *GettableSilence) validateID(formats strfmt.Registry) error { if err := validate.Required("id", "body", m.ID); err != nil { return err } return nil } func (m *GettableSilence) validateStatus(formats strfmt.Registry) error { if err := validate.Required("status", "body", m.Status); err != nil { return err } if m.Status != nil { if err := m.Status.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("status") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("status") } return err } } return nil } func (m *GettableSilence) validateUpdatedAt(formats strfmt.Registry) error { if err := validate.Required("updatedAt", "body", m.UpdatedAt); err != nil { return err } if err := validate.FormatOf("updatedAt", "body", "date-time", m.UpdatedAt.String(), formats); err != nil { return err } return nil } // ContextValidate validate this gettable silence based on the context it is used func (m *GettableSilence) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error if err := m.contextValidateStatus(ctx, formats); err != nil { res = append(res, err) } // validation for a type composition with Silence if err := m.Silence.ContextValidate(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *GettableSilence) contextValidateStatus(ctx context.Context, formats strfmt.Registry) error { if m.Status != nil { if err := m.Status.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("status") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("status") } return err } } return nil } // MarshalBinary interface implementation func (m *GettableSilence) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *GettableSilence) UnmarshalBinary(b []byte) error { var res GettableSilence if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/gettable_silences.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "strconv" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // GettableSilences gettable silences // // swagger:model gettableSilences type GettableSilences []*GettableSilence // Validate validates this gettable silences func (m GettableSilences) Validate(formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if swag.IsZero(m[i]) { // not required continue } if m[i] != nil { if err := m[i].Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // ContextValidate validate this gettable silences based on the context it is used func (m GettableSilences) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if m[i] != nil { if swag.IsZero(m[i]) { // not required return nil } if err := m[i].ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/models/label_set.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "github.com/go-openapi/strfmt" ) // LabelSet label set // // swagger:model labelSet type LabelSet map[string]string // Validate validates this label set func (m LabelSet) Validate(formats strfmt.Registry) error { return nil } // ContextValidate validates this label set based on context it is used func (m LabelSet) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } ================================================ FILE: api/v2/models/matcher.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // Matcher matcher // // swagger:model matcher type Matcher struct { // is equal IsEqual *bool `json:"isEqual,omitempty"` // is regex // Required: true IsRegex *bool `json:"isRegex"` // name // Required: true Name *string `json:"name"` // value // Required: true Value *string `json:"value"` } // Validate validates this matcher func (m *Matcher) Validate(formats strfmt.Registry) error { var res []error if err := m.validateIsRegex(formats); err != nil { res = append(res, err) } if err := m.validateName(formats); err != nil { res = append(res, err) } if err := m.validateValue(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *Matcher) validateIsRegex(formats strfmt.Registry) error { if err := validate.Required("isRegex", "body", m.IsRegex); err != nil { return err } return nil } func (m *Matcher) validateName(formats strfmt.Registry) error { if err := validate.Required("name", "body", m.Name); err != nil { return err } return nil } func (m *Matcher) validateValue(formats strfmt.Registry) error { if err := validate.Required("value", "body", m.Value); err != nil { return err } return nil } // ContextValidate validates this matcher based on context it is used func (m *Matcher) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (m *Matcher) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *Matcher) UnmarshalBinary(b []byte) error { var res Matcher if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/matchers.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "strconv" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // Matchers matchers // // swagger:model matchers type Matchers []*Matcher // Validate validates this matchers func (m Matchers) Validate(formats strfmt.Registry) error { var res []error iMatchersSize := int64(len(m)) if err := validate.MinItems("", "body", iMatchersSize, 1); err != nil { return err } for i := 0; i < len(m); i++ { if swag.IsZero(m[i]) { // not required continue } if m[i] != nil { if err := m[i].Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // ContextValidate validate this matchers based on the context it is used func (m Matchers) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if m[i] != nil { if swag.IsZero(m[i]) { // not required return nil } if err := m[i].ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/models/peer_status.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // PeerStatus peer status // // swagger:model peerStatus type PeerStatus struct { // address // Required: true Address *string `json:"address"` // name // Required: true Name *string `json:"name"` } // Validate validates this peer status func (m *PeerStatus) Validate(formats strfmt.Registry) error { var res []error if err := m.validateAddress(formats); err != nil { res = append(res, err) } if err := m.validateName(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *PeerStatus) validateAddress(formats strfmt.Registry) error { if err := validate.Required("address", "body", m.Address); err != nil { return err } return nil } func (m *PeerStatus) validateName(formats strfmt.Registry) error { if err := validate.Required("name", "body", m.Name); err != nil { return err } return nil } // ContextValidate validates this peer status based on context it is used func (m *PeerStatus) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (m *PeerStatus) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *PeerStatus) UnmarshalBinary(b []byte) error { var res PeerStatus if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/postable_alert.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // PostableAlert postable alert // // swagger:model postableAlert type PostableAlert struct { // annotations Annotations LabelSet `json:"annotations,omitempty"` // ends at // Format: date-time EndsAt strfmt.DateTime `json:"endsAt,omitempty"` // starts at // Format: date-time StartsAt strfmt.DateTime `json:"startsAt,omitempty"` Alert } // UnmarshalJSON unmarshals this object from a JSON structure func (m *PostableAlert) UnmarshalJSON(raw []byte) error { // AO0 var dataAO0 struct { Annotations LabelSet `json:"annotations,omitempty"` EndsAt strfmt.DateTime `json:"endsAt,omitempty"` StartsAt strfmt.DateTime `json:"startsAt,omitempty"` } if err := swag.ReadJSON(raw, &dataAO0); err != nil { return err } m.Annotations = dataAO0.Annotations m.EndsAt = dataAO0.EndsAt m.StartsAt = dataAO0.StartsAt // AO1 var aO1 Alert if err := swag.ReadJSON(raw, &aO1); err != nil { return err } m.Alert = aO1 return nil } // MarshalJSON marshals this object to a JSON structure func (m PostableAlert) MarshalJSON() ([]byte, error) { _parts := make([][]byte, 0, 2) var dataAO0 struct { Annotations LabelSet `json:"annotations,omitempty"` EndsAt strfmt.DateTime `json:"endsAt,omitempty"` StartsAt strfmt.DateTime `json:"startsAt,omitempty"` } dataAO0.Annotations = m.Annotations dataAO0.EndsAt = m.EndsAt dataAO0.StartsAt = m.StartsAt jsonDataAO0, errAO0 := swag.WriteJSON(dataAO0) if errAO0 != nil { return nil, errAO0 } _parts = append(_parts, jsonDataAO0) aO1, err := swag.WriteJSON(m.Alert) if err != nil { return nil, err } _parts = append(_parts, aO1) return swag.ConcatJSON(_parts...), nil } // Validate validates this postable alert func (m *PostableAlert) Validate(formats strfmt.Registry) error { var res []error if err := m.validateAnnotations(formats); err != nil { res = append(res, err) } if err := m.validateEndsAt(formats); err != nil { res = append(res, err) } if err := m.validateStartsAt(formats); err != nil { res = append(res, err) } // validation for a type composition with Alert if err := m.Alert.Validate(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *PostableAlert) validateAnnotations(formats strfmt.Registry) error { if swag.IsZero(m.Annotations) { // not required return nil } if m.Annotations != nil { if err := m.Annotations.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("annotations") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("annotations") } return err } } return nil } func (m *PostableAlert) validateEndsAt(formats strfmt.Registry) error { if swag.IsZero(m.EndsAt) { // not required return nil } if err := validate.FormatOf("endsAt", "body", "date-time", m.EndsAt.String(), formats); err != nil { return err } return nil } func (m *PostableAlert) validateStartsAt(formats strfmt.Registry) error { if swag.IsZero(m.StartsAt) { // not required return nil } if err := validate.FormatOf("startsAt", "body", "date-time", m.StartsAt.String(), formats); err != nil { return err } return nil } // ContextValidate validate this postable alert based on the context it is used func (m *PostableAlert) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error if err := m.contextValidateAnnotations(ctx, formats); err != nil { res = append(res, err) } // validation for a type composition with Alert if err := m.Alert.ContextValidate(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *PostableAlert) contextValidateAnnotations(ctx context.Context, formats strfmt.Registry) error { if swag.IsZero(m.Annotations) { // not required return nil } if err := m.Annotations.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("annotations") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("annotations") } return err } return nil } // MarshalBinary interface implementation func (m *PostableAlert) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *PostableAlert) UnmarshalBinary(b []byte) error { var res PostableAlert if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/postable_alerts.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "strconv" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // PostableAlerts postable alerts // // swagger:model postableAlerts type PostableAlerts []*PostableAlert // Validate validates this postable alerts func (m PostableAlerts) Validate(formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if swag.IsZero(m[i]) { // not required continue } if m[i] != nil { if err := m[i].Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // ContextValidate validate this postable alerts based on the context it is used func (m PostableAlerts) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error for i := 0; i < len(m); i++ { if m[i] != nil { if swag.IsZero(m[i]) { // not required return nil } if err := m[i].ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName(strconv.Itoa(i)) } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName(strconv.Itoa(i)) } return err } } } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/models/postable_silence.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // PostableSilence postable silence // // swagger:model postableSilence type PostableSilence struct { // id ID string `json:"id,omitempty"` Silence } // UnmarshalJSON unmarshals this object from a JSON structure func (m *PostableSilence) UnmarshalJSON(raw []byte) error { // AO0 var dataAO0 struct { ID string `json:"id,omitempty"` } if err := swag.ReadJSON(raw, &dataAO0); err != nil { return err } m.ID = dataAO0.ID // AO1 var aO1 Silence if err := swag.ReadJSON(raw, &aO1); err != nil { return err } m.Silence = aO1 return nil } // MarshalJSON marshals this object to a JSON structure func (m PostableSilence) MarshalJSON() ([]byte, error) { _parts := make([][]byte, 0, 2) var dataAO0 struct { ID string `json:"id,omitempty"` } dataAO0.ID = m.ID jsonDataAO0, errAO0 := swag.WriteJSON(dataAO0) if errAO0 != nil { return nil, errAO0 } _parts = append(_parts, jsonDataAO0) aO1, err := swag.WriteJSON(m.Silence) if err != nil { return nil, err } _parts = append(_parts, aO1) return swag.ConcatJSON(_parts...), nil } // Validate validates this postable silence func (m *PostableSilence) Validate(formats strfmt.Registry) error { var res []error // validation for a type composition with Silence if err := m.Silence.Validate(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // ContextValidate validate this postable silence based on the context it is used func (m *PostableSilence) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error // validation for a type composition with Silence if err := m.Silence.ContextValidate(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // MarshalBinary interface implementation func (m *PostableSilence) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *PostableSilence) UnmarshalBinary(b []byte) error { var res PostableSilence if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/receiver.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // Receiver receiver // // swagger:model receiver type Receiver struct { // name // Required: true Name *string `json:"name"` } // Validate validates this receiver func (m *Receiver) Validate(formats strfmt.Registry) error { var res []error if err := m.validateName(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *Receiver) validateName(formats strfmt.Registry) error { if err := validate.Required("name", "body", m.Name); err != nil { return err } return nil } // ContextValidate validates this receiver based on context it is used func (m *Receiver) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (m *Receiver) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *Receiver) UnmarshalBinary(b []byte) error { var res Receiver if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/silence.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" stderrors "errors" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // Silence silence // // swagger:model silence type Silence struct { // annotations Annotations LabelSet `json:"annotations,omitempty"` // comment // Required: true Comment *string `json:"comment"` // created by // Required: true CreatedBy *string `json:"createdBy"` // ends at // Required: true // Format: date-time EndsAt *strfmt.DateTime `json:"endsAt"` // matchers // Required: true Matchers Matchers `json:"matchers"` // starts at // Required: true // Format: date-time StartsAt *strfmt.DateTime `json:"startsAt"` } // Validate validates this silence func (m *Silence) Validate(formats strfmt.Registry) error { var res []error if err := m.validateAnnotations(formats); err != nil { res = append(res, err) } if err := m.validateComment(formats); err != nil { res = append(res, err) } if err := m.validateCreatedBy(formats); err != nil { res = append(res, err) } if err := m.validateEndsAt(formats); err != nil { res = append(res, err) } if err := m.validateMatchers(formats); err != nil { res = append(res, err) } if err := m.validateStartsAt(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *Silence) validateAnnotations(formats strfmt.Registry) error { if swag.IsZero(m.Annotations) { // not required return nil } if m.Annotations != nil { if err := m.Annotations.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("annotations") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("annotations") } return err } } return nil } func (m *Silence) validateComment(formats strfmt.Registry) error { if err := validate.Required("comment", "body", m.Comment); err != nil { return err } return nil } func (m *Silence) validateCreatedBy(formats strfmt.Registry) error { if err := validate.Required("createdBy", "body", m.CreatedBy); err != nil { return err } return nil } func (m *Silence) validateEndsAt(formats strfmt.Registry) error { if err := validate.Required("endsAt", "body", m.EndsAt); err != nil { return err } if err := validate.FormatOf("endsAt", "body", "date-time", m.EndsAt.String(), formats); err != nil { return err } return nil } func (m *Silence) validateMatchers(formats strfmt.Registry) error { if err := validate.Required("matchers", "body", m.Matchers); err != nil { return err } if err := m.Matchers.Validate(formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("matchers") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("matchers") } return err } return nil } func (m *Silence) validateStartsAt(formats strfmt.Registry) error { if err := validate.Required("startsAt", "body", m.StartsAt); err != nil { return err } if err := validate.FormatOf("startsAt", "body", "date-time", m.StartsAt.String(), formats); err != nil { return err } return nil } // ContextValidate validate this silence based on the context it is used func (m *Silence) ContextValidate(ctx context.Context, formats strfmt.Registry) error { var res []error if err := m.contextValidateAnnotations(ctx, formats); err != nil { res = append(res, err) } if err := m.contextValidateMatchers(ctx, formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *Silence) contextValidateAnnotations(ctx context.Context, formats strfmt.Registry) error { if swag.IsZero(m.Annotations) { // not required return nil } if err := m.Annotations.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("annotations") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("annotations") } return err } return nil } func (m *Silence) contextValidateMatchers(ctx context.Context, formats strfmt.Registry) error { if err := m.Matchers.ContextValidate(ctx, formats); err != nil { ve := new(errors.Validation) if stderrors.As(err, &ve) { return ve.ValidateName("matchers") } ce := new(errors.CompositeError) if stderrors.As(err, &ce) { return ce.ValidateName("matchers") } return err } return nil } // MarshalBinary interface implementation func (m *Silence) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *Silence) UnmarshalBinary(b []byte) error { var res Silence if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/silence_status.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "encoding/json" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // SilenceStatus silence status // // swagger:model silenceStatus type SilenceStatus struct { // state // Required: true // Enum: ["expired","active","pending"] State *string `json:"state"` } // Validate validates this silence status func (m *SilenceStatus) Validate(formats strfmt.Registry) error { var res []error if err := m.validateState(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } var silenceStatusTypeStatePropEnum []any func init() { var res []string if err := json.Unmarshal([]byte(`["expired","active","pending"]`), &res); err != nil { panic(err) } for _, v := range res { silenceStatusTypeStatePropEnum = append(silenceStatusTypeStatePropEnum, v) } } const ( // SilenceStatusStateExpired captures enum value "expired" SilenceStatusStateExpired string = "expired" // SilenceStatusStateActive captures enum value "active" SilenceStatusStateActive string = "active" // SilenceStatusStatePending captures enum value "pending" SilenceStatusStatePending string = "pending" ) // prop value enum func (m *SilenceStatus) validateStateEnum(path, location string, value string) error { if err := validate.EnumCase(path, location, value, silenceStatusTypeStatePropEnum, true); err != nil { return err } return nil } func (m *SilenceStatus) validateState(formats strfmt.Registry) error { if err := validate.Required("state", "body", m.State); err != nil { return err } // value enum if err := m.validateStateEnum("state", "body", *m.State); err != nil { return err } return nil } // ContextValidate validates this silence status based on context it is used func (m *SilenceStatus) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (m *SilenceStatus) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *SilenceStatus) UnmarshalBinary(b []byte) error { var res SilenceStatus if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/models/version_info.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package models // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "context" "github.com/go-openapi/errors" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/go-openapi/validate" ) // VersionInfo version info // // swagger:model versionInfo type VersionInfo struct { // branch // Required: true Branch *string `json:"branch"` // build date // Required: true BuildDate *string `json:"buildDate"` // build user // Required: true BuildUser *string `json:"buildUser"` // go version // Required: true GoVersion *string `json:"goVersion"` // revision // Required: true Revision *string `json:"revision"` // version // Required: true Version *string `json:"version"` } // Validate validates this version info func (m *VersionInfo) Validate(formats strfmt.Registry) error { var res []error if err := m.validateBranch(formats); err != nil { res = append(res, err) } if err := m.validateBuildDate(formats); err != nil { res = append(res, err) } if err := m.validateBuildUser(formats); err != nil { res = append(res, err) } if err := m.validateGoVersion(formats); err != nil { res = append(res, err) } if err := m.validateRevision(formats); err != nil { res = append(res, err) } if err := m.validateVersion(formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } func (m *VersionInfo) validateBranch(formats strfmt.Registry) error { if err := validate.Required("branch", "body", m.Branch); err != nil { return err } return nil } func (m *VersionInfo) validateBuildDate(formats strfmt.Registry) error { if err := validate.Required("buildDate", "body", m.BuildDate); err != nil { return err } return nil } func (m *VersionInfo) validateBuildUser(formats strfmt.Registry) error { if err := validate.Required("buildUser", "body", m.BuildUser); err != nil { return err } return nil } func (m *VersionInfo) validateGoVersion(formats strfmt.Registry) error { if err := validate.Required("goVersion", "body", m.GoVersion); err != nil { return err } return nil } func (m *VersionInfo) validateRevision(formats strfmt.Registry) error { if err := validate.Required("revision", "body", m.Revision); err != nil { return err } return nil } func (m *VersionInfo) validateVersion(formats strfmt.Registry) error { if err := validate.Required("version", "body", m.Version); err != nil { return err } return nil } // ContextValidate validates this version info based on context it is used func (m *VersionInfo) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (m *VersionInfo) MarshalBinary() ([]byte, error) { if m == nil { return nil, nil } return swag.WriteJSON(m) } // UnmarshalBinary interface implementation func (m *VersionInfo) UnmarshalBinary(b []byte) error { var res VersionInfo if err := swag.ReadJSON(b, &res); err != nil { return err } *m = res return nil } ================================================ FILE: api/v2/openapi.yaml ================================================ --- swagger: '2.0' info: version: 0.0.1 title: Alertmanager API description: API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) license: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html consumes: - "application/json" produces: - "application/json" basePath: "/api/v2/" paths: /status: get: tags: - general operationId: getStatus description: Get current status of an Alertmanager instance and its cluster responses: '200': description: Get status response schema: $ref: '#/definitions/alertmanagerStatus' /receivers: get: tags: - receiver operationId: getReceivers description: Get list of all receivers (name of notification integrations) responses: '200': description: Get receivers response schema: type: array items: $ref: '#/definitions/receiver' /silences: get: tags: - silence operationId: getSilences description: Get a list of silences responses: '200': description: Get silences response schema: $ref: '#/definitions/gettableSilences' '400': $ref: '#/responses/BadRequest' '500': $ref: '#/responses/InternalServerError' parameters: - name: filter in: query description: A matcher expression to filter silences. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. required: false type: array collectionFormat: multi items: type: string post: tags: - silence operationId: postSilences description: Post a new silence or update an existing one parameters: - in: body name: silence description: The silence to create required: true schema: $ref: '#/definitions/postableSilence' responses: '200': description: Create / update silence response schema: type: object properties: silenceID: type: string '400': $ref: '#/responses/BadRequest' '404': description: A silence with the specified ID was not found schema: type: string /silence/{silenceID}: parameters: - in: path name: silenceID type: string format: uuid required: true description: ID of the silence to get get: tags: - silence operationId: getSilence description: Get a silence by its ID responses: '200': description: Get silence response schema: $ref: '#/definitions/gettableSilence' '404': description: A silence with the specified ID was not found '500': $ref: '#/responses/InternalServerError' delete: tags: - silence operationId: deleteSilence description: Delete a silence by its ID parameters: - in: path name: silenceID type: string format: uuid required: true description: ID of the silence to get responses: '200': description: Delete silence response '404': description: A silence with the specified ID was not found '500': $ref: '#/responses/InternalServerError' /alerts: get: tags: - alert operationId: getAlerts description: Get a list of alerts parameters: - in: query name: active type: boolean description: Include active alerts in results. If false, excludes active alerts and returns only suppressed (silenced or inhibited) alerts. default: true - in: query name: silenced type: boolean description: Include silenced alerts in results. If false, excludes silenced alerts. Note that true (default) shows both silenced and non-silenced alerts. default: true - in: query name: inhibited type: boolean description: Include inhibited alerts in results. If false, excludes inhibited alerts. Note that true (default) shows both inhibited and non-inhibited alerts. default: true - in: query name: unprocessed type: boolean description: Include unprocessed alerts in results. If false, excludes unprocessed alerts. Note that true (default) shows both processed and unprocessed alerts. default: true - name: filter in: query description: A matcher expression to filter alerts. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. required: false type: array collectionFormat: multi items: type: string - name: receiver in: query description: A regex matching receivers to filter alerts by required: false type: string responses: '200': description: Get alerts response schema: '$ref': '#/definitions/gettableAlerts' '400': $ref: '#/responses/BadRequest' '500': $ref: '#/responses/InternalServerError' post: tags: - alert operationId: postAlerts description: Create new Alerts parameters: - in: body name: alerts description: The alerts to create required: true schema: $ref: '#/definitions/postableAlerts' responses: '200': description: Create alerts response '500': $ref: '#/responses/InternalServerError' '400': $ref: '#/responses/BadRequest' /alerts/groups: get: tags: - alertgroup operationId: getAlertGroups description: Get a list of alert groups parameters: - in: query name: active type: boolean description: Include active alerts within the returned groups. If false, excludes active alerts from groups and only shows suppressed (silenced or inhibited) alerts. default: true - in: query name: silenced type: boolean description: Include silenced alerts within the returned groups. If false, excludes silenced alerts from groups. Note that true (default) shows both silenced and non-silenced alerts. default: true - in: query name: inhibited type: boolean description: Include inhibited alerts within the returned groups. If false, excludes inhibited alerts from groups. Note that true (default) shows both inhibited and non-inhibited alerts. default: true - in: query name: muted type: boolean description: Include muted (silenced or inhibited) alert groups in results. If false, excludes entire groups where all alerts are muted. default: true - name: filter in: query description: A matcher expression to filter alert groups. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. required: false type: array collectionFormat: multi items: type: string - name: receiver in: query description: A regex matching receivers to filter alerts by required: false type: string responses: '200': description: Get alert groups response schema: '$ref': '#/definitions/alertGroups' '400': $ref: '#/responses/BadRequest' '500': $ref: '#/responses/InternalServerError' responses: BadRequest: description: Bad request schema: type: string InternalServerError: description: Internal server error schema: type: string definitions: alertmanagerStatus: type: object properties: cluster: $ref: '#/definitions/clusterStatus' versionInfo: $ref: '#/definitions/versionInfo' config: $ref: '#/definitions/alertmanagerConfig' uptime: type: string format: date-time required: - cluster - versionInfo - config - uptime clusterStatus: type: object properties: name: type: string status: type: string enum: ["ready", "settling", "disabled"] peers: type: array items: $ref: '#/definitions/peerStatus' required: - status alertmanagerConfig: type: object properties: original: type: string required: - original versionInfo: type: object properties: version: type: string revision: type: string branch: type: string buildUser: type: string buildDate: type: string goVersion: type: string required: - version - revision - branch - buildUser - buildDate - goVersion peerStatus: type: object properties: name: type: string address: type: string required: - name - address silence: type: object properties: matchers: $ref: '#/definitions/matchers' startsAt: type: string format: date-time endsAt: type: string format: date-time createdBy: type: string comment: type: string annotations: $ref: "#/definitions/labelSet" required: - matchers - startsAt - endsAt - createdBy - comment gettableSilence: allOf: - type: object properties: id: type: string status: $ref: '#/definitions/silenceStatus' updatedAt: type: string format: date-time required: - id - status - updatedAt - annotations - $ref: '#/definitions/silence' postableSilence: allOf: - type: object properties: id: type: string - $ref: '#/definitions/silence' silenceStatus: type: object properties: state: type: string enum: ["expired", "active", "pending"] required: - state gettableSilences: type: array items: $ref: '#/definitions/gettableSilence' matchers: type: array items: $ref: '#/definitions/matcher' minItems: 1 matcher: type: object properties: name: type: string value: type: string isRegex: type: boolean isEqual: type: boolean default: true required: - name - value - isRegex alert: type: object properties: labels: $ref: '#/definitions/labelSet' generatorURL: type: string format: uri required: - labels gettableAlerts: type: array items: $ref: '#/definitions/gettableAlert' gettableAlert: allOf: - type: object properties: annotations: $ref: '#/definitions/labelSet' receivers: type: array items: $ref: '#/definitions/receiver' fingerprint: type: string startsAt: type: string format: date-time updatedAt: type: string format: date-time endsAt: type: string format: date-time status: $ref: '#/definitions/alertStatus' required: - receivers - fingerprint - startsAt - updatedAt - endsAt - annotations - status - $ref: '#/definitions/alert' postableAlerts: type: array items: $ref: '#/definitions/postableAlert' postableAlert: allOf: - type: object properties: startsAt: type: string format: date-time endsAt: type: string format: date-time annotations: $ref: '#/definitions/labelSet' - $ref: '#/definitions/alert' alertGroups: type: array items: $ref: '#/definitions/alertGroup' alertGroup: type: object properties: labels: $ref: '#/definitions/labelSet' receiver: $ref: '#/definitions/receiver' alerts: type: array items: $ref: '#/definitions/gettableAlert' required: - labels - receiver - alerts alertStatus: type: object properties: state: type: string enum: ['unprocessed', 'active', 'suppressed'] silencedBy: type: array items: type: string inhibitedBy: type: array items: type: string mutedBy: type: array items: type: string required: - state - silencedBy - inhibitedBy - mutedBy receiver: type: object properties: name: type: string required: - name labelSet: type: object additionalProperties: type: string tags: - name: general description: General Alertmanager operations - name: receiver description: Everything related to Alertmanager receivers - name: silence description: Everything related to Alertmanager silences - name: alert description: Everything related to Alertmanager alerts ================================================ FILE: api/v2/restapi/configure_alertmanager.go ================================================ // This file is safe to edit. Once it exists it will not be overwritten // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package restapi import ( "crypto/tls" "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/prometheus/alertmanager/api/v2/restapi/operations" "github.com/prometheus/alertmanager/api/v2/restapi/operations/alert" "github.com/prometheus/alertmanager/api/v2/restapi/operations/alertgroup" "github.com/prometheus/alertmanager/api/v2/restapi/operations/general" "github.com/prometheus/alertmanager/api/v2/restapi/operations/receiver" "github.com/prometheus/alertmanager/api/v2/restapi/operations/silence" ) //go:generate swagger generate server --target ../../v2 --name Alertmanager --spec ../openapi.yaml --principal any --exclude-main func configureFlags(api *operations.AlertmanagerAPI) { // api.CommandLineOptionsGroups = []swag.CommandLineOptionsGroup{ ... } _ = api } func configureAPI(api *operations.AlertmanagerAPI) http.Handler { // configure the api here api.ServeError = errors.ServeError // Set your custom logger if needed. Default one is log.Printf // Expected interface func(string, ...any) // // Example: // api.Logger = log.Printf api.UseSwaggerUI() // To continue using redoc as your UI, uncomment the following line // api.UseRedoc() api.JSONConsumer = runtime.JSONConsumer() api.JSONProducer = runtime.JSONProducer() if api.SilenceDeleteSilenceHandler == nil { api.SilenceDeleteSilenceHandler = silence.DeleteSilenceHandlerFunc(func(params silence.DeleteSilenceParams) middleware.Responder { _ = params return middleware.NotImplemented("operation silence.DeleteSilence has not yet been implemented") }) } if api.AlertgroupGetAlertGroupsHandler == nil { api.AlertgroupGetAlertGroupsHandler = alertgroup.GetAlertGroupsHandlerFunc(func(params alertgroup.GetAlertGroupsParams) middleware.Responder { _ = params return middleware.NotImplemented("operation alertgroup.GetAlertGroups has not yet been implemented") }) } if api.AlertGetAlertsHandler == nil { api.AlertGetAlertsHandler = alert.GetAlertsHandlerFunc(func(params alert.GetAlertsParams) middleware.Responder { _ = params return middleware.NotImplemented("operation alert.GetAlerts has not yet been implemented") }) } if api.ReceiverGetReceiversHandler == nil { api.ReceiverGetReceiversHandler = receiver.GetReceiversHandlerFunc(func(params receiver.GetReceiversParams) middleware.Responder { _ = params return middleware.NotImplemented("operation receiver.GetReceivers has not yet been implemented") }) } if api.SilenceGetSilenceHandler == nil { api.SilenceGetSilenceHandler = silence.GetSilenceHandlerFunc(func(params silence.GetSilenceParams) middleware.Responder { _ = params return middleware.NotImplemented("operation silence.GetSilence has not yet been implemented") }) } if api.SilenceGetSilencesHandler == nil { api.SilenceGetSilencesHandler = silence.GetSilencesHandlerFunc(func(params silence.GetSilencesParams) middleware.Responder { _ = params return middleware.NotImplemented("operation silence.GetSilences has not yet been implemented") }) } if api.GeneralGetStatusHandler == nil { api.GeneralGetStatusHandler = general.GetStatusHandlerFunc(func(params general.GetStatusParams) middleware.Responder { _ = params return middleware.NotImplemented("operation general.GetStatus has not yet been implemented") }) } if api.AlertPostAlertsHandler == nil { api.AlertPostAlertsHandler = alert.PostAlertsHandlerFunc(func(params alert.PostAlertsParams) middleware.Responder { _ = params return middleware.NotImplemented("operation alert.PostAlerts has not yet been implemented") }) } if api.SilencePostSilencesHandler == nil { api.SilencePostSilencesHandler = silence.PostSilencesHandlerFunc(func(params silence.PostSilencesParams) middleware.Responder { _ = params return middleware.NotImplemented("operation silence.PostSilences has not yet been implemented") }) } api.PreServerShutdown = func() {} api.ServerShutdown = func() {} return setupGlobalMiddleware(api.Serve(setupMiddlewares)) } // The TLS configuration before HTTPS server starts. func configureTLS(tlsConfig *tls.Config) { // Make all necessary changes to the TLS configuration here. _ = tlsConfig } // As soon as server is initialized but not run yet, this function will be called. // If you need to modify a config, store server instance to stop it individually later, this is the place. // This function can be called multiple times, depending on the number of serving schemes. // scheme value will be set accordingly: "http", "https" or "unix". func configureServer(server *http.Server, scheme, addr string) { _ = server _ = scheme _ = addr } // The middleware configuration is for the handler executors. These do not apply to the swagger.json document. // The middleware executes after routing but before authentication, binding and validation. func setupMiddlewares(handler http.Handler) http.Handler { return handler } // The middleware configuration happens before anything, this middleware also applies to serving the swagger.json document. // So this is a good place to plug in a panic handling middleware, logging and metrics. func setupGlobalMiddleware(handler http.Handler) http.Handler { return handler } ================================================ FILE: api/v2/restapi/doc.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // Package restapi Alertmanager API // // API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) // Schemes: // http // Host: localhost // BasePath: /api/v2/ // Version: 0.0.1 // License: Apache 2.0 http://www.apache.org/licenses/LICENSE-2.0.html // // Consumes: // - application/json // // Produces: // - application/json // // swagger:meta package restapi ================================================ FILE: api/v2/restapi/embedded_spec.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package restapi // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "encoding/json" ) var ( // SwaggerJSON embedded version of the swagger document used at generation time SwaggerJSON json.RawMessage // FlatSwaggerJSON embedded flattened version of the swagger document used at generation time FlatSwaggerJSON json.RawMessage ) func init() { SwaggerJSON = json.RawMessage([]byte(`{ "consumes": [ "application/json" ], "produces": [ "application/json" ], "swagger": "2.0", "info": { "description": "API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager)", "title": "Alertmanager API", "license": { "name": "Apache 2.0", "url": "http://www.apache.org/licenses/LICENSE-2.0.html" }, "version": "0.0.1" }, "basePath": "/api/v2/", "paths": { "/alerts": { "get": { "description": "Get a list of alerts", "tags": [ "alert" ], "operationId": "getAlerts", "parameters": [ { "type": "boolean", "default": true, "description": "Include active alerts in results. If false, excludes active alerts and returns only suppressed (silenced or inhibited) alerts.", "name": "active", "in": "query" }, { "type": "boolean", "default": true, "description": "Include silenced alerts in results. If false, excludes silenced alerts. Note that true (default) shows both silenced and non-silenced alerts.", "name": "silenced", "in": "query" }, { "type": "boolean", "default": true, "description": "Include inhibited alerts in results. If false, excludes inhibited alerts. Note that true (default) shows both inhibited and non-inhibited alerts.", "name": "inhibited", "in": "query" }, { "type": "boolean", "default": true, "description": "Include unprocessed alerts in results. If false, excludes unprocessed alerts. Note that true (default) shows both processed and unprocessed alerts.", "name": "unprocessed", "in": "query" }, { "type": "array", "items": { "type": "string" }, "collectionFormat": "multi", "description": "A matcher expression to filter alerts. For example ` + "`" + `alertname=\"MyAlert\"` + "`" + `. It can be repeated to apply multiple matchers.", "name": "filter", "in": "query" }, { "type": "string", "description": "A regex matching receivers to filter alerts by", "name": "receiver", "in": "query" } ], "responses": { "200": { "description": "Get alerts response", "schema": { "$ref": "#/definitions/gettableAlerts" } }, "400": { "$ref": "#/responses/BadRequest" }, "500": { "$ref": "#/responses/InternalServerError" } } }, "post": { "description": "Create new Alerts", "tags": [ "alert" ], "operationId": "postAlerts", "parameters": [ { "description": "The alerts to create", "name": "alerts", "in": "body", "required": true, "schema": { "$ref": "#/definitions/postableAlerts" } } ], "responses": { "200": { "description": "Create alerts response" }, "400": { "$ref": "#/responses/BadRequest" }, "500": { "$ref": "#/responses/InternalServerError" } } } }, "/alerts/groups": { "get": { "description": "Get a list of alert groups", "tags": [ "alertgroup" ], "operationId": "getAlertGroups", "parameters": [ { "type": "boolean", "default": true, "description": "Include active alerts within the returned groups. If false, excludes active alerts from groups and only shows suppressed (silenced or inhibited) alerts.", "name": "active", "in": "query" }, { "type": "boolean", "default": true, "description": "Include silenced alerts within the returned groups. If false, excludes silenced alerts from groups. Note that true (default) shows both silenced and non-silenced alerts.", "name": "silenced", "in": "query" }, { "type": "boolean", "default": true, "description": "Include inhibited alerts within the returned groups. If false, excludes inhibited alerts from groups. Note that true (default) shows both inhibited and non-inhibited alerts.", "name": "inhibited", "in": "query" }, { "type": "boolean", "default": true, "description": "Include muted (silenced or inhibited) alert groups in results. If false, excludes entire groups where all alerts are muted.", "name": "muted", "in": "query" }, { "type": "array", "items": { "type": "string" }, "collectionFormat": "multi", "description": "A matcher expression to filter alert groups. For example ` + "`" + `alertname=\"MyAlert\"` + "`" + `. It can be repeated to apply multiple matchers.", "name": "filter", "in": "query" }, { "type": "string", "description": "A regex matching receivers to filter alerts by", "name": "receiver", "in": "query" } ], "responses": { "200": { "description": "Get alert groups response", "schema": { "$ref": "#/definitions/alertGroups" } }, "400": { "$ref": "#/responses/BadRequest" }, "500": { "$ref": "#/responses/InternalServerError" } } } }, "/receivers": { "get": { "description": "Get list of all receivers (name of notification integrations)", "tags": [ "receiver" ], "operationId": "getReceivers", "responses": { "200": { "description": "Get receivers response", "schema": { "type": "array", "items": { "$ref": "#/definitions/receiver" } } } } } }, "/silence/{silenceID}": { "get": { "description": "Get a silence by its ID", "tags": [ "silence" ], "operationId": "getSilence", "responses": { "200": { "description": "Get silence response", "schema": { "$ref": "#/definitions/gettableSilence" } }, "404": { "description": "A silence with the specified ID was not found" }, "500": { "$ref": "#/responses/InternalServerError" } } }, "delete": { "description": "Delete a silence by its ID", "tags": [ "silence" ], "operationId": "deleteSilence", "parameters": [ { "type": "string", "format": "uuid", "description": "ID of the silence to get", "name": "silenceID", "in": "path", "required": true } ], "responses": { "200": { "description": "Delete silence response" }, "404": { "description": "A silence with the specified ID was not found" }, "500": { "$ref": "#/responses/InternalServerError" } } }, "parameters": [ { "type": "string", "format": "uuid", "description": "ID of the silence to get", "name": "silenceID", "in": "path", "required": true } ] }, "/silences": { "get": { "description": "Get a list of silences", "tags": [ "silence" ], "operationId": "getSilences", "parameters": [ { "type": "array", "items": { "type": "string" }, "collectionFormat": "multi", "description": "A matcher expression to filter silences. For example ` + "`" + `alertname=\"MyAlert\"` + "`" + `. It can be repeated to apply multiple matchers.", "name": "filter", "in": "query" } ], "responses": { "200": { "description": "Get silences response", "schema": { "$ref": "#/definitions/gettableSilences" } }, "400": { "$ref": "#/responses/BadRequest" }, "500": { "$ref": "#/responses/InternalServerError" } } }, "post": { "description": "Post a new silence or update an existing one", "tags": [ "silence" ], "operationId": "postSilences", "parameters": [ { "description": "The silence to create", "name": "silence", "in": "body", "required": true, "schema": { "$ref": "#/definitions/postableSilence" } } ], "responses": { "200": { "description": "Create / update silence response", "schema": { "type": "object", "properties": { "silenceID": { "type": "string" } } } }, "400": { "$ref": "#/responses/BadRequest" }, "404": { "description": "A silence with the specified ID was not found", "schema": { "type": "string" } } } } }, "/status": { "get": { "description": "Get current status of an Alertmanager instance and its cluster", "tags": [ "general" ], "operationId": "getStatus", "responses": { "200": { "description": "Get status response", "schema": { "$ref": "#/definitions/alertmanagerStatus" } } } } } }, "definitions": { "alert": { "type": "object", "required": [ "labels" ], "properties": { "generatorURL": { "type": "string", "format": "uri" }, "labels": { "$ref": "#/definitions/labelSet" } } }, "alertGroup": { "type": "object", "required": [ "labels", "receiver", "alerts" ], "properties": { "alerts": { "type": "array", "items": { "$ref": "#/definitions/gettableAlert" } }, "labels": { "$ref": "#/definitions/labelSet" }, "receiver": { "$ref": "#/definitions/receiver" } } }, "alertGroups": { "type": "array", "items": { "$ref": "#/definitions/alertGroup" } }, "alertStatus": { "type": "object", "required": [ "state", "silencedBy", "inhibitedBy", "mutedBy" ], "properties": { "inhibitedBy": { "type": "array", "items": { "type": "string" } }, "mutedBy": { "type": "array", "items": { "type": "string" } }, "silencedBy": { "type": "array", "items": { "type": "string" } }, "state": { "type": "string", "enum": [ "unprocessed", "active", "suppressed" ] } } }, "alertmanagerConfig": { "type": "object", "required": [ "original" ], "properties": { "original": { "type": "string" } } }, "alertmanagerStatus": { "type": "object", "required": [ "cluster", "versionInfo", "config", "uptime" ], "properties": { "cluster": { "$ref": "#/definitions/clusterStatus" }, "config": { "$ref": "#/definitions/alertmanagerConfig" }, "uptime": { "type": "string", "format": "date-time" }, "versionInfo": { "$ref": "#/definitions/versionInfo" } } }, "clusterStatus": { "type": "object", "required": [ "status" ], "properties": { "name": { "type": "string" }, "peers": { "type": "array", "items": { "$ref": "#/definitions/peerStatus" } }, "status": { "type": "string", "enum": [ "ready", "settling", "disabled" ] } } }, "gettableAlert": { "allOf": [ { "type": "object", "required": [ "receivers", "fingerprint", "startsAt", "updatedAt", "endsAt", "annotations", "status" ], "properties": { "annotations": { "$ref": "#/definitions/labelSet" }, "endsAt": { "type": "string", "format": "date-time" }, "fingerprint": { "type": "string" }, "receivers": { "type": "array", "items": { "$ref": "#/definitions/receiver" } }, "startsAt": { "type": "string", "format": "date-time" }, "status": { "$ref": "#/definitions/alertStatus" }, "updatedAt": { "type": "string", "format": "date-time" } } }, { "$ref": "#/definitions/alert" } ] }, "gettableAlerts": { "type": "array", "items": { "$ref": "#/definitions/gettableAlert" } }, "gettableSilence": { "allOf": [ { "type": "object", "required": [ "id", "status", "updatedAt", "annotations" ], "properties": { "id": { "type": "string" }, "status": { "$ref": "#/definitions/silenceStatus" }, "updatedAt": { "type": "string", "format": "date-time" } } }, { "$ref": "#/definitions/silence" } ] }, "gettableSilences": { "type": "array", "items": { "$ref": "#/definitions/gettableSilence" } }, "labelSet": { "type": "object", "additionalProperties": { "type": "string" } }, "matcher": { "type": "object", "required": [ "name", "value", "isRegex" ], "properties": { "isEqual": { "type": "boolean", "default": true }, "isRegex": { "type": "boolean" }, "name": { "type": "string" }, "value": { "type": "string" } } }, "matchers": { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/matcher" } }, "peerStatus": { "type": "object", "required": [ "name", "address" ], "properties": { "address": { "type": "string" }, "name": { "type": "string" } } }, "postableAlert": { "allOf": [ { "type": "object", "properties": { "annotations": { "$ref": "#/definitions/labelSet" }, "endsAt": { "type": "string", "format": "date-time" }, "startsAt": { "type": "string", "format": "date-time" } } }, { "$ref": "#/definitions/alert" } ] }, "postableAlerts": { "type": "array", "items": { "$ref": "#/definitions/postableAlert" } }, "postableSilence": { "allOf": [ { "type": "object", "properties": { "id": { "type": "string" } } }, { "$ref": "#/definitions/silence" } ] }, "receiver": { "type": "object", "required": [ "name" ], "properties": { "name": { "type": "string" } } }, "silence": { "type": "object", "required": [ "matchers", "startsAt", "endsAt", "createdBy", "comment" ], "properties": { "annotations": { "$ref": "#/definitions/labelSet" }, "comment": { "type": "string" }, "createdBy": { "type": "string" }, "endsAt": { "type": "string", "format": "date-time" }, "matchers": { "$ref": "#/definitions/matchers" }, "startsAt": { "type": "string", "format": "date-time" } } }, "silenceStatus": { "type": "object", "required": [ "state" ], "properties": { "state": { "type": "string", "enum": [ "expired", "active", "pending" ] } } }, "versionInfo": { "type": "object", "required": [ "version", "revision", "branch", "buildUser", "buildDate", "goVersion" ], "properties": { "branch": { "type": "string" }, "buildDate": { "type": "string" }, "buildUser": { "type": "string" }, "goVersion": { "type": "string" }, "revision": { "type": "string" }, "version": { "type": "string" } } } }, "responses": { "BadRequest": { "description": "Bad request", "schema": { "type": "string" } }, "InternalServerError": { "description": "Internal server error", "schema": { "type": "string" } } }, "tags": [ { "description": "General Alertmanager operations", "name": "general" }, { "description": "Everything related to Alertmanager receivers", "name": "receiver" }, { "description": "Everything related to Alertmanager silences", "name": "silence" }, { "description": "Everything related to Alertmanager alerts", "name": "alert" } ] }`)) FlatSwaggerJSON = json.RawMessage([]byte(`{ "consumes": [ "application/json" ], "produces": [ "application/json" ], "swagger": "2.0", "info": { "description": "API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager)", "title": "Alertmanager API", "license": { "name": "Apache 2.0", "url": "http://www.apache.org/licenses/LICENSE-2.0.html" }, "version": "0.0.1" }, "basePath": "/api/v2/", "paths": { "/alerts": { "get": { "description": "Get a list of alerts", "tags": [ "alert" ], "operationId": "getAlerts", "parameters": [ { "type": "boolean", "default": true, "description": "Include active alerts in results. If false, excludes active alerts and returns only suppressed (silenced or inhibited) alerts.", "name": "active", "in": "query" }, { "type": "boolean", "default": true, "description": "Include silenced alerts in results. If false, excludes silenced alerts. Note that true (default) shows both silenced and non-silenced alerts.", "name": "silenced", "in": "query" }, { "type": "boolean", "default": true, "description": "Include inhibited alerts in results. If false, excludes inhibited alerts. Note that true (default) shows both inhibited and non-inhibited alerts.", "name": "inhibited", "in": "query" }, { "type": "boolean", "default": true, "description": "Include unprocessed alerts in results. If false, excludes unprocessed alerts. Note that true (default) shows both processed and unprocessed alerts.", "name": "unprocessed", "in": "query" }, { "type": "array", "items": { "type": "string" }, "collectionFormat": "multi", "description": "A matcher expression to filter alerts. For example ` + "`" + `alertname=\"MyAlert\"` + "`" + `. It can be repeated to apply multiple matchers.", "name": "filter", "in": "query" }, { "type": "string", "description": "A regex matching receivers to filter alerts by", "name": "receiver", "in": "query" } ], "responses": { "200": { "description": "Get alerts response", "schema": { "$ref": "#/definitions/gettableAlerts" } }, "400": { "description": "Bad request", "schema": { "type": "string" } }, "500": { "description": "Internal server error", "schema": { "type": "string" } } } }, "post": { "description": "Create new Alerts", "tags": [ "alert" ], "operationId": "postAlerts", "parameters": [ { "description": "The alerts to create", "name": "alerts", "in": "body", "required": true, "schema": { "$ref": "#/definitions/postableAlerts" } } ], "responses": { "200": { "description": "Create alerts response" }, "400": { "description": "Bad request", "schema": { "type": "string" } }, "500": { "description": "Internal server error", "schema": { "type": "string" } } } } }, "/alerts/groups": { "get": { "description": "Get a list of alert groups", "tags": [ "alertgroup" ], "operationId": "getAlertGroups", "parameters": [ { "type": "boolean", "default": true, "description": "Include active alerts within the returned groups. If false, excludes active alerts from groups and only shows suppressed (silenced or inhibited) alerts.", "name": "active", "in": "query" }, { "type": "boolean", "default": true, "description": "Include silenced alerts within the returned groups. If false, excludes silenced alerts from groups. Note that true (default) shows both silenced and non-silenced alerts.", "name": "silenced", "in": "query" }, { "type": "boolean", "default": true, "description": "Include inhibited alerts within the returned groups. If false, excludes inhibited alerts from groups. Note that true (default) shows both inhibited and non-inhibited alerts.", "name": "inhibited", "in": "query" }, { "type": "boolean", "default": true, "description": "Include muted (silenced or inhibited) alert groups in results. If false, excludes entire groups where all alerts are muted.", "name": "muted", "in": "query" }, { "type": "array", "items": { "type": "string" }, "collectionFormat": "multi", "description": "A matcher expression to filter alert groups. For example ` + "`" + `alertname=\"MyAlert\"` + "`" + `. It can be repeated to apply multiple matchers.", "name": "filter", "in": "query" }, { "type": "string", "description": "A regex matching receivers to filter alerts by", "name": "receiver", "in": "query" } ], "responses": { "200": { "description": "Get alert groups response", "schema": { "$ref": "#/definitions/alertGroups" } }, "400": { "description": "Bad request", "schema": { "type": "string" } }, "500": { "description": "Internal server error", "schema": { "type": "string" } } } } }, "/receivers": { "get": { "description": "Get list of all receivers (name of notification integrations)", "tags": [ "receiver" ], "operationId": "getReceivers", "responses": { "200": { "description": "Get receivers response", "schema": { "type": "array", "items": { "$ref": "#/definitions/receiver" } } } } } }, "/silence/{silenceID}": { "get": { "description": "Get a silence by its ID", "tags": [ "silence" ], "operationId": "getSilence", "responses": { "200": { "description": "Get silence response", "schema": { "$ref": "#/definitions/gettableSilence" } }, "404": { "description": "A silence with the specified ID was not found" }, "500": { "description": "Internal server error", "schema": { "type": "string" } } } }, "delete": { "description": "Delete a silence by its ID", "tags": [ "silence" ], "operationId": "deleteSilence", "parameters": [ { "type": "string", "format": "uuid", "description": "ID of the silence to get", "name": "silenceID", "in": "path", "required": true } ], "responses": { "200": { "description": "Delete silence response" }, "404": { "description": "A silence with the specified ID was not found" }, "500": { "description": "Internal server error", "schema": { "type": "string" } } } }, "parameters": [ { "type": "string", "format": "uuid", "description": "ID of the silence to get", "name": "silenceID", "in": "path", "required": true } ] }, "/silences": { "get": { "description": "Get a list of silences", "tags": [ "silence" ], "operationId": "getSilences", "parameters": [ { "type": "array", "items": { "type": "string" }, "collectionFormat": "multi", "description": "A matcher expression to filter silences. For example ` + "`" + `alertname=\"MyAlert\"` + "`" + `. It can be repeated to apply multiple matchers.", "name": "filter", "in": "query" } ], "responses": { "200": { "description": "Get silences response", "schema": { "$ref": "#/definitions/gettableSilences" } }, "400": { "description": "Bad request", "schema": { "type": "string" } }, "500": { "description": "Internal server error", "schema": { "type": "string" } } } }, "post": { "description": "Post a new silence or update an existing one", "tags": [ "silence" ], "operationId": "postSilences", "parameters": [ { "description": "The silence to create", "name": "silence", "in": "body", "required": true, "schema": { "$ref": "#/definitions/postableSilence" } } ], "responses": { "200": { "description": "Create / update silence response", "schema": { "type": "object", "properties": { "silenceID": { "type": "string" } } } }, "400": { "description": "Bad request", "schema": { "type": "string" } }, "404": { "description": "A silence with the specified ID was not found", "schema": { "type": "string" } } } } }, "/status": { "get": { "description": "Get current status of an Alertmanager instance and its cluster", "tags": [ "general" ], "operationId": "getStatus", "responses": { "200": { "description": "Get status response", "schema": { "$ref": "#/definitions/alertmanagerStatus" } } } } } }, "definitions": { "alert": { "type": "object", "required": [ "labels" ], "properties": { "generatorURL": { "type": "string", "format": "uri" }, "labels": { "$ref": "#/definitions/labelSet" } } }, "alertGroup": { "type": "object", "required": [ "labels", "receiver", "alerts" ], "properties": { "alerts": { "type": "array", "items": { "$ref": "#/definitions/gettableAlert" } }, "labels": { "$ref": "#/definitions/labelSet" }, "receiver": { "$ref": "#/definitions/receiver" } } }, "alertGroups": { "type": "array", "items": { "$ref": "#/definitions/alertGroup" } }, "alertStatus": { "type": "object", "required": [ "state", "silencedBy", "inhibitedBy", "mutedBy" ], "properties": { "inhibitedBy": { "type": "array", "items": { "type": "string" } }, "mutedBy": { "type": "array", "items": { "type": "string" } }, "silencedBy": { "type": "array", "items": { "type": "string" } }, "state": { "type": "string", "enum": [ "unprocessed", "active", "suppressed" ] } } }, "alertmanagerConfig": { "type": "object", "required": [ "original" ], "properties": { "original": { "type": "string" } } }, "alertmanagerStatus": { "type": "object", "required": [ "cluster", "versionInfo", "config", "uptime" ], "properties": { "cluster": { "$ref": "#/definitions/clusterStatus" }, "config": { "$ref": "#/definitions/alertmanagerConfig" }, "uptime": { "type": "string", "format": "date-time" }, "versionInfo": { "$ref": "#/definitions/versionInfo" } } }, "clusterStatus": { "type": "object", "required": [ "status" ], "properties": { "name": { "type": "string" }, "peers": { "type": "array", "items": { "$ref": "#/definitions/peerStatus" } }, "status": { "type": "string", "enum": [ "ready", "settling", "disabled" ] } } }, "gettableAlert": { "allOf": [ { "type": "object", "required": [ "receivers", "fingerprint", "startsAt", "updatedAt", "endsAt", "annotations", "status" ], "properties": { "annotations": { "$ref": "#/definitions/labelSet" }, "endsAt": { "type": "string", "format": "date-time" }, "fingerprint": { "type": "string" }, "receivers": { "type": "array", "items": { "$ref": "#/definitions/receiver" } }, "startsAt": { "type": "string", "format": "date-time" }, "status": { "$ref": "#/definitions/alertStatus" }, "updatedAt": { "type": "string", "format": "date-time" } } }, { "$ref": "#/definitions/alert" } ] }, "gettableAlerts": { "type": "array", "items": { "$ref": "#/definitions/gettableAlert" } }, "gettableSilence": { "allOf": [ { "type": "object", "required": [ "id", "status", "updatedAt", "annotations" ], "properties": { "id": { "type": "string" }, "status": { "$ref": "#/definitions/silenceStatus" }, "updatedAt": { "type": "string", "format": "date-time" } } }, { "$ref": "#/definitions/silence" } ] }, "gettableSilences": { "type": "array", "items": { "$ref": "#/definitions/gettableSilence" } }, "labelSet": { "type": "object", "additionalProperties": { "type": "string" } }, "matcher": { "type": "object", "required": [ "name", "value", "isRegex" ], "properties": { "isEqual": { "type": "boolean", "default": true }, "isRegex": { "type": "boolean" }, "name": { "type": "string" }, "value": { "type": "string" } } }, "matchers": { "type": "array", "minItems": 1, "items": { "$ref": "#/definitions/matcher" } }, "peerStatus": { "type": "object", "required": [ "name", "address" ], "properties": { "address": { "type": "string" }, "name": { "type": "string" } } }, "postableAlert": { "allOf": [ { "type": "object", "properties": { "annotations": { "$ref": "#/definitions/labelSet" }, "endsAt": { "type": "string", "format": "date-time" }, "startsAt": { "type": "string", "format": "date-time" } } }, { "$ref": "#/definitions/alert" } ] }, "postableAlerts": { "type": "array", "items": { "$ref": "#/definitions/postableAlert" } }, "postableSilence": { "allOf": [ { "type": "object", "properties": { "id": { "type": "string" } } }, { "$ref": "#/definitions/silence" } ] }, "receiver": { "type": "object", "required": [ "name" ], "properties": { "name": { "type": "string" } } }, "silence": { "type": "object", "required": [ "matchers", "startsAt", "endsAt", "createdBy", "comment" ], "properties": { "annotations": { "$ref": "#/definitions/labelSet" }, "comment": { "type": "string" }, "createdBy": { "type": "string" }, "endsAt": { "type": "string", "format": "date-time" }, "matchers": { "$ref": "#/definitions/matchers" }, "startsAt": { "type": "string", "format": "date-time" } } }, "silenceStatus": { "type": "object", "required": [ "state" ], "properties": { "state": { "type": "string", "enum": [ "expired", "active", "pending" ] } } }, "versionInfo": { "type": "object", "required": [ "version", "revision", "branch", "buildUser", "buildDate", "goVersion" ], "properties": { "branch": { "type": "string" }, "buildDate": { "type": "string" }, "buildUser": { "type": "string" }, "goVersion": { "type": "string" }, "revision": { "type": "string" }, "version": { "type": "string" } } } }, "responses": { "BadRequest": { "description": "Bad request", "schema": { "type": "string" } }, "InternalServerError": { "description": "Internal server error", "schema": { "type": "string" } } }, "tags": [ { "description": "General Alertmanager operations", "name": "general" }, { "description": "Everything related to Alertmanager receivers", "name": "receiver" }, { "description": "Everything related to Alertmanager silences", "name": "silence" }, { "description": "Everything related to Alertmanager alerts", "name": "alert" } ] }`)) } ================================================ FILE: api/v2/restapi/operations/alert/get_alerts.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "net/http" "github.com/go-openapi/runtime/middleware" ) // GetAlertsHandlerFunc turns a function with the right signature into a get alerts handler type GetAlertsHandlerFunc func(GetAlertsParams) middleware.Responder // Handle executing the request and returning a response func (fn GetAlertsHandlerFunc) Handle(params GetAlertsParams) middleware.Responder { return fn(params) } // GetAlertsHandler interface for that can handle valid get alerts params type GetAlertsHandler interface { Handle(GetAlertsParams) middleware.Responder } // NewGetAlerts creates a new http.Handler for the get alerts operation func NewGetAlerts(ctx *middleware.Context, handler GetAlertsHandler) *GetAlerts { return &GetAlerts{Context: ctx, Handler: handler} } /* GetAlerts swagger:route GET /alerts alert getAlerts Get a list of alerts */ type GetAlerts struct { Context *middleware.Context Handler GetAlertsHandler } func (o *GetAlerts) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewGetAlertsParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } ================================================ FILE: api/v2/restapi/operations/alert/get_alerts_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // NewGetAlertsParams creates a new GetAlertsParams object // with the default values initialized. func NewGetAlertsParams() GetAlertsParams { var ( // initialize parameters with default values activeDefault = bool(true) inhibitedDefault = bool(true) silencedDefault = bool(true) unprocessedDefault = bool(true) ) return GetAlertsParams{ Active: &activeDefault, Inhibited: &inhibitedDefault, Silenced: &silencedDefault, Unprocessed: &unprocessedDefault, } } // GetAlertsParams contains all the bound params for the get alerts operation // typically these are obtained from a http.Request // // swagger:parameters getAlerts type GetAlertsParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` /*Include active alerts in results. If false, excludes active alerts and returns only suppressed (silenced or inhibited) alerts. In: query Default: true */ Active *bool /*A matcher expression to filter alerts. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. In: query Collection Format: multi */ Filter []string /*Include inhibited alerts in results. If false, excludes inhibited alerts. Note that true (default) shows both inhibited and non-inhibited alerts. In: query Default: true */ Inhibited *bool /*A regex matching receivers to filter alerts by In: query */ Receiver *string /*Include silenced alerts in results. If false, excludes silenced alerts. Note that true (default) shows both silenced and non-silenced alerts. In: query Default: true */ Silenced *bool /*Include unprocessed alerts in results. If false, excludes unprocessed alerts. Note that true (default) shows both processed and unprocessed alerts. In: query Default: true */ Unprocessed *bool } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewGetAlertsParams() beforehand. func (o *GetAlertsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r qs := runtime.Values(r.URL.Query()) qActive, qhkActive, _ := qs.GetOK("active") if err := o.bindActive(qActive, qhkActive, route.Formats); err != nil { res = append(res, err) } qFilter, qhkFilter, _ := qs.GetOK("filter") if err := o.bindFilter(qFilter, qhkFilter, route.Formats); err != nil { res = append(res, err) } qInhibited, qhkInhibited, _ := qs.GetOK("inhibited") if err := o.bindInhibited(qInhibited, qhkInhibited, route.Formats); err != nil { res = append(res, err) } qReceiver, qhkReceiver, _ := qs.GetOK("receiver") if err := o.bindReceiver(qReceiver, qhkReceiver, route.Formats); err != nil { res = append(res, err) } qSilenced, qhkSilenced, _ := qs.GetOK("silenced") if err := o.bindSilenced(qSilenced, qhkSilenced, route.Formats); err != nil { res = append(res, err) } qUnprocessed, qhkUnprocessed, _ := qs.GetOK("unprocessed") if err := o.bindUnprocessed(qUnprocessed, qhkUnprocessed, route.Formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // bindActive binds and validates parameter Active from query. func (o *GetAlertsParams) bindActive(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations // Default values have been previously initialized by NewGetAlertsParams() return nil } value, err := swag.ConvertBool(raw) if err != nil { return errors.InvalidType("active", "query", "bool", raw) } o.Active = &value return nil } // bindFilter binds and validates array parameter Filter from query. // // Arrays are parsed according to CollectionFormat: "multi" (defaults to "csv" when empty). func (o *GetAlertsParams) bindFilter(rawData []string, hasKey bool, formats strfmt.Registry) error { // CollectionFormat: multi filterIC := rawData if len(filterIC) == 0 { return nil } var filterIR []string for _, filterIV := range filterIC { filterI := filterIV filterIR = append(filterIR, filterI) } o.Filter = filterIR return nil } // bindInhibited binds and validates parameter Inhibited from query. func (o *GetAlertsParams) bindInhibited(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations // Default values have been previously initialized by NewGetAlertsParams() return nil } value, err := swag.ConvertBool(raw) if err != nil { return errors.InvalidType("inhibited", "query", "bool", raw) } o.Inhibited = &value return nil } // bindReceiver binds and validates parameter Receiver from query. func (o *GetAlertsParams) bindReceiver(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations return nil } o.Receiver = &raw return nil } // bindSilenced binds and validates parameter Silenced from query. func (o *GetAlertsParams) bindSilenced(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations // Default values have been previously initialized by NewGetAlertsParams() return nil } value, err := swag.ConvertBool(raw) if err != nil { return errors.InvalidType("silenced", "query", "bool", raw) } o.Silenced = &value return nil } // bindUnprocessed binds and validates parameter Unprocessed from query. func (o *GetAlertsParams) bindUnprocessed(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations // Default values have been previously initialized by NewGetAlertsParams() return nil } value, err := swag.ConvertBool(raw) if err != nil { return errors.InvalidType("unprocessed", "query", "bool", raw) } o.Unprocessed = &value return nil } ================================================ FILE: api/v2/restapi/operations/alert/get_alerts_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" "github.com/prometheus/alertmanager/api/v2/models" ) // GetAlertsOKCode is the HTTP code returned for type GetAlertsOK const GetAlertsOKCode int = 200 /* GetAlertsOK Get alerts response swagger:response getAlertsOK */ type GetAlertsOK struct { /* In: Body */ Payload models.GettableAlerts `json:"body,omitempty"` } // NewGetAlertsOK creates GetAlertsOK with default headers values func NewGetAlertsOK() *GetAlertsOK { return &GetAlertsOK{} } // WithPayload adds the payload to the get alerts o k response func (o *GetAlertsOK) WithPayload(payload models.GettableAlerts) *GetAlertsOK { o.Payload = payload return o } // SetPayload sets the payload to the get alerts o k response func (o *GetAlertsOK) SetPayload(payload models.GettableAlerts) { o.Payload = payload } // WriteResponse to the client func (o *GetAlertsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(200) payload := o.Payload if payload == nil { // return empty array payload = models.GettableAlerts{} } if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } // GetAlertsBadRequestCode is the HTTP code returned for type GetAlertsBadRequest const GetAlertsBadRequestCode int = 400 /* GetAlertsBadRequest Bad request swagger:response getAlertsBadRequest */ type GetAlertsBadRequest struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewGetAlertsBadRequest creates GetAlertsBadRequest with default headers values func NewGetAlertsBadRequest() *GetAlertsBadRequest { return &GetAlertsBadRequest{} } // WithPayload adds the payload to the get alerts bad request response func (o *GetAlertsBadRequest) WithPayload(payload string) *GetAlertsBadRequest { o.Payload = payload return o } // SetPayload sets the payload to the get alerts bad request response func (o *GetAlertsBadRequest) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *GetAlertsBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(400) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } // GetAlertsInternalServerErrorCode is the HTTP code returned for type GetAlertsInternalServerError const GetAlertsInternalServerErrorCode int = 500 /* GetAlertsInternalServerError Internal server error swagger:response getAlertsInternalServerError */ type GetAlertsInternalServerError struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewGetAlertsInternalServerError creates GetAlertsInternalServerError with default headers values func NewGetAlertsInternalServerError() *GetAlertsInternalServerError { return &GetAlertsInternalServerError{} } // WithPayload adds the payload to the get alerts internal server error response func (o *GetAlertsInternalServerError) WithPayload(payload string) *GetAlertsInternalServerError { o.Payload = payload return o } // SetPayload sets the payload to the get alerts internal server error response func (o *GetAlertsInternalServerError) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *GetAlertsInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(500) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } ================================================ FILE: api/v2/restapi/operations/alert/get_alerts_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" "github.com/go-openapi/swag" ) // GetAlertsURL generates an URL for the get alerts operation type GetAlertsURL struct { Active *bool Filter []string Inhibited *bool Receiver *string Silenced *bool Unprocessed *bool _basePath string // avoid unkeyed usage _ struct{} } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetAlertsURL) WithBasePath(bp string) *GetAlertsURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetAlertsURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *GetAlertsURL) Build() (*url.URL, error) { var _result url.URL var _path = "/alerts" _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) qs := make(url.Values) var activeQ string if o.Active != nil { activeQ = swag.FormatBool(*o.Active) } if activeQ != "" { qs.Set("active", activeQ) } var filterIR []string for _, filterI := range o.Filter { filterIS := filterI if filterIS != "" { filterIR = append(filterIR, filterIS) } } filter := swag.JoinByFormat(filterIR, "multi") for _, qsv := range filter { qs.Add("filter", qsv) } var inhibitedQ string if o.Inhibited != nil { inhibitedQ = swag.FormatBool(*o.Inhibited) } if inhibitedQ != "" { qs.Set("inhibited", inhibitedQ) } var receiverQ string if o.Receiver != nil { receiverQ = *o.Receiver } if receiverQ != "" { qs.Set("receiver", receiverQ) } var silencedQ string if o.Silenced != nil { silencedQ = swag.FormatBool(*o.Silenced) } if silencedQ != "" { qs.Set("silenced", silencedQ) } var unprocessedQ string if o.Unprocessed != nil { unprocessedQ = swag.FormatBool(*o.Unprocessed) } if unprocessedQ != "" { qs.Set("unprocessed", unprocessedQ) } _result.RawQuery = qs.Encode() return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *GetAlertsURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *GetAlertsURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *GetAlertsURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on GetAlertsURL") } if host == "" { return nil, errors.New("host is required for a full url on GetAlertsURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *GetAlertsURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/operations/alert/post_alerts.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "net/http" "github.com/go-openapi/runtime/middleware" ) // PostAlertsHandlerFunc turns a function with the right signature into a post alerts handler type PostAlertsHandlerFunc func(PostAlertsParams) middleware.Responder // Handle executing the request and returning a response func (fn PostAlertsHandlerFunc) Handle(params PostAlertsParams) middleware.Responder { return fn(params) } // PostAlertsHandler interface for that can handle valid post alerts params type PostAlertsHandler interface { Handle(PostAlertsParams) middleware.Responder } // NewPostAlerts creates a new http.Handler for the post alerts operation func NewPostAlerts(ctx *middleware.Context, handler PostAlertsHandler) *PostAlerts { return &PostAlerts{Context: ctx, Handler: handler} } /* PostAlerts swagger:route POST /alerts alert postAlerts Create new Alerts */ type PostAlerts struct { Context *middleware.Context Handler PostAlertsHandler } func (o *PostAlerts) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewPostAlertsParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } ================================================ FILE: api/v2/restapi/operations/alert/post_alerts_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( stderrors "errors" "io" "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/validate" "github.com/prometheus/alertmanager/api/v2/models" ) // NewPostAlertsParams creates a new PostAlertsParams object // // There are no default values defined in the spec. func NewPostAlertsParams() PostAlertsParams { return PostAlertsParams{} } // PostAlertsParams contains all the bound params for the post alerts operation // typically these are obtained from a http.Request // // swagger:parameters postAlerts type PostAlertsParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` /*The alerts to create Required: true In: body */ Alerts models.PostableAlerts } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewPostAlertsParams() beforehand. func (o *PostAlertsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r if runtime.HasBody(r) { defer func() { _ = r.Body.Close() }() var body models.PostableAlerts if err := route.Consumer.Consume(r.Body, &body); err != nil { if stderrors.Is(err, io.EOF) { res = append(res, errors.Required("alerts", "body", "")) } else { res = append(res, errors.NewParseError("alerts", "body", "", err)) } } else { // validate body object if err := body.Validate(route.Formats); err != nil { res = append(res, err) } ctx := validate.WithOperationRequest(r.Context()) if err := body.ContextValidate(ctx, route.Formats); err != nil { res = append(res, err) } if len(res) == 0 { o.Alerts = body } } } else { res = append(res, errors.Required("alerts", "body", "")) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/restapi/operations/alert/post_alerts_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" ) // PostAlertsOKCode is the HTTP code returned for type PostAlertsOK const PostAlertsOKCode int = 200 /* PostAlertsOK Create alerts response swagger:response postAlertsOK */ type PostAlertsOK struct { } // NewPostAlertsOK creates PostAlertsOK with default headers values func NewPostAlertsOK() *PostAlertsOK { return &PostAlertsOK{} } // WriteResponse to the client func (o *PostAlertsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.Header().Del(runtime.HeaderContentType) // Remove Content-Type on empty responses rw.WriteHeader(200) } // PostAlertsBadRequestCode is the HTTP code returned for type PostAlertsBadRequest const PostAlertsBadRequestCode int = 400 /* PostAlertsBadRequest Bad request swagger:response postAlertsBadRequest */ type PostAlertsBadRequest struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewPostAlertsBadRequest creates PostAlertsBadRequest with default headers values func NewPostAlertsBadRequest() *PostAlertsBadRequest { return &PostAlertsBadRequest{} } // WithPayload adds the payload to the post alerts bad request response func (o *PostAlertsBadRequest) WithPayload(payload string) *PostAlertsBadRequest { o.Payload = payload return o } // SetPayload sets the payload to the post alerts bad request response func (o *PostAlertsBadRequest) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *PostAlertsBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(400) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } // PostAlertsInternalServerErrorCode is the HTTP code returned for type PostAlertsInternalServerError const PostAlertsInternalServerErrorCode int = 500 /* PostAlertsInternalServerError Internal server error swagger:response postAlertsInternalServerError */ type PostAlertsInternalServerError struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewPostAlertsInternalServerError creates PostAlertsInternalServerError with default headers values func NewPostAlertsInternalServerError() *PostAlertsInternalServerError { return &PostAlertsInternalServerError{} } // WithPayload adds the payload to the post alerts internal server error response func (o *PostAlertsInternalServerError) WithPayload(payload string) *PostAlertsInternalServerError { o.Payload = payload return o } // SetPayload sets the payload to the post alerts internal server error response func (o *PostAlertsInternalServerError) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *PostAlertsInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(500) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } ================================================ FILE: api/v2/restapi/operations/alert/post_alerts_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alert // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" ) // PostAlertsURL generates an URL for the post alerts operation type PostAlertsURL struct { _basePath string } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *PostAlertsURL) WithBasePath(bp string) *PostAlertsURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *PostAlertsURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *PostAlertsURL) Build() (*url.URL, error) { var _result url.URL var _path = "/alerts" _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *PostAlertsURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *PostAlertsURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *PostAlertsURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on PostAlertsURL") } if host == "" { return nil, errors.New("host is required for a full url on PostAlertsURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *PostAlertsURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/operations/alertgroup/get_alert_groups.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alertgroup // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "net/http" "github.com/go-openapi/runtime/middleware" ) // GetAlertGroupsHandlerFunc turns a function with the right signature into a get alert groups handler type GetAlertGroupsHandlerFunc func(GetAlertGroupsParams) middleware.Responder // Handle executing the request and returning a response func (fn GetAlertGroupsHandlerFunc) Handle(params GetAlertGroupsParams) middleware.Responder { return fn(params) } // GetAlertGroupsHandler interface for that can handle valid get alert groups params type GetAlertGroupsHandler interface { Handle(GetAlertGroupsParams) middleware.Responder } // NewGetAlertGroups creates a new http.Handler for the get alert groups operation func NewGetAlertGroups(ctx *middleware.Context, handler GetAlertGroupsHandler) *GetAlertGroups { return &GetAlertGroups{Context: ctx, Handler: handler} } /* GetAlertGroups swagger:route GET /alerts/groups alertgroup getAlertGroups Get a list of alert groups */ type GetAlertGroups struct { Context *middleware.Context Handler GetAlertGroupsHandler } func (o *GetAlertGroups) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewGetAlertGroupsParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } ================================================ FILE: api/v2/restapi/operations/alertgroup/get_alert_groups_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alertgroup // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // NewGetAlertGroupsParams creates a new GetAlertGroupsParams object // with the default values initialized. func NewGetAlertGroupsParams() GetAlertGroupsParams { var ( // initialize parameters with default values activeDefault = bool(true) inhibitedDefault = bool(true) mutedDefault = bool(true) silencedDefault = bool(true) ) return GetAlertGroupsParams{ Active: &activeDefault, Inhibited: &inhibitedDefault, Muted: &mutedDefault, Silenced: &silencedDefault, } } // GetAlertGroupsParams contains all the bound params for the get alert groups operation // typically these are obtained from a http.Request // // swagger:parameters getAlertGroups type GetAlertGroupsParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` /*Include active alerts within the returned groups. If false, excludes active alerts from groups and only shows suppressed (silenced or inhibited) alerts. In: query Default: true */ Active *bool /*A matcher expression to filter alert groups. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. In: query Collection Format: multi */ Filter []string /*Include inhibited alerts within the returned groups. If false, excludes inhibited alerts from groups. Note that true (default) shows both inhibited and non-inhibited alerts. In: query Default: true */ Inhibited *bool /*Include muted (silenced or inhibited) alert groups in results. If false, excludes entire groups where all alerts are muted. In: query Default: true */ Muted *bool /*A regex matching receivers to filter alerts by In: query */ Receiver *string /*Include silenced alerts within the returned groups. If false, excludes silenced alerts from groups. Note that true (default) shows both silenced and non-silenced alerts. In: query Default: true */ Silenced *bool } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewGetAlertGroupsParams() beforehand. func (o *GetAlertGroupsParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r qs := runtime.Values(r.URL.Query()) qActive, qhkActive, _ := qs.GetOK("active") if err := o.bindActive(qActive, qhkActive, route.Formats); err != nil { res = append(res, err) } qFilter, qhkFilter, _ := qs.GetOK("filter") if err := o.bindFilter(qFilter, qhkFilter, route.Formats); err != nil { res = append(res, err) } qInhibited, qhkInhibited, _ := qs.GetOK("inhibited") if err := o.bindInhibited(qInhibited, qhkInhibited, route.Formats); err != nil { res = append(res, err) } qMuted, qhkMuted, _ := qs.GetOK("muted") if err := o.bindMuted(qMuted, qhkMuted, route.Formats); err != nil { res = append(res, err) } qReceiver, qhkReceiver, _ := qs.GetOK("receiver") if err := o.bindReceiver(qReceiver, qhkReceiver, route.Formats); err != nil { res = append(res, err) } qSilenced, qhkSilenced, _ := qs.GetOK("silenced") if err := o.bindSilenced(qSilenced, qhkSilenced, route.Formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // bindActive binds and validates parameter Active from query. func (o *GetAlertGroupsParams) bindActive(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations // Default values have been previously initialized by NewGetAlertGroupsParams() return nil } value, err := swag.ConvertBool(raw) if err != nil { return errors.InvalidType("active", "query", "bool", raw) } o.Active = &value return nil } // bindFilter binds and validates array parameter Filter from query. // // Arrays are parsed according to CollectionFormat: "multi" (defaults to "csv" when empty). func (o *GetAlertGroupsParams) bindFilter(rawData []string, hasKey bool, formats strfmt.Registry) error { // CollectionFormat: multi filterIC := rawData if len(filterIC) == 0 { return nil } var filterIR []string for _, filterIV := range filterIC { filterI := filterIV filterIR = append(filterIR, filterI) } o.Filter = filterIR return nil } // bindInhibited binds and validates parameter Inhibited from query. func (o *GetAlertGroupsParams) bindInhibited(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations // Default values have been previously initialized by NewGetAlertGroupsParams() return nil } value, err := swag.ConvertBool(raw) if err != nil { return errors.InvalidType("inhibited", "query", "bool", raw) } o.Inhibited = &value return nil } // bindMuted binds and validates parameter Muted from query. func (o *GetAlertGroupsParams) bindMuted(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations // Default values have been previously initialized by NewGetAlertGroupsParams() return nil } value, err := swag.ConvertBool(raw) if err != nil { return errors.InvalidType("muted", "query", "bool", raw) } o.Muted = &value return nil } // bindReceiver binds and validates parameter Receiver from query. func (o *GetAlertGroupsParams) bindReceiver(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations return nil } o.Receiver = &raw return nil } // bindSilenced binds and validates parameter Silenced from query. func (o *GetAlertGroupsParams) bindSilenced(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: false // AllowEmptyValue: false if raw == "" { // empty values pass all other validations // Default values have been previously initialized by NewGetAlertGroupsParams() return nil } value, err := swag.ConvertBool(raw) if err != nil { return errors.InvalidType("silenced", "query", "bool", raw) } o.Silenced = &value return nil } ================================================ FILE: api/v2/restapi/operations/alertgroup/get_alert_groups_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alertgroup // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" "github.com/prometheus/alertmanager/api/v2/models" ) // GetAlertGroupsOKCode is the HTTP code returned for type GetAlertGroupsOK const GetAlertGroupsOKCode int = 200 /* GetAlertGroupsOK Get alert groups response swagger:response getAlertGroupsOK */ type GetAlertGroupsOK struct { /* In: Body */ Payload models.AlertGroups `json:"body,omitempty"` } // NewGetAlertGroupsOK creates GetAlertGroupsOK with default headers values func NewGetAlertGroupsOK() *GetAlertGroupsOK { return &GetAlertGroupsOK{} } // WithPayload adds the payload to the get alert groups o k response func (o *GetAlertGroupsOK) WithPayload(payload models.AlertGroups) *GetAlertGroupsOK { o.Payload = payload return o } // SetPayload sets the payload to the get alert groups o k response func (o *GetAlertGroupsOK) SetPayload(payload models.AlertGroups) { o.Payload = payload } // WriteResponse to the client func (o *GetAlertGroupsOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(200) payload := o.Payload if payload == nil { // return empty array payload = models.AlertGroups{} } if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } // GetAlertGroupsBadRequestCode is the HTTP code returned for type GetAlertGroupsBadRequest const GetAlertGroupsBadRequestCode int = 400 /* GetAlertGroupsBadRequest Bad request swagger:response getAlertGroupsBadRequest */ type GetAlertGroupsBadRequest struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewGetAlertGroupsBadRequest creates GetAlertGroupsBadRequest with default headers values func NewGetAlertGroupsBadRequest() *GetAlertGroupsBadRequest { return &GetAlertGroupsBadRequest{} } // WithPayload adds the payload to the get alert groups bad request response func (o *GetAlertGroupsBadRequest) WithPayload(payload string) *GetAlertGroupsBadRequest { o.Payload = payload return o } // SetPayload sets the payload to the get alert groups bad request response func (o *GetAlertGroupsBadRequest) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *GetAlertGroupsBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(400) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } // GetAlertGroupsInternalServerErrorCode is the HTTP code returned for type GetAlertGroupsInternalServerError const GetAlertGroupsInternalServerErrorCode int = 500 /* GetAlertGroupsInternalServerError Internal server error swagger:response getAlertGroupsInternalServerError */ type GetAlertGroupsInternalServerError struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewGetAlertGroupsInternalServerError creates GetAlertGroupsInternalServerError with default headers values func NewGetAlertGroupsInternalServerError() *GetAlertGroupsInternalServerError { return &GetAlertGroupsInternalServerError{} } // WithPayload adds the payload to the get alert groups internal server error response func (o *GetAlertGroupsInternalServerError) WithPayload(payload string) *GetAlertGroupsInternalServerError { o.Payload = payload return o } // SetPayload sets the payload to the get alert groups internal server error response func (o *GetAlertGroupsInternalServerError) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *GetAlertGroupsInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(500) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } ================================================ FILE: api/v2/restapi/operations/alertgroup/get_alert_groups_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package alertgroup // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" "github.com/go-openapi/swag" ) // GetAlertGroupsURL generates an URL for the get alert groups operation type GetAlertGroupsURL struct { Active *bool Filter []string Inhibited *bool Muted *bool Receiver *string Silenced *bool _basePath string // avoid unkeyed usage _ struct{} } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetAlertGroupsURL) WithBasePath(bp string) *GetAlertGroupsURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetAlertGroupsURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *GetAlertGroupsURL) Build() (*url.URL, error) { var _result url.URL var _path = "/alerts/groups" _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) qs := make(url.Values) var activeQ string if o.Active != nil { activeQ = swag.FormatBool(*o.Active) } if activeQ != "" { qs.Set("active", activeQ) } var filterIR []string for _, filterI := range o.Filter { filterIS := filterI if filterIS != "" { filterIR = append(filterIR, filterIS) } } filter := swag.JoinByFormat(filterIR, "multi") for _, qsv := range filter { qs.Add("filter", qsv) } var inhibitedQ string if o.Inhibited != nil { inhibitedQ = swag.FormatBool(*o.Inhibited) } if inhibitedQ != "" { qs.Set("inhibited", inhibitedQ) } var mutedQ string if o.Muted != nil { mutedQ = swag.FormatBool(*o.Muted) } if mutedQ != "" { qs.Set("muted", mutedQ) } var receiverQ string if o.Receiver != nil { receiverQ = *o.Receiver } if receiverQ != "" { qs.Set("receiver", receiverQ) } var silencedQ string if o.Silenced != nil { silencedQ = swag.FormatBool(*o.Silenced) } if silencedQ != "" { qs.Set("silenced", silencedQ) } _result.RawQuery = qs.Encode() return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *GetAlertGroupsURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *GetAlertGroupsURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *GetAlertGroupsURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on GetAlertGroupsURL") } if host == "" { return nil, errors.New("host is required for a full url on GetAlertGroupsURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *GetAlertGroupsURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/operations/alertmanager_api.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package operations // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "fmt" "net/http" "strings" "github.com/go-openapi/errors" "github.com/go-openapi/loads" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/runtime/security" "github.com/go-openapi/spec" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" "github.com/prometheus/alertmanager/api/v2/restapi/operations/alert" "github.com/prometheus/alertmanager/api/v2/restapi/operations/alertgroup" "github.com/prometheus/alertmanager/api/v2/restapi/operations/general" "github.com/prometheus/alertmanager/api/v2/restapi/operations/receiver" "github.com/prometheus/alertmanager/api/v2/restapi/operations/silence" ) // NewAlertmanagerAPI creates a new Alertmanager instance func NewAlertmanagerAPI(spec *loads.Document) *AlertmanagerAPI { return &AlertmanagerAPI{ handlers: make(map[string]map[string]http.Handler), formats: strfmt.Default, defaultConsumes: "application/json", defaultProduces: "application/json", customConsumers: make(map[string]runtime.Consumer), customProducers: make(map[string]runtime.Producer), PreServerShutdown: func() {}, ServerShutdown: func() {}, spec: spec, useSwaggerUI: false, ServeError: errors.ServeError, BasicAuthenticator: security.BasicAuth, APIKeyAuthenticator: security.APIKeyAuth, BearerAuthenticator: security.BearerAuth, JSONConsumer: runtime.JSONConsumer(), JSONProducer: runtime.JSONProducer(), SilenceDeleteSilenceHandler: silence.DeleteSilenceHandlerFunc(func(params silence.DeleteSilenceParams) middleware.Responder { _ = params return middleware.NotImplemented("operation silence.DeleteSilence has not yet been implemented") }), AlertgroupGetAlertGroupsHandler: alertgroup.GetAlertGroupsHandlerFunc(func(params alertgroup.GetAlertGroupsParams) middleware.Responder { _ = params return middleware.NotImplemented("operation alertgroup.GetAlertGroups has not yet been implemented") }), AlertGetAlertsHandler: alert.GetAlertsHandlerFunc(func(params alert.GetAlertsParams) middleware.Responder { _ = params return middleware.NotImplemented("operation alert.GetAlerts has not yet been implemented") }), ReceiverGetReceiversHandler: receiver.GetReceiversHandlerFunc(func(params receiver.GetReceiversParams) middleware.Responder { _ = params return middleware.NotImplemented("operation receiver.GetReceivers has not yet been implemented") }), SilenceGetSilenceHandler: silence.GetSilenceHandlerFunc(func(params silence.GetSilenceParams) middleware.Responder { _ = params return middleware.NotImplemented("operation silence.GetSilence has not yet been implemented") }), SilenceGetSilencesHandler: silence.GetSilencesHandlerFunc(func(params silence.GetSilencesParams) middleware.Responder { _ = params return middleware.NotImplemented("operation silence.GetSilences has not yet been implemented") }), GeneralGetStatusHandler: general.GetStatusHandlerFunc(func(params general.GetStatusParams) middleware.Responder { _ = params return middleware.NotImplemented("operation general.GetStatus has not yet been implemented") }), AlertPostAlertsHandler: alert.PostAlertsHandlerFunc(func(params alert.PostAlertsParams) middleware.Responder { _ = params return middleware.NotImplemented("operation alert.PostAlerts has not yet been implemented") }), SilencePostSilencesHandler: silence.PostSilencesHandlerFunc(func(params silence.PostSilencesParams) middleware.Responder { _ = params return middleware.NotImplemented("operation silence.PostSilences has not yet been implemented") }), } } /*AlertmanagerAPI API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) */ type AlertmanagerAPI struct { spec *loads.Document context *middleware.Context handlers map[string]map[string]http.Handler formats strfmt.Registry customConsumers map[string]runtime.Consumer customProducers map[string]runtime.Producer defaultConsumes string defaultProduces string Middleware func(middleware.Builder) http.Handler useSwaggerUI bool // BasicAuthenticator generates a runtime.Authenticator from the supplied basic auth function. // It has a default implementation in the security package, however you can replace it for your particular usage. BasicAuthenticator func(security.UserPassAuthentication) runtime.Authenticator // APIKeyAuthenticator generates a runtime.Authenticator from the supplied token auth function. // It has a default implementation in the security package, however you can replace it for your particular usage. APIKeyAuthenticator func(string, string, security.TokenAuthentication) runtime.Authenticator // BearerAuthenticator generates a runtime.Authenticator from the supplied bearer token auth function. // It has a default implementation in the security package, however you can replace it for your particular usage. BearerAuthenticator func(string, security.ScopedTokenAuthentication) runtime.Authenticator // JSONConsumer registers a consumer for the following mime types: // - application/json JSONConsumer runtime.Consumer // JSONProducer registers a producer for the following mime types: // - application/json JSONProducer runtime.Producer // SilenceDeleteSilenceHandler sets the operation handler for the delete silence operation SilenceDeleteSilenceHandler silence.DeleteSilenceHandler // AlertgroupGetAlertGroupsHandler sets the operation handler for the get alert groups operation AlertgroupGetAlertGroupsHandler alertgroup.GetAlertGroupsHandler // AlertGetAlertsHandler sets the operation handler for the get alerts operation AlertGetAlertsHandler alert.GetAlertsHandler // ReceiverGetReceiversHandler sets the operation handler for the get receivers operation ReceiverGetReceiversHandler receiver.GetReceiversHandler // SilenceGetSilenceHandler sets the operation handler for the get silence operation SilenceGetSilenceHandler silence.GetSilenceHandler // SilenceGetSilencesHandler sets the operation handler for the get silences operation SilenceGetSilencesHandler silence.GetSilencesHandler // GeneralGetStatusHandler sets the operation handler for the get status operation GeneralGetStatusHandler general.GetStatusHandler // AlertPostAlertsHandler sets the operation handler for the post alerts operation AlertPostAlertsHandler alert.PostAlertsHandler // SilencePostSilencesHandler sets the operation handler for the post silences operation SilencePostSilencesHandler silence.PostSilencesHandler // ServeError is called when an error is received, there is a default handler // but you can set your own with this ServeError func(http.ResponseWriter, *http.Request, error) // PreServerShutdown is called before the HTTP(S) server is shutdown // This allows for custom functions to get executed before the HTTP(S) server stops accepting traffic PreServerShutdown func() // ServerShutdown is called when the HTTP(S) server is shut down and done // handling all active connections and does not accept connections any more ServerShutdown func() // Custom command line argument groups with their descriptions CommandLineOptionsGroups []swag.CommandLineOptionsGroup // User defined logger function. Logger func(string, ...any) } // UseRedoc for documentation at /docs func (o *AlertmanagerAPI) UseRedoc() { o.useSwaggerUI = false } // UseSwaggerUI for documentation at /docs func (o *AlertmanagerAPI) UseSwaggerUI() { o.useSwaggerUI = true } // SetDefaultProduces sets the default produces media type func (o *AlertmanagerAPI) SetDefaultProduces(mediaType string) { o.defaultProduces = mediaType } // SetDefaultConsumes returns the default consumes media type func (o *AlertmanagerAPI) SetDefaultConsumes(mediaType string) { o.defaultConsumes = mediaType } // SetSpec sets a spec that will be served for the clients. func (o *AlertmanagerAPI) SetSpec(spec *loads.Document) { o.spec = spec } // DefaultProduces returns the default produces media type func (o *AlertmanagerAPI) DefaultProduces() string { return o.defaultProduces } // DefaultConsumes returns the default consumes media type func (o *AlertmanagerAPI) DefaultConsumes() string { return o.defaultConsumes } // Formats returns the registered string formats func (o *AlertmanagerAPI) Formats() strfmt.Registry { return o.formats } // RegisterFormat registers a custom format validator func (o *AlertmanagerAPI) RegisterFormat(name string, format strfmt.Format, validator strfmt.Validator) { o.formats.Add(name, format, validator) } // Validate validates the registrations in the AlertmanagerAPI func (o *AlertmanagerAPI) Validate() error { var unregistered []string if o.JSONConsumer == nil { unregistered = append(unregistered, "JSONConsumer") } if o.JSONProducer == nil { unregistered = append(unregistered, "JSONProducer") } if o.SilenceDeleteSilenceHandler == nil { unregistered = append(unregistered, "silence.DeleteSilenceHandler") } if o.AlertgroupGetAlertGroupsHandler == nil { unregistered = append(unregistered, "alertgroup.GetAlertGroupsHandler") } if o.AlertGetAlertsHandler == nil { unregistered = append(unregistered, "alert.GetAlertsHandler") } if o.ReceiverGetReceiversHandler == nil { unregistered = append(unregistered, "receiver.GetReceiversHandler") } if o.SilenceGetSilenceHandler == nil { unregistered = append(unregistered, "silence.GetSilenceHandler") } if o.SilenceGetSilencesHandler == nil { unregistered = append(unregistered, "silence.GetSilencesHandler") } if o.GeneralGetStatusHandler == nil { unregistered = append(unregistered, "general.GetStatusHandler") } if o.AlertPostAlertsHandler == nil { unregistered = append(unregistered, "alert.PostAlertsHandler") } if o.SilencePostSilencesHandler == nil { unregistered = append(unregistered, "silence.PostSilencesHandler") } if len(unregistered) > 0 { return fmt.Errorf("missing registration: %s", strings.Join(unregistered, ", ")) } return nil } // ServeErrorFor gets a error handler for a given operation id func (o *AlertmanagerAPI) ServeErrorFor(operationID string) func(http.ResponseWriter, *http.Request, error) { return o.ServeError } // AuthenticatorsFor gets the authenticators for the specified security schemes func (o *AlertmanagerAPI) AuthenticatorsFor(schemes map[string]spec.SecurityScheme) map[string]runtime.Authenticator { return nil } // Authorizer returns the registered authorizer func (o *AlertmanagerAPI) Authorizer() runtime.Authorizer { return nil } // ConsumersFor gets the consumers for the specified media types. // // MIME type parameters are ignored here. func (o *AlertmanagerAPI) ConsumersFor(mediaTypes []string) map[string]runtime.Consumer { result := make(map[string]runtime.Consumer, len(mediaTypes)) for _, mt := range mediaTypes { if mt == "application/json" { result["application/json"] = o.JSONConsumer } if c, ok := o.customConsumers[mt]; ok { result[mt] = c } } return result } // ProducersFor gets the producers for the specified media types. // // MIME type parameters are ignored here. func (o *AlertmanagerAPI) ProducersFor(mediaTypes []string) map[string]runtime.Producer { result := make(map[string]runtime.Producer, len(mediaTypes)) for _, mt := range mediaTypes { if mt == "application/json" { result["application/json"] = o.JSONProducer } if p, ok := o.customProducers[mt]; ok { result[mt] = p } } return result } // HandlerFor gets a http.Handler for the provided operation method and path func (o *AlertmanagerAPI) HandlerFor(method, path string) (http.Handler, bool) { if o.handlers == nil { return nil, false } um := strings.ToUpper(method) if _, ok := o.handlers[um]; !ok { return nil, false } if path == "/" { path = "" } h, ok := o.handlers[um][path] return h, ok } // Context returns the middleware context for the alertmanager API func (o *AlertmanagerAPI) Context() *middleware.Context { if o.context == nil { o.context = middleware.NewRoutableContext(o.spec, o, nil) } return o.context } func (o *AlertmanagerAPI) initHandlerCache() { o.Context() // don't care about the result, just that the initialization happened if o.handlers == nil { o.handlers = make(map[string]map[string]http.Handler) } if o.handlers["DELETE"] == nil { o.handlers["DELETE"] = make(map[string]http.Handler) } o.handlers["DELETE"]["/silence/{silenceID}"] = silence.NewDeleteSilence(o.context, o.SilenceDeleteSilenceHandler) if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) } o.handlers["GET"]["/alerts/groups"] = alertgroup.NewGetAlertGroups(o.context, o.AlertgroupGetAlertGroupsHandler) if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) } o.handlers["GET"]["/alerts"] = alert.NewGetAlerts(o.context, o.AlertGetAlertsHandler) if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) } o.handlers["GET"]["/receivers"] = receiver.NewGetReceivers(o.context, o.ReceiverGetReceiversHandler) if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) } o.handlers["GET"]["/silence/{silenceID}"] = silence.NewGetSilence(o.context, o.SilenceGetSilenceHandler) if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) } o.handlers["GET"]["/silences"] = silence.NewGetSilences(o.context, o.SilenceGetSilencesHandler) if o.handlers["GET"] == nil { o.handlers["GET"] = make(map[string]http.Handler) } o.handlers["GET"]["/status"] = general.NewGetStatus(o.context, o.GeneralGetStatusHandler) if o.handlers["POST"] == nil { o.handlers["POST"] = make(map[string]http.Handler) } o.handlers["POST"]["/alerts"] = alert.NewPostAlerts(o.context, o.AlertPostAlertsHandler) if o.handlers["POST"] == nil { o.handlers["POST"] = make(map[string]http.Handler) } o.handlers["POST"]["/silences"] = silence.NewPostSilences(o.context, o.SilencePostSilencesHandler) } // Serve creates a http handler to serve the API over HTTP // can be used directly in http.ListenAndServe(":8000", api.Serve(nil)) func (o *AlertmanagerAPI) Serve(builder middleware.Builder) http.Handler { o.Init() if o.Middleware != nil { return o.Middleware(builder) } if o.useSwaggerUI { return o.context.APIHandlerSwaggerUI(builder) } return o.context.APIHandler(builder) } // Init allows you to just initialize the handler cache, you can then recompose the middleware as you see fit func (o *AlertmanagerAPI) Init() { if len(o.handlers) == 0 { o.initHandlerCache() } } // RegisterConsumer allows you to add (or override) a consumer for a media type. func (o *AlertmanagerAPI) RegisterConsumer(mediaType string, consumer runtime.Consumer) { o.customConsumers[mediaType] = consumer } // RegisterProducer allows you to add (or override) a producer for a media type. func (o *AlertmanagerAPI) RegisterProducer(mediaType string, producer runtime.Producer) { o.customProducers[mediaType] = producer } // AddMiddlewareFor adds a http middleware to existing handler func (o *AlertmanagerAPI) AddMiddlewareFor(method, path string, builder middleware.Builder) { um := strings.ToUpper(method) if path == "/" { path = "" } o.Init() if h, ok := o.handlers[um][path]; ok { o.handlers[um][path] = builder(h) } } ================================================ FILE: api/v2/restapi/operations/general/get_status.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package general // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "net/http" "github.com/go-openapi/runtime/middleware" ) // GetStatusHandlerFunc turns a function with the right signature into a get status handler type GetStatusHandlerFunc func(GetStatusParams) middleware.Responder // Handle executing the request and returning a response func (fn GetStatusHandlerFunc) Handle(params GetStatusParams) middleware.Responder { return fn(params) } // GetStatusHandler interface for that can handle valid get status params type GetStatusHandler interface { Handle(GetStatusParams) middleware.Responder } // NewGetStatus creates a new http.Handler for the get status operation func NewGetStatus(ctx *middleware.Context, handler GetStatusHandler) *GetStatus { return &GetStatus{Context: ctx, Handler: handler} } /* GetStatus swagger:route GET /status general getStatus Get current status of an Alertmanager instance and its cluster */ type GetStatus struct { Context *middleware.Context Handler GetStatusHandler } func (o *GetStatus) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewGetStatusParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } ================================================ FILE: api/v2/restapi/operations/general/get_status_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package general // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime/middleware" ) // NewGetStatusParams creates a new GetStatusParams object // // There are no default values defined in the spec. func NewGetStatusParams() GetStatusParams { return GetStatusParams{} } // GetStatusParams contains all the bound params for the get status operation // typically these are obtained from a http.Request // // swagger:parameters getStatus type GetStatusParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewGetStatusParams() beforehand. func (o *GetStatusParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/restapi/operations/general/get_status_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package general // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" "github.com/prometheus/alertmanager/api/v2/models" ) // GetStatusOKCode is the HTTP code returned for type GetStatusOK const GetStatusOKCode int = 200 /* GetStatusOK Get status response swagger:response getStatusOK */ type GetStatusOK struct { /* In: Body */ Payload *models.AlertmanagerStatus `json:"body,omitempty"` } // NewGetStatusOK creates GetStatusOK with default headers values func NewGetStatusOK() *GetStatusOK { return &GetStatusOK{} } // WithPayload adds the payload to the get status o k response func (o *GetStatusOK) WithPayload(payload *models.AlertmanagerStatus) *GetStatusOK { o.Payload = payload return o } // SetPayload sets the payload to the get status o k response func (o *GetStatusOK) SetPayload(payload *models.AlertmanagerStatus) { o.Payload = payload } // WriteResponse to the client func (o *GetStatusOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(200) if o.Payload != nil { payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } } ================================================ FILE: api/v2/restapi/operations/general/get_status_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package general // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" ) // GetStatusURL generates an URL for the get status operation type GetStatusURL struct { _basePath string } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetStatusURL) WithBasePath(bp string) *GetStatusURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetStatusURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *GetStatusURL) Build() (*url.URL, error) { var _result url.URL var _path = "/status" _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *GetStatusURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *GetStatusURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *GetStatusURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on GetStatusURL") } if host == "" { return nil, errors.New("host is required for a full url on GetStatusURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *GetStatusURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/operations/receiver/get_receivers.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package receiver // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "net/http" "github.com/go-openapi/runtime/middleware" ) // GetReceiversHandlerFunc turns a function with the right signature into a get receivers handler type GetReceiversHandlerFunc func(GetReceiversParams) middleware.Responder // Handle executing the request and returning a response func (fn GetReceiversHandlerFunc) Handle(params GetReceiversParams) middleware.Responder { return fn(params) } // GetReceiversHandler interface for that can handle valid get receivers params type GetReceiversHandler interface { Handle(GetReceiversParams) middleware.Responder } // NewGetReceivers creates a new http.Handler for the get receivers operation func NewGetReceivers(ctx *middleware.Context, handler GetReceiversHandler) *GetReceivers { return &GetReceivers{Context: ctx, Handler: handler} } /* GetReceivers swagger:route GET /receivers receiver getReceivers Get list of all receivers (name of notification integrations) */ type GetReceivers struct { Context *middleware.Context Handler GetReceiversHandler } func (o *GetReceivers) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewGetReceiversParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } ================================================ FILE: api/v2/restapi/operations/receiver/get_receivers_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package receiver // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime/middleware" ) // NewGetReceiversParams creates a new GetReceiversParams object // // There are no default values defined in the spec. func NewGetReceiversParams() GetReceiversParams { return GetReceiversParams{} } // GetReceiversParams contains all the bound params for the get receivers operation // typically these are obtained from a http.Request // // swagger:parameters getReceivers type GetReceiversParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewGetReceiversParams() beforehand. func (o *GetReceiversParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/restapi/operations/receiver/get_receivers_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package receiver // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" "github.com/prometheus/alertmanager/api/v2/models" ) // GetReceiversOKCode is the HTTP code returned for type GetReceiversOK const GetReceiversOKCode int = 200 /* GetReceiversOK Get receivers response swagger:response getReceiversOK */ type GetReceiversOK struct { /* In: Body */ Payload []*models.Receiver `json:"body,omitempty"` } // NewGetReceiversOK creates GetReceiversOK with default headers values func NewGetReceiversOK() *GetReceiversOK { return &GetReceiversOK{} } // WithPayload adds the payload to the get receivers o k response func (o *GetReceiversOK) WithPayload(payload []*models.Receiver) *GetReceiversOK { o.Payload = payload return o } // SetPayload sets the payload to the get receivers o k response func (o *GetReceiversOK) SetPayload(payload []*models.Receiver) { o.Payload = payload } // WriteResponse to the client func (o *GetReceiversOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(200) payload := o.Payload if payload == nil { // return empty array payload = make([]*models.Receiver, 0, 50) } if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } ================================================ FILE: api/v2/restapi/operations/receiver/get_receivers_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package receiver // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" ) // GetReceiversURL generates an URL for the get receivers operation type GetReceiversURL struct { _basePath string } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetReceiversURL) WithBasePath(bp string) *GetReceiversURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetReceiversURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *GetReceiversURL) Build() (*url.URL, error) { var _result url.URL var _path = "/receivers" _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *GetReceiversURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *GetReceiversURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *GetReceiversURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on GetReceiversURL") } if host == "" { return nil, errors.New("host is required for a full url on GetReceiversURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *GetReceiversURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/operations/silence/delete_silence.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "net/http" "github.com/go-openapi/runtime/middleware" ) // DeleteSilenceHandlerFunc turns a function with the right signature into a delete silence handler type DeleteSilenceHandlerFunc func(DeleteSilenceParams) middleware.Responder // Handle executing the request and returning a response func (fn DeleteSilenceHandlerFunc) Handle(params DeleteSilenceParams) middleware.Responder { return fn(params) } // DeleteSilenceHandler interface for that can handle valid delete silence params type DeleteSilenceHandler interface { Handle(DeleteSilenceParams) middleware.Responder } // NewDeleteSilence creates a new http.Handler for the delete silence operation func NewDeleteSilence(ctx *middleware.Context, handler DeleteSilenceHandler) *DeleteSilence { return &DeleteSilence{Context: ctx, Handler: handler} } /* DeleteSilence swagger:route DELETE /silence/{silenceID} silence deleteSilence Delete a silence by its ID */ type DeleteSilence struct { Context *middleware.Context Handler DeleteSilenceHandler } func (o *DeleteSilence) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewDeleteSilenceParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } ================================================ FILE: api/v2/restapi/operations/silence/delete_silence_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/strfmt" "github.com/go-openapi/validate" ) // NewDeleteSilenceParams creates a new DeleteSilenceParams object // // There are no default values defined in the spec. func NewDeleteSilenceParams() DeleteSilenceParams { return DeleteSilenceParams{} } // DeleteSilenceParams contains all the bound params for the delete silence operation // typically these are obtained from a http.Request // // swagger:parameters deleteSilence type DeleteSilenceParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` /*ID of the silence to get Required: true In: path */ SilenceID strfmt.UUID } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewDeleteSilenceParams() beforehand. func (o *DeleteSilenceParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r rSilenceID, rhkSilenceID, _ := route.Params.GetOK("silenceID") if err := o.bindSilenceID(rSilenceID, rhkSilenceID, route.Formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // bindSilenceID binds and validates parameter SilenceID from path. func (o *DeleteSilenceParams) bindSilenceID(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: true // Parameter is provided by construction from the route // Format: uuid value, err := formats.Parse("uuid", raw) if err != nil { return errors.InvalidType("silenceID", "path", "strfmt.UUID", raw) } o.SilenceID = *(value.(*strfmt.UUID)) if err := o.validateSilenceID(formats); err != nil { return err } return nil } // validateSilenceID carries out validations for parameter SilenceID func (o *DeleteSilenceParams) validateSilenceID(formats strfmt.Registry) error { if err := validate.FormatOf("silenceID", "path", "uuid", o.SilenceID.String(), formats); err != nil { return err } return nil } ================================================ FILE: api/v2/restapi/operations/silence/delete_silence_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" ) // DeleteSilenceOKCode is the HTTP code returned for type DeleteSilenceOK const DeleteSilenceOKCode int = 200 /* DeleteSilenceOK Delete silence response swagger:response deleteSilenceOK */ type DeleteSilenceOK struct { } // NewDeleteSilenceOK creates DeleteSilenceOK with default headers values func NewDeleteSilenceOK() *DeleteSilenceOK { return &DeleteSilenceOK{} } // WriteResponse to the client func (o *DeleteSilenceOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.Header().Del(runtime.HeaderContentType) // Remove Content-Type on empty responses rw.WriteHeader(200) } // DeleteSilenceNotFoundCode is the HTTP code returned for type DeleteSilenceNotFound const DeleteSilenceNotFoundCode int = 404 /* DeleteSilenceNotFound A silence with the specified ID was not found swagger:response deleteSilenceNotFound */ type DeleteSilenceNotFound struct { } // NewDeleteSilenceNotFound creates DeleteSilenceNotFound with default headers values func NewDeleteSilenceNotFound() *DeleteSilenceNotFound { return &DeleteSilenceNotFound{} } // WriteResponse to the client func (o *DeleteSilenceNotFound) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.Header().Del(runtime.HeaderContentType) // Remove Content-Type on empty responses rw.WriteHeader(404) } // DeleteSilenceInternalServerErrorCode is the HTTP code returned for type DeleteSilenceInternalServerError const DeleteSilenceInternalServerErrorCode int = 500 /* DeleteSilenceInternalServerError Internal server error swagger:response deleteSilenceInternalServerError */ type DeleteSilenceInternalServerError struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewDeleteSilenceInternalServerError creates DeleteSilenceInternalServerError with default headers values func NewDeleteSilenceInternalServerError() *DeleteSilenceInternalServerError { return &DeleteSilenceInternalServerError{} } // WithPayload adds the payload to the delete silence internal server error response func (o *DeleteSilenceInternalServerError) WithPayload(payload string) *DeleteSilenceInternalServerError { o.Payload = payload return o } // SetPayload sets the payload to the delete silence internal server error response func (o *DeleteSilenceInternalServerError) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *DeleteSilenceInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(500) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } ================================================ FILE: api/v2/restapi/operations/silence/delete_silence_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" "strings" "github.com/go-openapi/strfmt" ) // DeleteSilenceURL generates an URL for the delete silence operation type DeleteSilenceURL struct { SilenceID strfmt.UUID _basePath string // avoid unkeyed usage _ struct{} } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *DeleteSilenceURL) WithBasePath(bp string) *DeleteSilenceURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *DeleteSilenceURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *DeleteSilenceURL) Build() (*url.URL, error) { var _result url.URL var _path = "/silence/{silenceID}" silenceID := o.SilenceID.String() if silenceID != "" { _path = strings.ReplaceAll(_path, "{silenceID}", silenceID) } else { return nil, errors.New("silenceId is required on DeleteSilenceURL") } _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *DeleteSilenceURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *DeleteSilenceURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *DeleteSilenceURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on DeleteSilenceURL") } if host == "" { return nil, errors.New("host is required for a full url on DeleteSilenceURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *DeleteSilenceURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/operations/silence/get_silence.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "net/http" "github.com/go-openapi/runtime/middleware" ) // GetSilenceHandlerFunc turns a function with the right signature into a get silence handler type GetSilenceHandlerFunc func(GetSilenceParams) middleware.Responder // Handle executing the request and returning a response func (fn GetSilenceHandlerFunc) Handle(params GetSilenceParams) middleware.Responder { return fn(params) } // GetSilenceHandler interface for that can handle valid get silence params type GetSilenceHandler interface { Handle(GetSilenceParams) middleware.Responder } // NewGetSilence creates a new http.Handler for the get silence operation func NewGetSilence(ctx *middleware.Context, handler GetSilenceHandler) *GetSilence { return &GetSilence{Context: ctx, Handler: handler} } /* GetSilence swagger:route GET /silence/{silenceID} silence getSilence Get a silence by its ID */ type GetSilence struct { Context *middleware.Context Handler GetSilenceHandler } func (o *GetSilence) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewGetSilenceParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } ================================================ FILE: api/v2/restapi/operations/silence/get_silence_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/strfmt" "github.com/go-openapi/validate" ) // NewGetSilenceParams creates a new GetSilenceParams object // // There are no default values defined in the spec. func NewGetSilenceParams() GetSilenceParams { return GetSilenceParams{} } // GetSilenceParams contains all the bound params for the get silence operation // typically these are obtained from a http.Request // // swagger:parameters getSilence type GetSilenceParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` /*ID of the silence to get Required: true In: path */ SilenceID strfmt.UUID } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewGetSilenceParams() beforehand. func (o *GetSilenceParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r rSilenceID, rhkSilenceID, _ := route.Params.GetOK("silenceID") if err := o.bindSilenceID(rSilenceID, rhkSilenceID, route.Formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // bindSilenceID binds and validates parameter SilenceID from path. func (o *GetSilenceParams) bindSilenceID(rawData []string, hasKey bool, formats strfmt.Registry) error { var raw string if len(rawData) > 0 { raw = rawData[len(rawData)-1] } // Required: true // Parameter is provided by construction from the route // Format: uuid value, err := formats.Parse("uuid", raw) if err != nil { return errors.InvalidType("silenceID", "path", "strfmt.UUID", raw) } o.SilenceID = *(value.(*strfmt.UUID)) if err := o.validateSilenceID(formats); err != nil { return err } return nil } // validateSilenceID carries out validations for parameter SilenceID func (o *GetSilenceParams) validateSilenceID(formats strfmt.Registry) error { if err := validate.FormatOf("silenceID", "path", "uuid", o.SilenceID.String(), formats); err != nil { return err } return nil } ================================================ FILE: api/v2/restapi/operations/silence/get_silence_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" "github.com/prometheus/alertmanager/api/v2/models" ) // GetSilenceOKCode is the HTTP code returned for type GetSilenceOK const GetSilenceOKCode int = 200 /* GetSilenceOK Get silence response swagger:response getSilenceOK */ type GetSilenceOK struct { /* In: Body */ Payload *models.GettableSilence `json:"body,omitempty"` } // NewGetSilenceOK creates GetSilenceOK with default headers values func NewGetSilenceOK() *GetSilenceOK { return &GetSilenceOK{} } // WithPayload adds the payload to the get silence o k response func (o *GetSilenceOK) WithPayload(payload *models.GettableSilence) *GetSilenceOK { o.Payload = payload return o } // SetPayload sets the payload to the get silence o k response func (o *GetSilenceOK) SetPayload(payload *models.GettableSilence) { o.Payload = payload } // WriteResponse to the client func (o *GetSilenceOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(200) if o.Payload != nil { payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } } // GetSilenceNotFoundCode is the HTTP code returned for type GetSilenceNotFound const GetSilenceNotFoundCode int = 404 /* GetSilenceNotFound A silence with the specified ID was not found swagger:response getSilenceNotFound */ type GetSilenceNotFound struct { } // NewGetSilenceNotFound creates GetSilenceNotFound with default headers values func NewGetSilenceNotFound() *GetSilenceNotFound { return &GetSilenceNotFound{} } // WriteResponse to the client func (o *GetSilenceNotFound) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.Header().Del(runtime.HeaderContentType) // Remove Content-Type on empty responses rw.WriteHeader(404) } // GetSilenceInternalServerErrorCode is the HTTP code returned for type GetSilenceInternalServerError const GetSilenceInternalServerErrorCode int = 500 /* GetSilenceInternalServerError Internal server error swagger:response getSilenceInternalServerError */ type GetSilenceInternalServerError struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewGetSilenceInternalServerError creates GetSilenceInternalServerError with default headers values func NewGetSilenceInternalServerError() *GetSilenceInternalServerError { return &GetSilenceInternalServerError{} } // WithPayload adds the payload to the get silence internal server error response func (o *GetSilenceInternalServerError) WithPayload(payload string) *GetSilenceInternalServerError { o.Payload = payload return o } // SetPayload sets the payload to the get silence internal server error response func (o *GetSilenceInternalServerError) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *GetSilenceInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(500) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } ================================================ FILE: api/v2/restapi/operations/silence/get_silence_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" "strings" "github.com/go-openapi/strfmt" ) // GetSilenceURL generates an URL for the get silence operation type GetSilenceURL struct { SilenceID strfmt.UUID _basePath string // avoid unkeyed usage _ struct{} } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetSilenceURL) WithBasePath(bp string) *GetSilenceURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetSilenceURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *GetSilenceURL) Build() (*url.URL, error) { var _result url.URL var _path = "/silence/{silenceID}" silenceID := o.SilenceID.String() if silenceID != "" { _path = strings.ReplaceAll(_path, "{silenceID}", silenceID) } else { return nil, errors.New("silenceId is required on GetSilenceURL") } _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *GetSilenceURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *GetSilenceURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *GetSilenceURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on GetSilenceURL") } if host == "" { return nil, errors.New("host is required for a full url on GetSilenceURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *GetSilenceURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/operations/silence/get_silences.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "net/http" "github.com/go-openapi/runtime/middleware" ) // GetSilencesHandlerFunc turns a function with the right signature into a get silences handler type GetSilencesHandlerFunc func(GetSilencesParams) middleware.Responder // Handle executing the request and returning a response func (fn GetSilencesHandlerFunc) Handle(params GetSilencesParams) middleware.Responder { return fn(params) } // GetSilencesHandler interface for that can handle valid get silences params type GetSilencesHandler interface { Handle(GetSilencesParams) middleware.Responder } // NewGetSilences creates a new http.Handler for the get silences operation func NewGetSilences(ctx *middleware.Context, handler GetSilencesHandler) *GetSilences { return &GetSilences{Context: ctx, Handler: handler} } /* GetSilences swagger:route GET /silences silence getSilences Get a list of silences */ type GetSilences struct { Context *middleware.Context Handler GetSilencesHandler } func (o *GetSilences) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewGetSilencesParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } ================================================ FILE: api/v2/restapi/operations/silence/get_silences_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/strfmt" ) // NewGetSilencesParams creates a new GetSilencesParams object // // There are no default values defined in the spec. func NewGetSilencesParams() GetSilencesParams { return GetSilencesParams{} } // GetSilencesParams contains all the bound params for the get silences operation // typically these are obtained from a http.Request // // swagger:parameters getSilences type GetSilencesParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` /*A matcher expression to filter silences. For example `alertname="MyAlert"`. It can be repeated to apply multiple matchers. In: query Collection Format: multi */ Filter []string } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewGetSilencesParams() beforehand. func (o *GetSilencesParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r qs := runtime.Values(r.URL.Query()) qFilter, qhkFilter, _ := qs.GetOK("filter") if err := o.bindFilter(qFilter, qhkFilter, route.Formats); err != nil { res = append(res, err) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } // bindFilter binds and validates array parameter Filter from query. // // Arrays are parsed according to CollectionFormat: "multi" (defaults to "csv" when empty). func (o *GetSilencesParams) bindFilter(rawData []string, hasKey bool, formats strfmt.Registry) error { // CollectionFormat: multi filterIC := rawData if len(filterIC) == 0 { return nil } var filterIR []string for _, filterIV := range filterIC { filterI := filterIV filterIR = append(filterIR, filterI) } o.Filter = filterIR return nil } ================================================ FILE: api/v2/restapi/operations/silence/get_silences_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" "github.com/prometheus/alertmanager/api/v2/models" ) // GetSilencesOKCode is the HTTP code returned for type GetSilencesOK const GetSilencesOKCode int = 200 /* GetSilencesOK Get silences response swagger:response getSilencesOK */ type GetSilencesOK struct { /* In: Body */ Payload models.GettableSilences `json:"body,omitempty"` } // NewGetSilencesOK creates GetSilencesOK with default headers values func NewGetSilencesOK() *GetSilencesOK { return &GetSilencesOK{} } // WithPayload adds the payload to the get silences o k response func (o *GetSilencesOK) WithPayload(payload models.GettableSilences) *GetSilencesOK { o.Payload = payload return o } // SetPayload sets the payload to the get silences o k response func (o *GetSilencesOK) SetPayload(payload models.GettableSilences) { o.Payload = payload } // WriteResponse to the client func (o *GetSilencesOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(200) payload := o.Payload if payload == nil { // return empty array payload = models.GettableSilences{} } if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } // GetSilencesBadRequestCode is the HTTP code returned for type GetSilencesBadRequest const GetSilencesBadRequestCode int = 400 /* GetSilencesBadRequest Bad request swagger:response getSilencesBadRequest */ type GetSilencesBadRequest struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewGetSilencesBadRequest creates GetSilencesBadRequest with default headers values func NewGetSilencesBadRequest() *GetSilencesBadRequest { return &GetSilencesBadRequest{} } // WithPayload adds the payload to the get silences bad request response func (o *GetSilencesBadRequest) WithPayload(payload string) *GetSilencesBadRequest { o.Payload = payload return o } // SetPayload sets the payload to the get silences bad request response func (o *GetSilencesBadRequest) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *GetSilencesBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(400) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } // GetSilencesInternalServerErrorCode is the HTTP code returned for type GetSilencesInternalServerError const GetSilencesInternalServerErrorCode int = 500 /* GetSilencesInternalServerError Internal server error swagger:response getSilencesInternalServerError */ type GetSilencesInternalServerError struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewGetSilencesInternalServerError creates GetSilencesInternalServerError with default headers values func NewGetSilencesInternalServerError() *GetSilencesInternalServerError { return &GetSilencesInternalServerError{} } // WithPayload adds the payload to the get silences internal server error response func (o *GetSilencesInternalServerError) WithPayload(payload string) *GetSilencesInternalServerError { o.Payload = payload return o } // SetPayload sets the payload to the get silences internal server error response func (o *GetSilencesInternalServerError) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *GetSilencesInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(500) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } ================================================ FILE: api/v2/restapi/operations/silence/get_silences_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" "github.com/go-openapi/swag" ) // GetSilencesURL generates an URL for the get silences operation type GetSilencesURL struct { Filter []string _basePath string // avoid unkeyed usage _ struct{} } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetSilencesURL) WithBasePath(bp string) *GetSilencesURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *GetSilencesURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *GetSilencesURL) Build() (*url.URL, error) { var _result url.URL var _path = "/silences" _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) qs := make(url.Values) var filterIR []string for _, filterI := range o.Filter { filterIS := filterI if filterIS != "" { filterIR = append(filterIR, filterIS) } } filter := swag.JoinByFormat(filterIR, "multi") for _, qsv := range filter { qs.Add("filter", qsv) } _result.RawQuery = qs.Encode() return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *GetSilencesURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *GetSilencesURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *GetSilencesURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on GetSilencesURL") } if host == "" { return nil, errors.New("host is required for a full url on GetSilencesURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *GetSilencesURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/operations/silence/post_silences.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "context" "net/http" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/strfmt" "github.com/go-openapi/swag" ) // PostSilencesHandlerFunc turns a function with the right signature into a post silences handler type PostSilencesHandlerFunc func(PostSilencesParams) middleware.Responder // Handle executing the request and returning a response func (fn PostSilencesHandlerFunc) Handle(params PostSilencesParams) middleware.Responder { return fn(params) } // PostSilencesHandler interface for that can handle valid post silences params type PostSilencesHandler interface { Handle(PostSilencesParams) middleware.Responder } // NewPostSilences creates a new http.Handler for the post silences operation func NewPostSilences(ctx *middleware.Context, handler PostSilencesHandler) *PostSilences { return &PostSilences{Context: ctx, Handler: handler} } /* PostSilences swagger:route POST /silences silence postSilences Post a new silence or update an existing one */ type PostSilences struct { Context *middleware.Context Handler PostSilencesHandler } func (o *PostSilences) ServeHTTP(rw http.ResponseWriter, r *http.Request) { route, rCtx, _ := o.Context.RouteInfo(r) if rCtx != nil { *r = *rCtx } var Params = NewPostSilencesParams() if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params o.Context.Respond(rw, r, route.Produces, route, err) return } res := o.Handler.Handle(Params) // actually handle the request o.Context.Respond(rw, r, route.Produces, route, res) } // PostSilencesOKBody post silences o k body // // swagger:model PostSilencesOKBody type PostSilencesOKBody struct { // silence ID SilenceID string `json:"silenceID,omitempty"` } // Validate validates this post silences o k body func (o *PostSilencesOKBody) Validate(formats strfmt.Registry) error { return nil } // ContextValidate validates this post silences o k body based on context it is used func (o *PostSilencesOKBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { return nil } // MarshalBinary interface implementation func (o *PostSilencesOKBody) MarshalBinary() ([]byte, error) { if o == nil { return nil, nil } return swag.WriteJSON(o) } // UnmarshalBinary interface implementation func (o *PostSilencesOKBody) UnmarshalBinary(b []byte) error { var res PostSilencesOKBody if err := swag.ReadJSON(b, &res); err != nil { return err } *o = res return nil } ================================================ FILE: api/v2/restapi/operations/silence/post_silences_parameters.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( stderrors "errors" "io" "net/http" "github.com/go-openapi/errors" "github.com/go-openapi/runtime" "github.com/go-openapi/runtime/middleware" "github.com/go-openapi/validate" "github.com/prometheus/alertmanager/api/v2/models" ) // NewPostSilencesParams creates a new PostSilencesParams object // // There are no default values defined in the spec. func NewPostSilencesParams() PostSilencesParams { return PostSilencesParams{} } // PostSilencesParams contains all the bound params for the post silences operation // typically these are obtained from a http.Request // // swagger:parameters postSilences type PostSilencesParams struct { // HTTP Request Object HTTPRequest *http.Request `json:"-"` /*The silence to create Required: true In: body */ Silence *models.PostableSilence } // BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface // for simple values it will use straight method calls. // // To ensure default values, the struct must have been initialized with NewPostSilencesParams() beforehand. func (o *PostSilencesParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { var res []error o.HTTPRequest = r if runtime.HasBody(r) { defer func() { _ = r.Body.Close() }() var body models.PostableSilence if err := route.Consumer.Consume(r.Body, &body); err != nil { if stderrors.Is(err, io.EOF) { res = append(res, errors.Required("silence", "body", "")) } else { res = append(res, errors.NewParseError("silence", "body", "", err)) } } else { // validate body object if err := body.Validate(route.Formats); err != nil { res = append(res, err) } ctx := validate.WithOperationRequest(r.Context()) if err := body.ContextValidate(ctx, route.Formats); err != nil { res = append(res, err) } if len(res) == 0 { o.Silence = &body } } } else { res = append(res, errors.Required("silence", "body", "")) } if len(res) > 0 { return errors.CompositeValidationError(res...) } return nil } ================================================ FILE: api/v2/restapi/operations/silence/post_silences_responses.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the swagger generate command import ( "net/http" "github.com/go-openapi/runtime" ) // PostSilencesOKCode is the HTTP code returned for type PostSilencesOK const PostSilencesOKCode int = 200 /* PostSilencesOK Create / update silence response swagger:response postSilencesOK */ type PostSilencesOK struct { /* In: Body */ Payload *PostSilencesOKBody `json:"body,omitempty"` } // NewPostSilencesOK creates PostSilencesOK with default headers values func NewPostSilencesOK() *PostSilencesOK { return &PostSilencesOK{} } // WithPayload adds the payload to the post silences o k response func (o *PostSilencesOK) WithPayload(payload *PostSilencesOKBody) *PostSilencesOK { o.Payload = payload return o } // SetPayload sets the payload to the post silences o k response func (o *PostSilencesOK) SetPayload(payload *PostSilencesOKBody) { o.Payload = payload } // WriteResponse to the client func (o *PostSilencesOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(200) if o.Payload != nil { payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } } // PostSilencesBadRequestCode is the HTTP code returned for type PostSilencesBadRequest const PostSilencesBadRequestCode int = 400 /* PostSilencesBadRequest Bad request swagger:response postSilencesBadRequest */ type PostSilencesBadRequest struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewPostSilencesBadRequest creates PostSilencesBadRequest with default headers values func NewPostSilencesBadRequest() *PostSilencesBadRequest { return &PostSilencesBadRequest{} } // WithPayload adds the payload to the post silences bad request response func (o *PostSilencesBadRequest) WithPayload(payload string) *PostSilencesBadRequest { o.Payload = payload return o } // SetPayload sets the payload to the post silences bad request response func (o *PostSilencesBadRequest) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *PostSilencesBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(400) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } // PostSilencesNotFoundCode is the HTTP code returned for type PostSilencesNotFound const PostSilencesNotFoundCode int = 404 /* PostSilencesNotFound A silence with the specified ID was not found swagger:response postSilencesNotFound */ type PostSilencesNotFound struct { /* In: Body */ Payload string `json:"body,omitempty"` } // NewPostSilencesNotFound creates PostSilencesNotFound with default headers values func NewPostSilencesNotFound() *PostSilencesNotFound { return &PostSilencesNotFound{} } // WithPayload adds the payload to the post silences not found response func (o *PostSilencesNotFound) WithPayload(payload string) *PostSilencesNotFound { o.Payload = payload return o } // SetPayload sets the payload to the post silences not found response func (o *PostSilencesNotFound) SetPayload(payload string) { o.Payload = payload } // WriteResponse to the client func (o *PostSilencesNotFound) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { rw.WriteHeader(404) payload := o.Payload if err := producer.Produce(rw, payload); err != nil { panic(err) // let the recovery middleware deal with this } } ================================================ FILE: api/v2/restapi/operations/silence/post_silences_urlbuilder.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package silence // This file was generated by the swagger tool. // Editing this file might prove futile when you re-run the generate command import ( "errors" "net/url" golangswaggerpaths "path" ) // PostSilencesURL generates an URL for the post silences operation type PostSilencesURL struct { _basePath string } // WithBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *PostSilencesURL) WithBasePath(bp string) *PostSilencesURL { o.SetBasePath(bp) return o } // SetBasePath sets the base path for this url builder, only required when it's different from the // base path specified in the swagger spec. // When the value of the base path is an empty string func (o *PostSilencesURL) SetBasePath(bp string) { o._basePath = bp } // Build a url path and query string func (o *PostSilencesURL) Build() (*url.URL, error) { var _result url.URL var _path = "/silences" _basePath := o._basePath if _basePath == "" { _basePath = "/api/v2/" } _result.Path = golangswaggerpaths.Join(_basePath, _path) return &_result, nil } // Must is a helper function to panic when the url builder returns an error func (o *PostSilencesURL) Must(u *url.URL, err error) *url.URL { if err != nil { panic(err) } if u == nil { panic("url can't be nil") } return u } // String returns the string representation of the path with query string func (o *PostSilencesURL) String() string { return o.Must(o.Build()).String() } // BuildFull builds a full url with scheme, host, path and query string func (o *PostSilencesURL) BuildFull(scheme, host string) (*url.URL, error) { if scheme == "" { return nil, errors.New("scheme is required for a full url on PostSilencesURL") } if host == "" { return nil, errors.New("host is required for a full url on PostSilencesURL") } base, err := o.Build() if err != nil { return nil, err } base.Scheme = scheme base.Host = host return base, nil } // StringFull returns the string representation of a complete url func (o *PostSilencesURL) StringFull(scheme, host string) string { return o.Must(o.BuildFull(scheme, host)).String() } ================================================ FILE: api/v2/restapi/server.go ================================================ // Code generated by go-swagger; DO NOT EDIT. // Copyright Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package restapi import ( "context" "crypto/tls" "crypto/x509" "errors" "log" "net" "net/http" "os" "os/signal" "strconv" "sync" "sync/atomic" "syscall" "time" flags "github.com/jessevdk/go-flags" "golang.org/x/net/netutil" "github.com/go-openapi/runtime/flagext" "github.com/go-openapi/swag" "github.com/prometheus/alertmanager/api/v2/restapi/operations" ) const ( schemeHTTP = "http" schemeHTTPS = "https" schemeUnix = "unix" ) var defaultSchemes []string func init() { defaultSchemes = []string{ schemeHTTP, } } // NewServer creates a new api alertmanager server but does not configure it func NewServer(api *operations.AlertmanagerAPI) *Server { s := new(Server) s.shutdown = make(chan struct{}) s.api = api s.interrupt = make(chan os.Signal, 1) return s } // ConfigureAPI configures the API and handlers. func (s *Server) ConfigureAPI() { if s.api != nil { s.handler = configureAPI(s.api) } } // ConfigureFlags configures the additional flags defined by the handlers. Needs to be called before the parser.Parse func (s *Server) ConfigureFlags() { if s.api != nil { configureFlags(s.api) } } // Server for the alertmanager API type Server struct { EnabledListeners []string `long:"scheme" description:"the listeners to enable, this can be repeated and defaults to the schemes in the swagger spec"` CleanupTimeout time.Duration `long:"cleanup-timeout" description:"grace period for which to wait before killing idle connections" default:"10s"` GracefulTimeout time.Duration `long:"graceful-timeout" description:"grace period for which to wait before shutting down the server" default:"15s"` MaxHeaderSize flagext.ByteSize `long:"max-header-size" description:"controls the maximum number of bytes the server will read parsing the request header's keys and values, including the request line. It does not limit the size of the request body." default:"1MiB"` SocketPath flags.Filename `long:"socket-path" description:"the unix socket to listen on" default:"/var/run/alertmanager.sock"` domainSocketL net.Listener Host string `long:"host" description:"the IP to listen on" default:"localhost" env:"HOST"` Port int `long:"port" description:"the port to listen on for insecure connections, defaults to a random value" env:"PORT"` ListenLimit int `long:"listen-limit" description:"limit the number of outstanding requests"` KeepAlive time.Duration `long:"keep-alive" description:"sets the TCP keep-alive timeouts on accepted connections. It prunes dead TCP connections ( e.g. closing laptop mid-download)" default:"3m"` ReadTimeout time.Duration `long:"read-timeout" description:"maximum duration before timing out read of the request" default:"30s"` WriteTimeout time.Duration `long:"write-timeout" description:"maximum duration before timing out write of the response" default:"30s"` httpServerL net.Listener TLSHost string `long:"tls-host" description:"the IP to listen on for tls, when not specified it's the same as --host" env:"TLS_HOST"` TLSPort int `long:"tls-port" description:"the port to listen on for secure connections, defaults to a random value" env:"TLS_PORT"` TLSCertificate flags.Filename `long:"tls-certificate" description:"the certificate to use for secure connections" env:"TLS_CERTIFICATE"` TLSCertificateKey flags.Filename `long:"tls-key" description:"the private key to use for secure connections" env:"TLS_PRIVATE_KEY"` TLSCACertificate flags.Filename `long:"tls-ca" description:"the certificate authority file to be used with mutual tls auth" env:"TLS_CA_CERTIFICATE"` TLSListenLimit int `long:"tls-listen-limit" description:"limit the number of outstanding requests"` TLSKeepAlive time.Duration `long:"tls-keep-alive" description:"sets the TCP keep-alive timeouts on accepted connections. It prunes dead TCP connections ( e.g. closing laptop mid-download)"` TLSReadTimeout time.Duration `long:"tls-read-timeout" description:"maximum duration before timing out read of the request"` TLSWriteTimeout time.Duration `long:"tls-write-timeout" description:"maximum duration before timing out write of the response"` httpsServerL net.Listener api *operations.AlertmanagerAPI handler http.Handler hasListeners bool shutdown chan struct{} shuttingDown int32 interrupted bool interrupt chan os.Signal } // Logf logs message either via defined user logger or via system one if no user logger is defined. func (s *Server) Logf(f string, args ...any) { if s.api != nil && s.api.Logger != nil { s.api.Logger(f, args...) } else { log.Printf(f, args...) } } // Fatalf logs message either via defined user logger or via system one if no user logger is defined. // Exits with non-zero status after printing func (s *Server) Fatalf(f string, args ...any) { if s.api != nil && s.api.Logger != nil { s.api.Logger(f, args...) os.Exit(1) } else { log.Fatalf(f, args...) } } // SetAPI configures the server with the specified API. Needs to be called before Serve func (s *Server) SetAPI(api *operations.AlertmanagerAPI) { if api == nil { s.api = nil s.handler = nil return } s.api = api s.handler = configureAPI(api) } func (s *Server) hasScheme(scheme string) bool { schemes := s.EnabledListeners if len(schemes) == 0 { schemes = defaultSchemes } for _, v := range schemes { if v == scheme { return true } } return false } // Serve the api func (s *Server) Serve() (err error) { if !s.hasListeners { if err = s.Listen(); err != nil { return err } } // set default handler, if none is set if s.handler == nil { if s.api == nil { return errors.New("can't create the default handler, as no api is set") } s.SetHandler(s.api.Serve(nil)) } wg := new(sync.WaitGroup) once := new(sync.Once) signalNotify(s.interrupt) go handleInterrupt(once, s) servers := []*http.Server{} if s.hasScheme(schemeUnix) { domainSocket := new(http.Server) domainSocket.MaxHeaderBytes = int(s.MaxHeaderSize) domainSocket.Handler = s.handler if int64(s.CleanupTimeout) > 0 { domainSocket.IdleTimeout = s.CleanupTimeout } configureServer(domainSocket, "unix", string(s.SocketPath)) servers = append(servers, domainSocket) wg.Add(1) s.Logf("Serving alertmanager at unix://%s", s.SocketPath) go func(l net.Listener) { defer wg.Done() if errServe := domainSocket.Serve(l); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) { s.Fatalf("%v", errServe) } s.Logf("Stopped serving alertmanager at unix://%s", s.SocketPath) }(s.domainSocketL) } if s.hasScheme(schemeHTTP) { httpServer := new(http.Server) httpServer.MaxHeaderBytes = int(s.MaxHeaderSize) httpServer.ReadTimeout = s.ReadTimeout httpServer.WriteTimeout = s.WriteTimeout httpServer.SetKeepAlivesEnabled(int64(s.KeepAlive) > 0) if s.ListenLimit > 0 { s.httpServerL = netutil.LimitListener(s.httpServerL, s.ListenLimit) } if int64(s.CleanupTimeout) > 0 { httpServer.IdleTimeout = s.CleanupTimeout } httpServer.Handler = s.handler configureServer(httpServer, "http", s.httpServerL.Addr().String()) servers = append(servers, httpServer) wg.Add(1) s.Logf("Serving alertmanager at http://%s", s.httpServerL.Addr()) go func(l net.Listener) { defer wg.Done() if errServe := httpServer.Serve(l); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) { s.Fatalf("%v", errServe) } s.Logf("Stopped serving alertmanager at http://%s", l.Addr()) }(s.httpServerL) } if s.hasScheme(schemeHTTPS) { httpsServer := new(http.Server) httpsServer.MaxHeaderBytes = int(s.MaxHeaderSize) httpsServer.ReadTimeout = s.TLSReadTimeout httpsServer.WriteTimeout = s.TLSWriteTimeout httpsServer.SetKeepAlivesEnabled(int64(s.TLSKeepAlive) > 0) if s.TLSListenLimit > 0 { s.httpsServerL = netutil.LimitListener(s.httpsServerL, s.TLSListenLimit) } if int64(s.CleanupTimeout) > 0 { httpsServer.IdleTimeout = s.CleanupTimeout } httpsServer.Handler = s.handler // Inspired by https://blog.bracebin.com/achieving-perfect-ssl-labs-score-with-go httpsServer.TLSConfig = &tls.Config{ // Causes servers to use Go's default ciphersuite preferences, // which are tuned to avoid attacks. Does nothing on clients. PreferServerCipherSuites: true, // Only use curves which have assembly implementations // https://github.com/golang/go/tree/master/src/crypto/elliptic CurvePreferences: []tls.CurveID{tls.CurveP256}, // Use modern tls mode https://wiki.mozilla.org/Security/Server_Side_TLS#Modern_compatibility NextProtos: []string{"h2", "http/1.1"}, // https://www.owasp.org/index.php/Transport_Layer_Protection_Cheat_Sheet#Rule_-_Only_Support_Strong_Protocols MinVersion: tls.VersionTLS12, // These ciphersuites support Forward Secrecy: https://en.wikipedia.org/wiki/Forward_secrecy CipherSuites: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, }, } // build standard config from server options if s.TLSCertificate != "" && s.TLSCertificateKey != "" { httpsServer.TLSConfig.Certificates = make([]tls.Certificate, 1) httpsServer.TLSConfig.Certificates[0], err = tls.LoadX509KeyPair(string(s.TLSCertificate), string(s.TLSCertificateKey)) if err != nil { return err } } if s.TLSCACertificate != "" { // include specified CA certificate caCert, caCertErr := os.ReadFile(string(s.TLSCACertificate)) if caCertErr != nil { return caCertErr } caCertPool := x509.NewCertPool() ok := caCertPool.AppendCertsFromPEM(caCert) if !ok { return errors.New("cannot parse CA certificate") } httpsServer.TLSConfig.ClientCAs = caCertPool httpsServer.TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert } // call custom TLS configurator configureTLS(httpsServer.TLSConfig) if len(httpsServer.TLSConfig.Certificates) == 0 && httpsServer.TLSConfig.GetCertificate == nil { // after standard and custom config are passed, this ends up with no certificate if s.TLSCertificate == "" { if s.TLSCertificateKey == "" { s.Fatalf("the required flags `--tls-certificate` and `--tls-key` were not specified") } s.Fatalf("the required flag `--tls-certificate` was not specified") } if s.TLSCertificateKey == "" { s.Fatalf("the required flag `--tls-key` was not specified") } // this happens with a wrong custom TLS configurator s.Fatalf("no certificate was configured for TLS") } configureServer(httpsServer, "https", s.httpsServerL.Addr().String()) servers = append(servers, httpsServer) wg.Add(1) s.Logf("Serving alertmanager at https://%s", s.httpsServerL.Addr()) go func(l net.Listener) { defer wg.Done() if errServe := httpsServer.Serve(l); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) { s.Fatalf("%v", errServe) } s.Logf("Stopped serving alertmanager at https://%s", l.Addr()) }(tls.NewListener(s.httpsServerL, httpsServer.TLSConfig)) } wg.Add(1) go s.handleShutdown(wg, &servers) wg.Wait() return nil } // Listen creates the listeners for the server func (s *Server) Listen() error { if s.hasListeners { // already done this return nil } if s.hasScheme(schemeHTTPS) { // Use http host if https host wasn't defined if s.TLSHost == "" { s.TLSHost = s.Host } // Use http listen limit if https listen limit wasn't defined if s.TLSListenLimit == 0 { s.TLSListenLimit = s.ListenLimit } // Use http tcp keep alive if https tcp keep alive wasn't defined if int64(s.TLSKeepAlive) == 0 { s.TLSKeepAlive = s.KeepAlive } // Use http read timeout if https read timeout wasn't defined if int64(s.TLSReadTimeout) == 0 { s.TLSReadTimeout = s.ReadTimeout } // Use http write timeout if https write timeout wasn't defined if int64(s.TLSWriteTimeout) == 0 { s.TLSWriteTimeout = s.WriteTimeout } } if s.hasScheme(schemeUnix) { domSockListener, err := net.Listen("unix", string(s.SocketPath)) if err != nil { return err } s.domainSocketL = domSockListener } if s.hasScheme(schemeHTTP) { listener, err := net.Listen("tcp", net.JoinHostPort(s.Host, strconv.Itoa(s.Port))) if err != nil { return err } h, p, err := swag.SplitHostPort(listener.Addr().String()) if err != nil { return err } s.Host = h s.Port = p s.httpServerL = listener } if s.hasScheme(schemeHTTPS) { tlsListener, err := net.Listen("tcp", net.JoinHostPort(s.TLSHost, strconv.Itoa(s.TLSPort))) if err != nil { return err } sh, sp, err := swag.SplitHostPort(tlsListener.Addr().String()) if err != nil { return err } s.TLSHost = sh s.TLSPort = sp s.httpsServerL = tlsListener } s.hasListeners = true return nil } // Shutdown server and clean up resources func (s *Server) Shutdown() error { if atomic.CompareAndSwapInt32(&s.shuttingDown, 0, 1) { close(s.shutdown) } return nil } func (s *Server) handleShutdown(wg *sync.WaitGroup, serversPtr *[]*http.Server) { // wg.Done must occur last, after s.api.ServerShutdown() // (to preserve old behaviour) defer wg.Done() <-s.shutdown servers := *serversPtr ctx, cancel := context.WithTimeout(context.TODO(), s.GracefulTimeout) defer cancel() // first execute the pre-shutdown hook s.api.PreServerShutdown() shutdownChan := make(chan bool) for i := range servers { server := servers[i] go func() { var success bool defer func() { shutdownChan <- success }() if err := server.Shutdown(ctx); err != nil { // Error from closing listeners, or context timeout: s.Logf("HTTP server Shutdown: %v", err) } else { success = true } }() } // Wait until all listeners have successfully shut down before calling ServerShutdown success := true for range servers { success = success && <-shutdownChan } if success { s.api.ServerShutdown() } } // GetHandler returns a handler useful for testing func (s *Server) GetHandler() http.Handler { return s.handler } // SetHandler allows for setting a http handler on this server func (s *Server) SetHandler(handler http.Handler) { s.handler = handler } // UnixListener returns the domain socket listener func (s *Server) UnixListener() (net.Listener, error) { if !s.hasListeners { if err := s.Listen(); err != nil { return nil, err } } return s.domainSocketL, nil } // HTTPListener returns the http listener func (s *Server) HTTPListener() (net.Listener, error) { if !s.hasListeners { if err := s.Listen(); err != nil { return nil, err } } return s.httpServerL, nil } // TLSListener returns the https listener func (s *Server) TLSListener() (net.Listener, error) { if !s.hasListeners { if err := s.Listen(); err != nil { return nil, err } } return s.httpsServerL, nil } func handleInterrupt(once *sync.Once, s *Server) { once.Do(func() { for range s.interrupt { if s.interrupted { s.Logf("Server already shutting down") continue } s.interrupted = true s.Logf("Shutting down... ") if err := s.Shutdown(); err != nil { s.Logf("HTTP server Shutdown: %v", err) } } }) } func signalNotify(interrupt chan<- os.Signal) { signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) } ================================================ FILE: api/v2/testing.go ================================================ // Copyright 2022 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package v2 import ( "testing" "time" "github.com/go-openapi/strfmt" open_api_models "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/alertmanager/silence/silencepb" ) func createSilence(t *testing.T, ID, creator string, start, ends time.Time) open_api_models.PostableSilence { t.Helper() comment := "test" matcherName := "a" matcherValue := "b" isRegex := false startsAt := strfmt.DateTime(start) endsAt := strfmt.DateTime(ends) sil := open_api_models.PostableSilence{ ID: ID, Silence: open_api_models.Silence{ Matchers: open_api_models.Matchers{&open_api_models.Matcher{Name: &matcherName, Value: &matcherValue, IsRegex: &isRegex}}, StartsAt: &startsAt, EndsAt: &endsAt, CreatedBy: &creator, Comment: &comment, }, } return sil } func createSilenceMatcher(t *testing.T, name, pattern string, matcherType silencepb.Matcher_Type) *silencepb.Matcher { t.Helper() return &silencepb.Matcher{ Name: name, Pattern: pattern, Type: matcherType, } } func createLabelMatcher(t *testing.T, name, value string, matchType labels.MatchType) *labels.Matcher { t.Helper() matcher, _ := labels.NewMatcher(matchType, name, value) return matcher } ================================================ FILE: buf.gen.yaml ================================================ version: v2 plugins: - local: ['go', 'tool', '-modfile=internal/tools/go.mod', 'protoc-gen-go'] out: . opt: - module=github.com/prometheus/alertmanager ================================================ FILE: buf.yaml ================================================ # For details on buf.yaml configuration, visit https://buf.build/docs/configuration/v2/buf-yaml version: v2 modules: - path: nflog/nflogpb name: prometheus/alertmanager/nflog - path: cluster/clusterpb name: prometheus/alertmanager/cluster - path: silence/silencepb name: prometheus/alertmanager/silence ================================================ FILE: cli/alert.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "github.com/alecthomas/kingpin/v2" ) func configureAlertCmd(app *kingpin.Application) { alertCmd := app.Command("alert", "Add or query alerts.").PreAction(requireAlertManagerURL) configureQueryAlertsCmd(alertCmd) configureAddAlertCmd(alertCmd) } ================================================ FILE: cli/alert_add.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" "fmt" "strconv" "time" "github.com/alecthomas/kingpin/v2" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/client/alert" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/pkg/labels" ) type alertAddCmd struct { annotations []string generatorURL string labels []string start string end string } const alertAddHelp = `Add a new alert. This command is used to add a new alert to Alertmanager. To add a new alert with labels: amtool alert add alertname=foo node=bar If alertname is omitted and the first argument does not contain a '=' then it will be assumed to be the value of the alertname pair. amtool alert add foo node=bar One or more annotations can be added using the --annotation flag: amtool alert add foo node=bar \ --annotation=runbook='http://runbook.biz' \ --annotation=summary='summary of the alert' \ --annotation=description='description of the alert' Additional flags such as --generator-url, --start, and --end are also supported. ` func configureAddAlertCmd(cc *kingpin.CmdClause) { var ( a = &alertAddCmd{} addCmd = cc.Command("add", alertAddHelp) ) addCmd.Arg("labels", "List of labels to be included with the alert").StringsVar(&a.labels) addCmd.Flag("generator-url", "Set the URL of the source that generated the alert").StringVar(&a.generatorURL) addCmd.Flag("start", "Set when the alert should start. RFC3339 format 2006-01-02T15:04:05-07:00").StringVar(&a.start) addCmd.Flag("end", "Set when the alert should end. RFC3339 format 2006-01-02T15:04:05-07:00").StringVar(&a.end) addCmd.Flag("annotation", "Set an annotation to be included with the alert").StringsVar(&a.annotations) addCmd.Action(execWithTimeout(a.addAlert)) } func (a *alertAddCmd) addAlert(ctx context.Context, _ *kingpin.ParseContext) error { if len(a.labels) > 0 { // Allow the alertname label to be defined implicitly as the first argument rather // than explicitly as a key=value pair. if _, err := compat.Matcher(a.labels[0], "cli"); err != nil { a.labels[0] = fmt.Sprintf("alertname=%s", strconv.Quote(a.labels[0])) } } ls := make(models.LabelSet, len(a.labels)) for _, l := range a.labels { matcher, err := compat.Matcher(l, "cli") if err != nil { return err } if matcher.Type != labels.MatchEqual { return errors.New("labels must be specified as key=value pairs") } ls[matcher.Name] = matcher.Value } annotations := make(models.LabelSet, len(a.annotations)) for _, a := range a.annotations { matcher, err := compat.Matcher(a, "cli") if err != nil { return err } if matcher.Type != labels.MatchEqual { return errors.New("annotations must be specified as key=value pairs") } annotations[matcher.Name] = matcher.Value } var startsAt, endsAt time.Time if a.start != "" { var err error startsAt, err = time.Parse(time.RFC3339, a.start) if err != nil { return err } } if a.end != "" { var err error endsAt, err = time.Parse(time.RFC3339, a.end) if err != nil { return err } } pa := &models.PostableAlert{ Alert: models.Alert{ GeneratorURL: strfmt.URI(a.generatorURL), Labels: ls, }, Annotations: annotations, StartsAt: strfmt.DateTime(startsAt), EndsAt: strfmt.DateTime(endsAt), } alertParams := alert.NewPostAlertsParams().WithContext(ctx). WithAlerts(models.PostableAlerts{pa}) amclient := NewAlertmanagerClient(alertmanagerURL) _, err := amclient.Alert.PostAlerts(alertParams) return err } ================================================ FILE: cli/alert_query.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" "fmt" "strconv" "github.com/alecthomas/kingpin/v2" "github.com/prometheus/alertmanager/api/v2/client/alert" "github.com/prometheus/alertmanager/cli/format" "github.com/prometheus/alertmanager/matcher/compat" ) type alertQueryCmd struct { inhibited, silenced, active, unprocessed bool receiver string matcherGroups []string } const alertQueryHelp = `View and search through current alerts. Amtool has a simplified prometheus query syntax, but contains robust support for bash variable expansions. The non-option section of arguments constructs a list of "Matcher Groups" that will be used to filter your query. The following examples will attempt to show this behaviour in action: amtool alert query alertname=foo node=bar This query will match all alerts with the alertname=foo and node=bar label value pairs set. amtool alert query foo node=bar If alertname is omitted and the first argument does not contain a '=' or a '=~' then it will be assumed to be the value of the alertname pair. amtool alert query 'alertname=~foo.*' As well as direct equality, regex matching is also supported. The '=~' syntax (similar to prometheus) is used to represent a regex match. Regex matching can be used in combination with a direct match. Amtool supports several flags for filtering the returned alerts by state (inhibited, silenced, active, unprocessed). If none of these flags is given, only active alerts are returned. ` func configureQueryAlertsCmd(cc *kingpin.CmdClause) { var ( a = &alertQueryCmd{} queryCmd = cc.Command("query", alertQueryHelp).Default() ) queryCmd.Flag("inhibited", "Show inhibited alerts").Short('i').BoolVar(&a.inhibited) queryCmd.Flag("silenced", "Show silenced alerts").Short('s').BoolVar(&a.silenced) queryCmd.Flag("active", "Show active alerts").Short('a').BoolVar(&a.active) queryCmd.Flag("unprocessed", "Show unprocessed alerts").Short('u').BoolVar(&a.unprocessed) queryCmd.Flag("receiver", "Show alerts matching receiver (Supports regex syntax)").Short('r').StringVar(&a.receiver) queryCmd.Arg("matcher-groups", "Query filter").StringsVar(&a.matcherGroups) queryCmd.Action(execWithTimeout(a.queryAlerts)) } func (a *alertQueryCmd) queryAlerts(ctx context.Context, _ *kingpin.ParseContext) error { if len(a.matcherGroups) > 0 { // Attempt to parse the first argument. If the parser fails // then we likely don't have a (=|=~|!=|!~) so lets assume that // the user wants alertname= and prepend `alertname=` to // the front. m := a.matcherGroups[0] _, err := compat.Matcher(m, "cli") if err != nil { a.matcherGroups[0] = fmt.Sprintf("alertname=%s", strconv.Quote(m)) } } // If no selector was passed, default to showing active alerts. if !a.silenced && !a.inhibited && !a.active && !a.unprocessed { a.active = true } alertParams := alert.NewGetAlertsParams().WithContext(ctx). WithActive(&a.active). WithInhibited(&a.inhibited). WithSilenced(&a.silenced). WithUnprocessed(&a.unprocessed). WithReceiver(&a.receiver). WithFilter(a.matcherGroups) amclient := NewAlertmanagerClient(alertmanagerURL) getOk, err := amclient.Alert.GetAlerts(alertParams) if err != nil { return err } formatter, found := format.Formatters[output] if !found { return errors.New("unknown output formatter") } return formatter.FormatAlerts(getOk.Payload) } ================================================ FILE: cli/check_config.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "fmt" "os" "github.com/alecthomas/kingpin/v2" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/template" ) // TODO: This can just be a type that is []string, doesn't have to be a struct. type checkConfigCmd struct { files []string } const checkConfigHelp = `Validate alertmanager config files Will validate the syntax and schema for alertmanager config file and associated templates. Non existing templates will not trigger errors. ` func configureCheckConfigCmd(app *kingpin.Application) { var ( c = &checkConfigCmd{} checkCmd = app.Command("check-config", checkConfigHelp) ) checkCmd.Arg("check-files", "Files to be validated").ExistingFilesVar(&c.files) checkCmd.Action(c.checkConfig) } func (c *checkConfigCmd) checkConfig(ctx *kingpin.ParseContext) error { return CheckConfig(c.files) } func CheckConfig(args []string) error { if len(args) == 0 { stat, err := os.Stdin.Stat() if err != nil { kingpin.Fatalf("Failed to stat standard input: %v", err) } if (stat.Mode() & os.ModeCharDevice) != 0 { kingpin.Fatalf("Failed to read from standard input") } args = []string{os.Stdin.Name()} } failed := 0 for _, arg := range args { fmt.Printf("Checking '%s'", arg) cfg, err := config.LoadFile(arg) if err != nil { fmt.Printf(" FAILED: %s\n", err) failed++ } else { fmt.Printf(" SUCCESS\n") } if cfg != nil { fmt.Println("Found:") if cfg.Global != nil { fmt.Println(" - global config") } if cfg.Route != nil { fmt.Println(" - route") } fmt.Printf(" - %d inhibit rules\n", len(cfg.InhibitRules)) fmt.Printf(" - %d receivers\n", len(cfg.Receivers)) fmt.Printf(" - %d templates\n", len(cfg.Templates)) if len(cfg.Templates) > 0 { _, err = template.FromGlobs(cfg.Templates) if err != nil { fmt.Printf(" FAILED: %s\n", err) failed++ } else { fmt.Printf(" SUCCESS\n") } } } fmt.Printf("\n") } if failed > 0 { return fmt.Errorf("failed to validate %d file(s)", failed) } return nil } ================================================ FILE: cli/check_config_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "testing" ) func TestCheckConfig(t *testing.T) { err := CheckConfig([]string{"testdata/conf.good.yml"}) if err != nil { t.Fatalf("checking valid config file failed with: %v", err) } err = CheckConfig([]string{"testdata/conf.bad.yml"}) if err == nil { t.Fatalf("failed to detect invalid file.") } } ================================================ FILE: cli/cluster.go ================================================ // Copyright 2020 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" "github.com/alecthomas/kingpin/v2" "github.com/prometheus/alertmanager/cli/format" ) const clusterHelp = `View cluster status and peers.` // configureClusterCmd represents the cluster command. func configureClusterCmd(app *kingpin.Application) { clusterCmd := app.Command("cluster", clusterHelp) clusterCmd.Command("show", clusterHelp).Default().Action(execWithTimeout(showStatus)).PreAction(requireAlertManagerURL) } func showStatus(ctx context.Context, _ *kingpin.ParseContext) error { alertManagerStatus, err := getRemoteAlertmanagerConfigStatus(ctx, alertmanagerURL) if err != nil { return err } formatter, found := format.Formatters[output] if !found { return errors.New("unknown output formatter") } return formatter.FormatClusterStatus(alertManagerStatus.Cluster) } ================================================ FILE: cli/config/config.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "os" "github.com/alecthomas/kingpin/v2" "gopkg.in/yaml.v2" ) type getFlagger interface { GetFlag(name string) *kingpin.FlagClause } // Resolver represents a configuration file resolver for kingpin. type Resolver struct { flags map[string]string } // NewResolver returns a Resolver structure. func NewResolver(files []string, legacyFlags map[string]string) (*Resolver, error) { flags := map[string]string{} for _, f := range files { if _, err := os.Stat(f); err != nil { continue } b, err := os.ReadFile(f) if err != nil { if os.IsNotExist(err) { continue } return nil, err } var m map[string]string err = yaml.Unmarshal(b, &m) if err != nil { return nil, err } for k, v := range m { if flag, ok := legacyFlags[k]; ok { if _, ok := m[flag]; ok { continue } k = flag } if _, ok := flags[k]; !ok { flags[k] = v } } } return &Resolver{flags: flags}, nil } func (c *Resolver) setDefault(v getFlagger) { for name, value := range c.flags { f := v.GetFlag(name) if f != nil { f.Default(value) } } } // Bind sets active flags with their default values from the configuration file(s). func (c *Resolver) Bind(app *kingpin.Application, args []string) error { // Parse the command line arguments to get the selected command. pc, err := app.ParseContext(args) if err != nil { return err } c.setDefault(app) if pc.SelectedCommand != nil { c.setDefault(pc.SelectedCommand) } return nil } ================================================ FILE: cli/config/config_test.go ================================================ // Copyright 2015 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "io" "testing" "github.com/alecthomas/kingpin/v2" ) var ( url *string id *string ) func newApp() *kingpin.Application { url = new(string) id = new(string) app := kingpin.New("app", "") app.UsageWriter(io.Discard) app.ErrorWriter(io.Discard) app.Terminate(nil) app.Flag("url", "").StringVar(url) silence := app.Command("silence", "") silenceDel := silence.Command("del", "") silenceDel.Flag("id", "").StringVar(id) return app } func TestNewConfigResolver(t *testing.T) { for i, tc := range []struct { files []string err bool }{ {[]string{}, false}, {[]string{"testdata/amtool.good1.yml", "testdata/amtool.good2.yml"}, false}, {[]string{"testdata/amtool.good1.yml", "testdata/not_existing.yml"}, false}, {[]string{"testdata/amtool.good1.yml", "testdata/amtool.bad.yml"}, true}, } { _, err := NewResolver(tc.files, nil) if tc.err != (err != nil) { if tc.err { t.Fatalf("%d: expected error but got none", i) } else { t.Fatalf("%d: expected no error but got %v", i, err) } } } } type expectFn func() func TestConfigResolverBind(t *testing.T) { expectURL := func(expected string) expectFn { return func() { if *url != expected { t.Fatalf("expected url flag %q but got %q", expected, *url) } } } expectID := func(expected string) expectFn { return func() { if *id != expected { t.Fatalf("expected ID flag %q but got %q", expected, *id) } } } for i, tc := range []struct { files []string legacyFlags map[string]string args []string err bool expCmd string expFns []expectFn }{ { []string{"testdata/amtool.good1.yml", "testdata/amtool.good2.yml"}, nil, []string{}, true, "", []expectFn{expectURL("url1")}, // from amtool.good1.yml }, { []string{"testdata/amtool.good2.yml"}, nil, []string{}, true, "", []expectFn{expectURL("url2")}, // from amtool.good2.yml }, { []string{"testdata/amtool.good1.yml", "testdata/amtool.good2.yml"}, nil, []string{"--url", "url3"}, true, "", []expectFn{expectURL("url3")}, // from command line }, { []string{"testdata/amtool.good1.yml", "testdata/amtool.good2.yml"}, map[string]string{"old-id": "id"}, []string{"silence", "del"}, false, "silence del", []expectFn{ expectURL("url1"), // from amtool.good1.yml expectID("id1"), // from amtool.good1.yml }, }, { []string{"testdata/amtool.good2.yml"}, map[string]string{"old-id": "id"}, []string{"silence", "del"}, false, "silence del", []expectFn{ expectURL("url2"), // from amtool.good2.yml expectID("id2"), // from amtool.good2.yml }, }, { []string{"testdata/amtool.good2.yml"}, map[string]string{"old-id": "id"}, []string{"silence", "del", "--id", "id3"}, false, "silence del", []expectFn{ expectURL("url2"), // from amtool.good2.yml expectID("id3"), // from command line }, }, } { r, err := NewResolver(tc.files, tc.legacyFlags) if err != nil { t.Fatalf("%d: expected no error but got: %v", i, err) } app := newApp() err = r.Bind(app, tc.args) if err != nil { t.Fatalf("%d: expected Bind() to return no error but got: %v", i, err) } cmd, err := app.Parse(tc.args) if tc.err != (err != nil) { if tc.err { t.Fatalf("%d: expected Parse() to return an error but got none", i) } else { t.Fatalf("%d: expected Parse() to return no error but got: %v", i, err) } } if cmd != tc.expCmd { t.Fatalf("%d: expected command %q but got %q", i, tc.expCmd, cmd) } for _, fn := range tc.expFns { fn() } } } ================================================ FILE: cli/config/http_config.go ================================================ // Copyright 2021 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "os" "path/filepath" promconfig "github.com/prometheus/common/config" "gopkg.in/yaml.v2" ) // LoadHTTPConfigFile returns HTTPClientConfig for the given http_config file. func LoadHTTPConfigFile(filename string) (*promconfig.HTTPClientConfig, error) { b, err := os.ReadFile(filename) if err != nil { return nil, err } httpConfig := &promconfig.HTTPClientConfig{} err = yaml.UnmarshalStrict(b, httpConfig) if err != nil { return nil, err } httpConfig.SetDirectory(filepath.Dir(filepath.Dir(filename))) return httpConfig, nil } ================================================ FILE: cli/config/testdata/amtool.bad.yml ================================================ BAD ================================================ FILE: cli/config/testdata/amtool.good1.yml ================================================ id: id1 url: url1 ================================================ FILE: cli/config/testdata/amtool.good2.yml ================================================ old-id: id2 url: url2 ================================================ FILE: cli/config/testdata/http_config.bad.yml ================================================ authorization: type: Basic ================================================ FILE: cli/config/testdata/http_config.basic_auth.good.yml ================================================ basic_auth: username: user password: password ================================================ FILE: cli/config/testdata/http_config.good.yml ================================================ authorization: type: Bearer credentials: theanswertothegreatquestionoflifetheuniverseandeverythingisfortytwo proxy_url: "http://remote.host" ================================================ FILE: cli/config.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" kingpin "github.com/alecthomas/kingpin/v2" "github.com/prometheus/alertmanager/cli/format" ) const configHelp = `View current config. The amount of output is controlled by the output selection flag: - Simple: Print just the running config - Extended: Print the running config as well as uptime and all version info - Json: Print entire config object as json ` // configureConfigCmd represents the config command. func configureConfigCmd(app *kingpin.Application) { configCmd := app.Command("config", configHelp) configCmd.Command("show", configHelp).Default().Action(execWithTimeout(queryConfig)).PreAction(requireAlertManagerURL) configureRoutingCmd(configCmd) } func queryConfig(ctx context.Context, _ *kingpin.ParseContext) error { status, err := getRemoteAlertmanagerConfigStatus(ctx, alertmanagerURL) if err != nil { return err } formatter, found := format.Formatters[output] if !found { return errors.New("unknown output formatter") } return formatter.FormatConfig(status) } ================================================ FILE: cli/format/format.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package format import ( "io" "time" "github.com/alecthomas/kingpin/v2" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/pkg/labels" ) const DefaultDateFormat = "2006-01-02 15:04:05 MST" var dateFormat *string func InitFormatFlags(app *kingpin.Application) { dateFormat = app.Flag("date.format", "Format of date output").Default(DefaultDateFormat).String() } // Formatter needs to be implemented for each new output formatter. type Formatter interface { SetOutput(io.Writer) FormatSilences([]models.GettableSilence) error FormatAlerts([]*models.GettableAlert) error FormatConfig(*models.AlertmanagerStatus) error FormatClusterStatus(status *models.ClusterStatus) error } // Formatters is a map of cli argument names to formatter interface object. var Formatters = map[string]Formatter{} func FormatDate(input strfmt.DateTime) string { return time.Time(input).Format(*dateFormat) } func labelsMatcher(m models.Matcher) *labels.Matcher { var t labels.MatchType // Support for older alertmanager releases, which did not support isEqual. if m.IsEqual == nil { isEqual := true m.IsEqual = &isEqual } switch { case !*m.IsRegex && *m.IsEqual: t = labels.MatchEqual case !*m.IsRegex && !*m.IsEqual: t = labels.MatchNotEqual case *m.IsRegex && *m.IsEqual: t = labels.MatchRegexp case *m.IsRegex && !*m.IsEqual: t = labels.MatchNotRegexp } return &labels.Matcher{Type: t, Name: *m.Name, Value: *m.Value} } ================================================ FILE: cli/format/format_extended.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package format import ( "fmt" "io" "os" "sort" "strings" "text/tabwriter" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/pkg/labels" ) type ExtendedFormatter struct { writer io.Writer } func init() { Formatters["extended"] = &ExtendedFormatter{writer: os.Stdout} } func (formatter *ExtendedFormatter) SetOutput(writer io.Writer) { formatter.writer = writer } // FormatSilences formats the silences into a readable string. func (formatter *ExtendedFormatter) FormatSilences(silences []models.GettableSilence) error { w := tabwriter.NewWriter(formatter.writer, 0, 0, 2, ' ', 0) sort.Sort(ByEndAt(silences)) fmt.Fprintln(w, "ID\tMatchers\tStarts At\tEnds At\tUpdated At\tCreated By\tComment\t") for _, silence := range silences { fmt.Fprintf( w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t\n", *silence.ID, extendedFormatMatchers(silence.Matchers), FormatDate(*silence.StartsAt), FormatDate(*silence.EndsAt), FormatDate(*silence.UpdatedAt), *silence.CreatedBy, *silence.Comment, ) } return w.Flush() } // FormatAlerts formats the alerts into a readable string. func (formatter *ExtendedFormatter) FormatAlerts(alerts []*models.GettableAlert) error { w := tabwriter.NewWriter(formatter.writer, 0, 0, 2, ' ', 0) sort.Sort(ByStartsAt(alerts)) fmt.Fprintln(w, "Labels\tAnnotations\tStarts At\tEnds At\tGenerator URL\tState\t") for _, alert := range alerts { fmt.Fprintf( w, "%s\t%s\t%s\t%s\t%s\t%s\t\n", extendedFormatLabels(alert.Labels), extendedFormatAnnotations(alert.Annotations), FormatDate(*alert.StartsAt), FormatDate(*alert.EndsAt), alert.GeneratorURL, *alert.Status.State, ) } return w.Flush() } // FormatConfig formats the alertmanager status information into a readable string. func (formatter *ExtendedFormatter) FormatConfig(status *models.AlertmanagerStatus) error { fmt.Fprintln(formatter.writer, status.Config.Original) fmt.Fprintln(formatter.writer, "buildUser", status.VersionInfo.BuildUser) fmt.Fprintln(formatter.writer, "goVersion", status.VersionInfo.GoVersion) fmt.Fprintln(formatter.writer, "revision", status.VersionInfo.Revision) fmt.Fprintln(formatter.writer, "version", status.VersionInfo.Version) fmt.Fprintln(formatter.writer, "branch", status.VersionInfo.Branch) fmt.Fprintln(formatter.writer, "buildDate", status.VersionInfo.BuildDate) fmt.Fprintln(formatter.writer, "uptime", status.Uptime) return nil } // FormatClusterStatus formats the cluster status with peers into a readable string. func (formatter *ExtendedFormatter) FormatClusterStatus(status *models.ClusterStatus) error { w := tabwriter.NewWriter(formatter.writer, 0, 0, 2, ' ', 0) fmt.Fprintf(w, "Cluster Status:\t%s\nNode Name:\t%s\n\n", *status.Status, status.Name, ) fmt.Fprintln(w, "Address\tName") sort.Sort(ByAddress(status.Peers)) for _, peer := range status.Peers { fmt.Fprintf( w, "%s\t%s\t\n", *peer.Address, *peer.Name, ) } return w.Flush() } func extendedFormatLabels(labels models.LabelSet) string { output := []string{} for name, value := range labels { output = append(output, fmt.Sprintf("%s=\"%s\"", name, value)) } sort.Strings(output) return strings.Join(output, " ") } func extendedFormatAnnotations(labels models.LabelSet) string { output := []string{} for name, value := range labels { output = append(output, fmt.Sprintf("%s=\"%s\"", name, value)) } sort.Strings(output) return strings.Join(output, " ") } func extendedFormatMatchers(matchers models.Matchers) string { lms := labels.Matchers{} for _, matcher := range matchers { lms = append(lms, labelsMatcher(*matcher)) } return lms.String() } ================================================ FILE: cli/format/format_json.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package format import ( "encoding/json" "io" "os" "github.com/prometheus/alertmanager/api/v2/models" ) type JSONFormatter struct { writer io.Writer } func init() { Formatters["json"] = &JSONFormatter{writer: os.Stdout} } func (formatter *JSONFormatter) SetOutput(writer io.Writer) { formatter.writer = writer } func (formatter *JSONFormatter) FormatSilences(silences []models.GettableSilence) error { enc := json.NewEncoder(formatter.writer) return enc.Encode(silences) } func (formatter *JSONFormatter) FormatAlerts(alerts []*models.GettableAlert) error { enc := json.NewEncoder(formatter.writer) return enc.Encode(alerts) } func (formatter *JSONFormatter) FormatConfig(status *models.AlertmanagerStatus) error { enc := json.NewEncoder(formatter.writer) return enc.Encode(status) } func (formatter *JSONFormatter) FormatClusterStatus(status *models.ClusterStatus) error { enc := json.NewEncoder(formatter.writer) return enc.Encode(status) } ================================================ FILE: cli/format/format_simple.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package format import ( "fmt" "io" "os" "sort" "strings" "text/tabwriter" "github.com/prometheus/alertmanager/api/v2/models" ) type SimpleFormatter struct { writer io.Writer } func init() { Formatters["simple"] = &SimpleFormatter{writer: os.Stdout} } func (formatter *SimpleFormatter) SetOutput(writer io.Writer) { formatter.writer = writer } func (formatter *SimpleFormatter) FormatSilences(silences []models.GettableSilence) error { w := tabwriter.NewWriter(formatter.writer, 0, 0, 2, ' ', 0) sort.Sort(ByEndAt(silences)) fmt.Fprintln(w, "ID\tMatchers\tEnds At\tCreated By\tComment\t") for _, silence := range silences { fmt.Fprintf( w, "%s\t%s\t%s\t%s\t%s\t\n", *silence.ID, simpleFormatMatchers(silence.Matchers), FormatDate(*silence.EndsAt), *silence.CreatedBy, *silence.Comment, ) } return w.Flush() } func (formatter *SimpleFormatter) FormatAlerts(alerts []*models.GettableAlert) error { w := tabwriter.NewWriter(formatter.writer, 0, 0, 2, ' ', 0) sort.Sort(ByStartsAt(alerts)) fmt.Fprintln(w, "Alertname\tStarts At\tSummary\tState\t") for _, alert := range alerts { fmt.Fprintf( w, "%s\t%s\t%s\t%s\t\n", alert.Labels["alertname"], FormatDate(*alert.StartsAt), alert.Annotations["summary"], *alert.Status.State, ) } return w.Flush() } func (formatter *SimpleFormatter) FormatConfig(status *models.AlertmanagerStatus) error { fmt.Fprintln(formatter.writer, *status.Config.Original) return nil } func (formatter *SimpleFormatter) FormatClusterStatus(status *models.ClusterStatus) error { w := tabwriter.NewWriter(formatter.writer, 0, 0, 2, ' ', 0) fmt.Fprintf(w, "Cluster Status:\t%s\nNode Name:\t%s\n", *status.Status, status.Name, ) return w.Flush() } func simpleFormatMatchers(matchers models.Matchers) string { output := []string{} for _, matcher := range matchers { output = append(output, simpleFormatMatcher(*matcher)) } return strings.Join(output, " ") } func simpleFormatMatcher(m models.Matcher) string { return labelsMatcher(m).String() } ================================================ FILE: cli/format/sort.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package format import ( "bytes" "net" "strconv" "time" "github.com/prometheus/alertmanager/api/v2/models" ) type ByEndAt []models.GettableSilence func (s ByEndAt) Len() int { return len(s) } func (s ByEndAt) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s ByEndAt) Less(i, j int) bool { return time.Time(*s[i].Silence.EndsAt).Before(time.Time(*s[j].EndsAt)) } type ByStartsAt []*models.GettableAlert func (s ByStartsAt) Len() int { return len(s) } func (s ByStartsAt) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s ByStartsAt) Less(i, j int) bool { return time.Time(*s[i].StartsAt).Before(time.Time(*s[j].StartsAt)) } type ByAddress []*models.PeerStatus func (s ByAddress) Len() int { return len(s) } func (s ByAddress) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s ByAddress) Less(i, j int) bool { ip1, port1, _ := net.SplitHostPort(*s[i].Address) ip2, port2, _ := net.SplitHostPort(*s[j].Address) if ip1 == ip2 { p1, _ := strconv.Atoi(port1) p2, _ := strconv.Atoi(port2) return p1 < p2 } return bytes.Compare(net.ParseIP(ip1), net.ParseIP(ip2)) < 0 } ================================================ FILE: cli/root.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "fmt" "net/url" "os" "path" "strings" "time" "github.com/alecthomas/kingpin/v2" clientruntime "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" promconfig "github.com/prometheus/common/config" "github.com/prometheus/common/promslog" "github.com/prometheus/common/version" "golang.org/x/mod/semver" "github.com/prometheus/alertmanager/api/v2/client" "github.com/prometheus/alertmanager/cli/config" "github.com/prometheus/alertmanager/cli/format" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/matcher/compat" ) var ( verbose bool alertmanagerURL *url.URL output string timeout time.Duration httpConfigFile string versionCheck bool featureFlags string configFiles = []string{os.ExpandEnv("$HOME/.config/amtool/config.yml"), "/etc/amtool/config.yml"} legacyFlags = map[string]string{"comment_required": "require-comment"} ) func initMatchersCompat(_ *kingpin.ParseContext) error { promslogConfig := &promslog.Config{Writer: os.Stdout} if verbose { promslogConfig.Level = promslog.NewLevel() _ = promslogConfig.Level.Set("debug") } logger := promslog.New(promslogConfig) featureConfig, err := featurecontrol.NewFlags(logger, featureFlags) if err != nil { kingpin.Fatalf("error parsing the feature flag list: %v\n", err) } compat.InitFromFlags(logger, featureConfig) return nil } func requireAlertManagerURL(pc *kingpin.ParseContext) error { // Return without error if any help flag is set. for _, elem := range pc.Elements { f, ok := elem.Clause.(*kingpin.FlagClause) if !ok { continue } name := f.Model().Name if name == "help" || name == "help-long" || name == "help-man" { return nil } } if alertmanagerURL == nil { kingpin.Fatalf("required flag --alertmanager.url not provided") } return nil } const ( defaultAmHost = "localhost" defaultAmPort = "9093" defaultAmApiv2path = "/api/v2" ) // NewAlertmanagerClient initializes an alertmanager client with the given URL. func NewAlertmanagerClient(amURL *url.URL) *client.AlertmanagerAPI { address := defaultAmHost + ":" + defaultAmPort schemes := []string{"http"} if amURL.Host != "" { address = amURL.Host // URL documents host as host or host:port } if amURL.Scheme != "" { schemes = []string{amURL.Scheme} } cr := clientruntime.New(address, path.Join(amURL.Path, defaultAmApiv2path), schemes) if amURL.User != nil && httpConfigFile != "" { kingpin.Fatalf("basic authentication and http.config.file are mutually exclusive") } if amURL.User != nil { password, _ := amURL.User.Password() cr.DefaultAuthentication = clientruntime.BasicAuth(amURL.User.Username(), password) } if httpConfigFile != "" { var err error httpConfig, _, err := promconfig.LoadHTTPConfigFile(httpConfigFile) if err != nil { kingpin.Fatalf("failed to load HTTP config file: %v", err) } httpclient, err := promconfig.NewClientFromConfig(*httpConfig, "amtool") if err != nil { kingpin.Fatalf("failed to create a new HTTP client: %v", err) } cr = clientruntime.NewWithClient(address, path.Join(amURL.Path, defaultAmApiv2path), schemes, httpclient) } c := client.New(cr, strfmt.Default) if !versionCheck { return c } status, err := c.General.GetStatus(nil) if err != nil || status.Payload.VersionInfo == nil || version.Version == "" { // We can not get version info, or we do not know our own version. Let amtool continue. return c } if semver.MajorMinor("v"+*status.Payload.VersionInfo.Version) != semver.MajorMinor("v"+version.Version) { fmt.Fprintf(os.Stderr, "Warning: amtool version (%s) and alertmanager version (%s) are different.\n", version.Version, *status.Payload.VersionInfo.Version) } return c } // Execute is the main function for the amtool command. func Execute() { app := kingpin.New("amtool", helpRoot).UsageWriter(os.Stdout) format.InitFormatFlags(app) app.Flag("verbose", "Verbose running information").Short('v').BoolVar(&verbose) app.Flag("alertmanager.url", "Alertmanager to talk to").URLVar(&alertmanagerURL) app.Flag("output", "Output formatter (simple, extended, json)").Short('o').Default("simple").EnumVar(&output, "simple", "extended", "json") app.Flag("timeout", "Timeout for the executed command").Default("30s").DurationVar(&timeout) app.Flag("http.config.file", "HTTP client configuration file for amtool to connect to Alertmanager.").PlaceHolder("").ExistingFileVar(&httpConfigFile) app.Flag("version-check", "Check alertmanager version. Use --no-version-check to disable.").Default("true").BoolVar(&versionCheck) app.Flag("enable-feature", fmt.Sprintf("Experimental features to enable, comma separated. Valid options: %s", strings.Join(featurecontrol.AllowedFlags, ", "))).Default("").StringVar(&featureFlags) app.Version(version.Print("amtool")) app.GetFlag("help").Short('h') app.UsageTemplate(kingpin.CompactUsageTemplate) resolver, err := config.NewResolver(configFiles, legacyFlags) if err != nil { kingpin.Fatalf("could not load config file: %v\n", err) } configureAlertCmd(app) configureSilenceCmd(app) configureCheckConfigCmd(app) configureClusterCmd(app) configureConfigCmd(app) configureTemplateCmd(app) app.Action(initMatchersCompat) err = resolver.Bind(app, os.Args[1:]) if err != nil { kingpin.Fatalf("%v\n", err) } _, err = app.Parse(os.Args[1:]) if err != nil { kingpin.Fatalf("%v\n", err) } } const ( helpRoot = `View and modify the current Alertmanager state. Config File: The alertmanager tool will read a config file in YAML format from one of two default config locations: $HOME/.config/amtool/config.yml or /etc/amtool/config.yml All flags can be given in the config file, but the following are the suited for static configuration: alertmanager.url Set a default alertmanager url for each request author Set a default author value for new silences. If this argument is not specified then the username will be used require-comment Bool, whether to require a comment on silence creation. Defaults to true output Set a default output type. Options are (simple, extended, json) date.format Sets the output format for dates. Defaults to "2006-01-02 15:04:05 MST" http.config.file HTTP client configuration file for amtool to connect to Alertmanager. The format is https://prometheus.io/docs/alerting/latest/configuration/#http_config. ` ) ================================================ FILE: cli/routing.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "bytes" "context" "fmt" "github.com/alecthomas/kingpin/v2" "github.com/xlab/treeprint" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/dispatch" ) type routingShow struct { configFile string labels []string expectedReceivers string debugTree bool } const ( routingHelp = `Prints alert routing tree Will print whole routing tree in form of ASCII tree view. Routing is loaded from a local configuration file or a running Alertmanager configuration. Specifying --config.file takes precedence over --alertmanager.url. Example: ./amtool config routes [show] --config.file=doc/examples/simple.yml ` branchSlugSeparator = " " ) func configureRoutingCmd(app *kingpin.CmdClause) { var ( c = &routingShow{} routingCmd = app.Command("routes", routingHelp) routingShowCmd = routingCmd.Command("show", routingHelp).Default() configFlag = routingCmd.Flag("config.file", "Config file to be tested.") ) configFlag.ExistingFileVar(&c.configFile) routingShowCmd.Action(execWithTimeout(c.routingShowAction)) configureRoutingTestCmd(routingCmd, c) } func (c *routingShow) routingShowAction(ctx context.Context, _ *kingpin.ParseContext) error { // Load configuration from file or URL. cfg, err := loadAlertmanagerConfig(ctx, alertmanagerURL, c.configFile) if err != nil { kingpin.Fatalf("%s", err) return err } route := dispatch.NewRoute(cfg.Route, nil) tree := treeprint.New() convertRouteToTree(route, tree) fmt.Println("Routing tree:") fmt.Println(tree.String()) return nil } func getRouteTreeSlug(route *dispatch.Route, showContinue, showReceiver bool) string { var branchSlug bytes.Buffer if route.Matchers.Len() == 0 { branchSlug.WriteString("default-route") } else { branchSlug.WriteString(route.Matchers.String()) } if route.Continue && showContinue { branchSlug.WriteString(branchSlugSeparator) branchSlug.WriteString("continue: true") } if showReceiver { branchSlug.WriteString(branchSlugSeparator) branchSlug.WriteString("receiver: ") branchSlug.WriteString(route.RouteOpts.Receiver) } return branchSlug.String() } func convertRouteToTree(route *dispatch.Route, tree treeprint.Tree) { branch := tree.AddBranch(getRouteTreeSlug(route, true, true)) for _, r := range route.Routes { convertRouteToTree(r, branch) } } func getMatchingTree(route *dispatch.Route, tree treeprint.Tree, lset models.LabelSet) { final := true branch := tree.AddBranch(getRouteTreeSlug(route, false, false)) for _, r := range route.Routes { if r.Matchers.Matches(convertClientToCommonLabelSet(lset)) { getMatchingTree(r, branch, lset) final = false if !r.Continue { break } } } if final { branch.SetValue(getRouteTreeSlug(route, false, true)) } } ================================================ FILE: cli/silence.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "github.com/alecthomas/kingpin/v2" ) // configureSilenceCmd represents the silence command. func configureSilenceCmd(app *kingpin.Application) { silenceCmd := app.Command("silence", "Add, expire or view silences. For more information and additional flags see query help").PreAction(requireAlertManagerURL) configureSilenceAddCmd(silenceCmd) configureSilenceExpireCmd(silenceCmd) configureSilenceImportCmd(silenceCmd) configureSilenceQueryCmd(silenceCmd) configureSilenceUpdateCmd(silenceCmd) } ================================================ FILE: cli/silence_add.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" "fmt" "os/user" "strconv" "time" "github.com/alecthomas/kingpin/v2" "github.com/go-openapi/strfmt" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/pkg/labels" ) func username() string { user, err := user.Current() if err != nil { return "" } return user.Username } type silenceAddCmd struct { author string requireComment bool duration string start string end string comment string matchers []string annotations []string } const silenceAddHelp = `Add a new alertmanager silence Amtool uses a simplified Prometheus syntax to represent silences. The non-option section of arguments constructs a list of "Matcher Groups" that will be used to create a number of silences. The following examples will attempt to show this behaviour in action: amtool silence add alertname=foo node=bar This statement will add a silence that matches alerts with the alertname=foo and node=bar label value pairs set. amtool silence add foo node=bar If alertname is omitted and the first argument does not contain a '=' or a '=~' then it will be assumed to be the value of the alertname pair. amtool silence add 'alertname=~foo.*' As well as direct equality, regex matching is also supported. The '=~' syntax (similar to Prometheus) is used to represent a regex match. Regex matching can be used in combination with a direct match. ` func configureSilenceAddCmd(cc *kingpin.CmdClause) { var ( c = &silenceAddCmd{} addCmd = cc.Command("add", silenceAddHelp) ) addCmd.Flag("author", "Username for CreatedBy field").Short('a').Default(username()).StringVar(&c.author) addCmd.Flag("require-comment", "Require comment to be set").Hidden().Default("true").BoolVar(&c.requireComment) addCmd.Flag("duration", "Duration of silence").Short('d').Default("1h").StringVar(&c.duration) addCmd.Flag("start", "Set when the silence should start. RFC3339 format 2006-01-02T15:04:05-07:00").StringVar(&c.start) addCmd.Flag("end", "Set when the silence should end (overwrites duration). RFC3339 format 2006-01-02T15:04:05-07:00").StringVar(&c.end) addCmd.Flag("comment", "A comment to help describe the silence").Short('c').StringVar(&c.comment) addCmd.Arg("matcher-groups", "Query filter").StringsVar(&c.matchers) addCmd.Flag("annotation", "Set an annotation to be included with the silence").StringsVar(&c.annotations) addCmd.Action(execWithTimeout(c.add)) } func (c *silenceAddCmd) add(ctx context.Context, _ *kingpin.ParseContext) error { var err error if len(c.matchers) > 0 { // If the parser fails then we likely don't have a (=|=~|!=|!~) so lets // assume that the user wants alertname= and prepend `alertname=` // to the front. _, err := compat.Matcher(c.matchers[0], "cli") if err != nil { c.matchers[0] = fmt.Sprintf("alertname=%s", strconv.Quote(c.matchers[0])) } } matchers := make([]labels.Matcher, 0, len(c.matchers)) for _, s := range c.matchers { m, err := compat.Matcher(s, "cli") if err != nil { return err } matchers = append(matchers, *m) } if len(matchers) < 1 { return fmt.Errorf("no matchers specified") } var startsAt time.Time if c.start != "" { startsAt, err = time.Parse(time.RFC3339, c.start) if err != nil { return err } } else { startsAt = time.Now().UTC() } var endsAt time.Time if c.end != "" { endsAt, err = time.Parse(time.RFC3339, c.end) if err != nil { return err } } else { d, err := model.ParseDuration(c.duration) if err != nil { return err } if d == 0 { return fmt.Errorf("silence duration must be greater than 0") } endsAt = startsAt.UTC().Add(time.Duration(d)) } if startsAt.After(endsAt) { return errors.New("silence cannot start after it ends") } if c.requireComment && c.comment == "" { return errors.New("comment required by config") } var annotations models.LabelSet if len(c.annotations) > 0 { annotations = make(models.LabelSet, len(c.annotations)) for _, a := range c.annotations { matcher, err := compat.Matcher(a, "cli") if err != nil { return err } if matcher.Type != labels.MatchEqual { return errors.New("annotations must be specified as key=value pairs") } annotations[matcher.Name] = matcher.Value } } start := strfmt.DateTime(startsAt) end := strfmt.DateTime(endsAt) ps := &models.PostableSilence{ Silence: models.Silence{ Matchers: TypeMatchers(matchers), StartsAt: &start, EndsAt: &end, CreatedBy: &c.author, Comment: &c.comment, Annotations: annotations, }, } silenceParams := silence.NewPostSilencesParams().WithContext(ctx). WithSilence(ps) amclient := NewAlertmanagerClient(alertmanagerURL) postOk, err := amclient.Silence.PostSilences(silenceParams) if err != nil { return err } _, err = fmt.Println(postOk.Payload.SilenceID) return err } ================================================ FILE: cli/silence_expire.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" "github.com/alecthomas/kingpin/v2" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/client/silence" ) type silenceExpireCmd struct { ids []string } func configureSilenceExpireCmd(cc *kingpin.CmdClause) { var ( c = &silenceExpireCmd{} expireCmd = cc.Command("expire", "expire an alertmanager silence") ) expireCmd.Arg("silence-ids", "Ids of silences to expire").StringsVar(&c.ids) expireCmd.Action(execWithTimeout(c.expire)) } func (c *silenceExpireCmd) expire(ctx context.Context, _ *kingpin.ParseContext) error { if len(c.ids) < 1 { return errors.New("no silence IDs specified") } amclient := NewAlertmanagerClient(alertmanagerURL) for _, id := range c.ids { params := silence.NewDeleteSilenceParams().WithContext(ctx) params.SilenceID = strfmt.UUID(id) _, err := amclient.Silence.DeleteSilence(params) if err != nil { return err } } return nil } ================================================ FILE: cli/silence_import.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "encoding/json" "errors" "fmt" "os" "sync" kingpin "github.com/alecthomas/kingpin/v2" "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/alertmanager/api/v2/models" ) type silenceImportCmd struct { force bool workers int file string } const silenceImportHelp = `Import alertmanager silences from JSON file or stdin This command can be used to bulk import silences from a JSON file created by query command. For example: amtool silence query -o json foo > foo.json amtool silence import foo.json JSON data can also come from stdin if no param is specified. ` func configureSilenceImportCmd(cc *kingpin.CmdClause) { var ( c = &silenceImportCmd{} importCmd = cc.Command("import", silenceImportHelp) ) importCmd.Flag("force", "Force adding new silences even if it already exists").Short('f').BoolVar(&c.force) importCmd.Flag("worker", "Number of concurrent workers to use for import").Short('w').Default("8").IntVar(&c.workers) importCmd.Arg("input-file", "JSON file with silences").ExistingFileVar(&c.file) importCmd.Action(execWithTimeout(c.bulkImport)) } func addSilenceWorker(ctx context.Context, sclient silence.ClientService, silencec <-chan *models.PostableSilence, errc chan<- error) { for s := range silencec { sid := s.ID params := silence.NewPostSilencesParams().WithContext(ctx).WithSilence(s) postOk, err := sclient.PostSilences(params) var e *silence.PostSilencesNotFound if errors.As(err, &e) { // silence doesn't exists yet, retry to create as a new one params.Silence.ID = "" postOk, err = sclient.PostSilences(params) } if err != nil { fmt.Fprintf(os.Stderr, "Error adding silence id='%v': %v\n", sid, err) } else { fmt.Println(postOk.Payload.SilenceID) } errc <- err } } func (c *silenceImportCmd) bulkImport(ctx context.Context, _ *kingpin.ParseContext) error { input := os.Stdin var err error if c.file != "" { input, err = os.Open(c.file) if err != nil { return err } defer input.Close() } dec := json.NewDecoder(input) // read open square bracket _, err = dec.Token() if err != nil { return fmt.Errorf("couldn't unmarshal input data, is it JSON?: %w", err) } amclient := NewAlertmanagerClient(alertmanagerURL) silencec := make(chan *models.PostableSilence, 100) errc := make(chan error, 100) errDone := make(chan struct{}) var wg sync.WaitGroup var once sync.Once closeChannels := func() { once.Do(func() { close(silencec) wg.Wait() close(errc) <-errDone close(errDone) }) } defer closeChannels() for w := 0; w < c.workers; w++ { wg.Go(func() { addSilenceWorker(ctx, amclient.Silence, silencec, errc) }) } errCount := 0 go func() { for err := range errc { if err != nil { errCount++ } } errDone <- struct{}{} }() count := 0 for dec.More() { var s models.PostableSilence err := dec.Decode(&s) if err != nil { return fmt.Errorf("couldn't unmarshal input data, is it JSON?: %w", err) } if c.force { // reset the silence ID so Alertmanager will always create new silence s.ID = "" } silencec <- &s count++ } closeChannels() if errCount > 0 { return fmt.Errorf("couldn't import %v out of %v silences", errCount, count) } return nil } ================================================ FILE: cli/silence_query.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" "fmt" "strconv" "time" kingpin "github.com/alecthomas/kingpin/v2" "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/cli/format" "github.com/prometheus/alertmanager/matcher/compat" ) type silenceQueryCmd struct { expired bool quiet bool createdBy string ID string matchers []string within time.Duration } const querySilenceHelp = `Query Alertmanager silences. Amtool has a simplified prometheus query syntax, but contains robust support for bash variable expansions. The non-option section of arguments constructs a list of "Matcher Groups" that will be used to filter your query. The following examples will attempt to show this behaviour in action: amtool silence query alertname=foo node=bar This query will match all silences with the alertname=foo and node=bar label value pairs set. amtool silence query foo node=bar If alertname is omitted and the first argument does not contain a '=' or a '=~' then it will be assumed to be the value of the alertname pair. amtool silence query 'alertname=~foo.*' As well as direct equality, regex matching is also supported. The '=~' syntax (similar to prometheus) is used to represent a regex match. Regex matching can be used in combination with a direct match. In addition to filtering by silence labels, one can also query for silences that are due to expire soon with the "--within" parameter. In the event that you want to preemptively act upon expiring silences by either fixing them or extending them. For example: amtool silence query --within 8h returns all the silences due to expire within the next 8 hours. This syntax can also be combined with the label based filtering above for more flexibility. The "--expired" parameter returns only expired silences. Used in combination with "--within=TIME", amtool returns the silences that expired within the preceding duration. amtool silence query --within 2h --expired returns all silences that expired within the preceding 2 hours. ` func configureSilenceQueryCmd(cc *kingpin.CmdClause) { var ( c = &silenceQueryCmd{} queryCmd = cc.Command("query", querySilenceHelp).Default() ) queryCmd.Flag("expired", "Show expired silences instead of active").BoolVar(&c.expired) queryCmd.Flag("quiet", "Only show silence ids").Short('q').BoolVar(&c.quiet) queryCmd.Flag("created-by", "Show silences that belong to this creator").StringVar(&c.createdBy) queryCmd.Flag("id", "Get a single silence by its ID").StringVar(&c.ID) queryCmd.Arg("matcher-groups", "Query filter").StringsVar(&c.matchers) queryCmd.Flag("within", "Show silences that will expire or have expired within a duration").DurationVar(&c.within) queryCmd.Action(execWithTimeout(c.query)) } func (c *silenceQueryCmd) query(ctx context.Context, _ *kingpin.ParseContext) error { if len(c.matchers) > 0 { // If the parser fails then we likely don't have a (=|=~|!=|!~) so lets // assume that the user wants alertname= and prepend `alertname=` // to the front. _, err := compat.Matcher(c.matchers[0], "cli") if err != nil { c.matchers[0] = fmt.Sprintf("alertname=%s", strconv.Quote(c.matchers[0])) } } silenceParams := silence.NewGetSilencesParams().WithContext(ctx).WithFilter(c.matchers) amclient := NewAlertmanagerClient(alertmanagerURL) getOk, err := amclient.Silence.GetSilences(silenceParams) if err != nil { return err } displaySilences := []models.GettableSilence{} for _, silence := range getOk.Payload { // skip expired silences if --expired is not set if !c.expired && time.Time(*silence.EndsAt).Before(time.Now()) { continue } // skip active silences if --expired is set if c.expired && time.Time(*silence.EndsAt).After(time.Now()) { continue } // skip active silences expiring after "--within" if !c.expired && int64(c.within) > 0 && time.Time(*silence.EndsAt).After(time.Now().UTC().Add(c.within)) { continue } // skip silences that expired before "--within" if c.expired && int64(c.within) > 0 && time.Time(*silence.EndsAt).Before(time.Now().UTC().Add(-c.within)) { continue } // Skip silences if the author doesn't match. if c.createdBy != "" && *silence.CreatedBy != c.createdBy { continue } // Skip silences if the ID doesn't match. if c.ID != "" && c.ID != *silence.ID { continue } displaySilences = append(displaySilences, *silence) } if c.quiet { for _, silence := range displaySilences { fmt.Println(*silence.ID) } } else { formatter, found := format.Formatters[output] if !found { return errors.New("unknown output formatter") } if err := formatter.FormatSilences(displaySilences); err != nil { return fmt.Errorf("error formatting silences: %w", err) } } return nil } ================================================ FILE: cli/silence_update.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" "fmt" "time" "github.com/alecthomas/kingpin/v2" "github.com/go-openapi/strfmt" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/cli/format" ) type silenceUpdateCmd struct { quiet bool duration string start string end string comment string ids []string } func configureSilenceUpdateCmd(cc *kingpin.CmdClause) { var ( c = &silenceUpdateCmd{} updateCmd = cc.Command("update", "Update silences") ) updateCmd.Flag("quiet", "Only show silence ids").Short('q').BoolVar(&c.quiet) updateCmd.Flag("duration", "Duration of silence").Short('d').StringVar(&c.duration) updateCmd.Flag("start", "Set when the silence should start. RFC3339 format 2006-01-02T15:04:05-07:00").StringVar(&c.start) updateCmd.Flag("end", "Set when the silence should end (overwrites duration). RFC3339 format 2006-01-02T15:04:05-07:00").StringVar(&c.end) updateCmd.Flag("comment", "A comment to help describe the silence").Short('c').StringVar(&c.comment) updateCmd.Arg("update-ids", "Silence IDs to update").StringsVar(&c.ids) updateCmd.Action(execWithTimeout(c.update)) } func (c *silenceUpdateCmd) update(ctx context.Context, _ *kingpin.ParseContext) error { if len(c.ids) < 1 { return fmt.Errorf("no silence IDs specified") } amclient := NewAlertmanagerClient(alertmanagerURL) var updatedSilences []models.GettableSilence for _, silenceID := range c.ids { params := silence.NewGetSilenceParams() params.SilenceID = strfmt.UUID(silenceID) response, err := amclient.Silence.GetSilence(params) if err != nil { return err } sil := response.Payload if c.start != "" { startsAtTime, err := time.Parse(time.RFC3339, c.start) if err != nil { return err } startsAt := strfmt.DateTime(startsAtTime) sil.StartsAt = &startsAt } if c.end != "" { endsAtTime, err := time.Parse(time.RFC3339, c.end) if err != nil { return err } endsAt := strfmt.DateTime(endsAtTime) sil.EndsAt = &endsAt } else if c.duration != "" { d, err := model.ParseDuration(c.duration) if err != nil { return err } if d == 0 { return fmt.Errorf("silence duration must be greater than 0") } endsAt := strfmt.DateTime(time.Time(*sil.StartsAt).UTC().Add(time.Duration(d))) sil.EndsAt = &endsAt } if time.Time(*sil.StartsAt).After(time.Time(*sil.EndsAt)) { return errors.New("silence cannot start after it ends") } if c.comment != "" { sil.Comment = &c.comment } ps := &models.PostableSilence{ ID: *sil.ID, Silence: sil.Silence, } amclient := NewAlertmanagerClient(alertmanagerURL) silenceParams := silence.NewPostSilencesParams().WithContext(ctx).WithSilence(ps) postOk, err := amclient.Silence.PostSilences(silenceParams) if err != nil { return err } sil.ID = &postOk.Payload.SilenceID updatedSilences = append(updatedSilences, *sil) } if c.quiet { for _, silence := range updatedSilences { fmt.Println(silence.ID) } } else { formatter, found := format.Formatters[output] if !found { return fmt.Errorf("unknown output formatter") } if err := formatter.FormatSilences(updatedSilences); err != nil { return fmt.Errorf("error formatting silences: %w", err) } } return nil } ================================================ FILE: cli/template.go ================================================ // Copyright 2021 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "github.com/alecthomas/kingpin/v2" ) // configureTemplateCmd represents the template command. func configureTemplateCmd(app *kingpin.Application) { templateCmd := app.Command("template", "Render template files.") configureTemplateRenderCmd(templateCmd) } ================================================ FILE: cli/template_render.go ================================================ // Copyright 2021 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "encoding/json" "fmt" "io" "os" "time" "github.com/alecthomas/kingpin/v2" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/template" ) var defaultData = template.Data{ Receiver: "receiver", Status: "alertstatus", Alerts: template.Alerts{ template.Alert{ Status: string(model.AlertFiring), Labels: template.KV{ "label1": "value1", "label2": "value2", "instance": "foo.bar:1234", "commonlabelkey1": "commonlabelvalue1", "commonlabelkey2": "commonlabelvalue2", }, Annotations: template.KV{ "annotation1": "value1", "annotation2": "value2", "commonannotationkey1": "commonannotationvalue1", "commonannotationkey2": "commonannotationvalue2", }, StartsAt: time.Now().Add(-5 * time.Minute), EndsAt: time.Now(), GeneratorURL: "https://generatorurl.com", Fingerprint: "fingerprint1", }, template.Alert{ Status: string(model.AlertResolved), Labels: template.KV{ "foo": "bar", "baz": "qux", "commonlabelkey1": "commonlabelvalue1", "commonlabelkey2": "commonlabelvalue2", }, Annotations: template.KV{ "aaa": "bbb", "ccc": "ddd", "commonannotationkey1": "commonannotationvalue1", "commonannotationkey2": "commonannotationvalue2", }, StartsAt: time.Now().Add(-10 * time.Minute), EndsAt: time.Now(), GeneratorURL: "https://generatorurl.com", Fingerprint: "fingerprint2", }, }, GroupLabels: template.KV{ "grouplabelkey1": "grouplabelvalue1", "grouplabelkey2": "grouplabelvalue2", }, CommonLabels: template.KV{ "commonlabelkey1": "commonlabelvalue1", "commonlabelkey2": "commonlabelvalue2", }, CommonAnnotations: template.KV{ "commonannotationkey1": "commonannotationvalue1", "commonannotationkey2": "commonannotationvalue2", }, ExternalURL: "https://example.com", } type templateRenderCmd struct { templateFilesGlobs []string templateType string templateText string templateData *os.File } func configureTemplateRenderCmd(cc *kingpin.CmdClause) { var ( c = &templateRenderCmd{} renderCmd = cc.Command("render", "Render a given definition in a template file to standard output.") ) renderCmd.Flag("template.glob", "Glob of paths that will be expanded and used for rendering.").Required().StringsVar(&c.templateFilesGlobs) renderCmd.Flag("template.text", "The template that will be rendered.").Required().StringVar(&c.templateText) renderCmd.Flag("template.type", "The type of the template. Can be either text (default) or html.").EnumVar(&c.templateType, "html", "text") renderCmd.Flag("template.data", "Full path to a file which contains the data of the alert(-s) with which the --template.text will be rendered. Must be in JSON. File must be formatted according to the following layout: https://pkg.go.dev/github.com/prometheus/alertmanager/template#Data. If none has been specified then a predefined, simple alert will be used for rendering.").FileVar(&c.templateData) renderCmd.Action(execWithTimeout(c.render)) } func (c *templateRenderCmd) render(ctx context.Context, _ *kingpin.ParseContext) error { tmpl, err := template.FromGlobs(c.templateFilesGlobs) if err != nil { return err } f := tmpl.ExecuteTextString if c.templateType == "html" { f = tmpl.ExecuteHTMLString } var data template.Data if c.templateData == nil { data = defaultData } else { content, err := io.ReadAll(c.templateData) if err != nil { return err } if err := json.Unmarshal(content, &data); err != nil { return err } } rendered, err := f(c.templateText, data) if err != nil { return err } fmt.Print(rendered) return nil } ================================================ FILE: cli/test_routing.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "fmt" "os" "strings" "github.com/alecthomas/kingpin/v2" "github.com/xlab/treeprint" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/dispatch" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/pkg/labels" ) const routingTestHelp = `Test alert routing Will return receiver names which the alert with given labels resolves to. If the labelset resolves to multiple receivers, they are printed out in order as defined in the routing tree. Routing is loaded from a local configuration file or a running Alertmanager configuration. Specifying --config.file takes precedence over --alertmanager.url. Example: ./amtool config routes test --config.file=doc/examples/simple.yml --verify.receivers=team-DB-pager service=database ` func configureRoutingTestCmd(cc *kingpin.CmdClause, c *routingShow) { routingTestCmd := cc.Command("test", routingTestHelp) routingTestCmd.Flag("verify.receivers", "Checks if specified receivers matches resolved receivers. The command fails if the labelset does not route to the specified receivers.").StringVar(&c.expectedReceivers) routingTestCmd.Flag("tree", "Prints out matching routes tree.").BoolVar(&c.debugTree) routingTestCmd.Arg("labels", "List of labels to be tested against the configured routes.").StringsVar(&c.labels) routingTestCmd.Action(execWithTimeout(c.routingTestAction)) } // resolveAlertReceivers returns list of receiver names which given LabelSet resolves to. func resolveAlertReceivers(mainRoute *dispatch.Route, labels *models.LabelSet) ([]string, error) { var ( finalRoutes []*dispatch.Route receivers []string ) finalRoutes = mainRoute.Match(convertClientToCommonLabelSet(*labels)) for _, r := range finalRoutes { receivers = append(receivers, r.RouteOpts.Receiver) } return receivers, nil } func printMatchingTree(mainRoute *dispatch.Route, ls models.LabelSet) { tree := treeprint.New() getMatchingTree(mainRoute, tree, ls) fmt.Println("Matching routes:") fmt.Println(tree.String()) fmt.Print("\n") } func (c *routingShow) routingTestAction(ctx context.Context, _ *kingpin.ParseContext) error { cfg, err := loadAlertmanagerConfig(ctx, alertmanagerURL, c.configFile) if err != nil { kingpin.Fatalf("%v\n", err) return err } mainRoute := dispatch.NewRoute(cfg.Route, nil) // Parse labels to LabelSet. ls := make(models.LabelSet, len(c.labels)) for _, l := range c.labels { matcher, err := compat.Matcher(l, "cli") if err != nil { kingpin.Fatalf("Failed to parse labels: %v\n", err) } if matcher.Type != labels.MatchEqual { kingpin.Fatalf("%s\n", "Labels must be specified as key=value pairs") } ls[matcher.Name] = matcher.Value } if c.debugTree { printMatchingTree(mainRoute, ls) } receivers, err := resolveAlertReceivers(mainRoute, &ls) receiversSlug := strings.Join(receivers, ",") fmt.Printf("%s\n", receiversSlug) if c.expectedReceivers != "" && c.expectedReceivers != receiversSlug { fmt.Printf("WARNING: Expected receivers did not match resolved receivers.\n") os.Exit(1) } return err } ================================================ FILE: cli/test_routing_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "fmt" "reflect" "strings" "testing" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/dispatch" ) type routingTestDefinition struct { alert models.LabelSet expectedReceivers []string configFile string } func checkResolvedReceivers(mainRoute *dispatch.Route, ls models.LabelSet, expectedReceivers []string) error { resolvedReceivers, err := resolveAlertReceivers(mainRoute, &ls) if err != nil { return err } if !reflect.DeepEqual(expectedReceivers, resolvedReceivers) { return fmt.Errorf("unexpected routing result want: `%s`, got: `%s`", strings.Join(expectedReceivers, ","), strings.Join(resolvedReceivers, ",")) } return nil } func TestRoutingTest(t *testing.T) { tests := []*routingTestDefinition{ {configFile: "testdata/conf.routing.yml", alert: models.LabelSet{"test": "1"}, expectedReceivers: []string{"test1"}}, {configFile: "testdata/conf.routing.yml", alert: models.LabelSet{"test": "2"}, expectedReceivers: []string{"test1", "test2"}}, {configFile: "testdata/conf.routing-reverted.yml", alert: models.LabelSet{"test": "2"}, expectedReceivers: []string{"test2", "test1"}}, {configFile: "testdata/conf.routing.yml", alert: models.LabelSet{"test": "volovina"}, expectedReceivers: []string{"default"}}, } for _, test := range tests { cfg, err := config.LoadFile(test.configFile) if err != nil { t.Fatalf("failed to load test configuration: %v", err) } mainRoute := dispatch.NewRoute(cfg.Route, nil) err = checkResolvedReceivers(mainRoute, test.alert, test.expectedReceivers) if err != nil { t.Fatalf("%v", err) } fmt.Println(" OK") } } ================================================ FILE: cli/testdata/conf.bad.yml ================================================ BAD ================================================ FILE: cli/testdata/conf.good.yml ================================================ global: smtp_smarthost: 'localhost:25' templates: - '/etc/alertmanager/template/*.tmpl' route: receiver: default receivers: - name: default ================================================ FILE: cli/testdata/conf.routing-reverted.yml ================================================ global: smtp_smarthost: 'localhost:25' templates: - '/etc/alertmanager/template/*.tmpl' route: receiver: default routes: - match: test: 2 receiver: test2 continue: true - match_re: test: ^[12]$ receiver: test1 continue: true receivers: - name: default - name: test1 - name: test2 ================================================ FILE: cli/testdata/conf.routing.yml ================================================ global: smtp_smarthost: 'localhost:25' templates: - '/etc/alertmanager/template/*.tmpl' route: receiver: default routes: - match_re: test: ^[12]$ receiver: test1 continue: true - match: test: 2 receiver: test2 receivers: - name: default - name: test1 - name: test2 ================================================ FILE: cli/utils.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cli import ( "context" "errors" "fmt" "net/url" "os" "github.com/alecthomas/kingpin/v2" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/api/v2/client/general" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" ) // getRemoteAlertmanagerConfigStatus returns status responsecontaining configuration from remote Alertmanager. func getRemoteAlertmanagerConfigStatus(ctx context.Context, alertmanagerURL *url.URL) (*models.AlertmanagerStatus, error) { amclient := NewAlertmanagerClient(alertmanagerURL) params := general.NewGetStatusParams().WithContext(ctx) getOk, err := amclient.General.GetStatus(params) if err != nil { return nil, err } return getOk.Payload, nil } func checkRoutingConfigInputFlags(alertmanagerURL *url.URL, configFile string) { if alertmanagerURL != nil && configFile != "" { fmt.Fprintln(os.Stderr, "Warning: --config.file flag overrides the --alertmanager.url.") } if alertmanagerURL == nil && configFile == "" { kingpin.Fatalf("You have to specify one of --config.file or --alertmanager.url flags.") } } func loadAlertmanagerConfig(ctx context.Context, alertmanagerURL *url.URL, configFile string) (*config.Config, error) { checkRoutingConfigInputFlags(alertmanagerURL, configFile) if configFile != "" { cfg, err := config.LoadFile(configFile) if err != nil { return nil, err } return cfg, nil } if alertmanagerURL == nil { return nil, errors.New("failed to get Alertmanager configuration") } configStatus, err := getRemoteAlertmanagerConfigStatus(ctx, alertmanagerURL) if err != nil { return nil, err } return config.Load(*configStatus.Config.Original) } // convertClientToCommonLabelSet converts client.LabelSet to model.Labelset. func convertClientToCommonLabelSet(cls models.LabelSet) model.LabelSet { mls := make(model.LabelSet, len(cls)) for ln, lv := range cls { mls[model.LabelName(ln)] = model.LabelValue(lv) } return mls } // TypeMatchers only valid for when you are going to add a silence. func TypeMatchers(matchers []labels.Matcher) models.Matchers { typeMatchers := make(models.Matchers, len(matchers)) for i, matcher := range matchers { typeMatchers[i] = TypeMatcher(matcher) } return typeMatchers } // TypeMatcher only valid for when you are going to add a silence. func TypeMatcher(matcher labels.Matcher) *models.Matcher { name := matcher.Name value := matcher.Value typeMatcher := models.Matcher{ Name: &name, Value: &value, } isEqual := (matcher.Type == labels.MatchEqual) || (matcher.Type == labels.MatchRegexp) isRegex := (matcher.Type == labels.MatchRegexp) || (matcher.Type == labels.MatchNotRegexp) typeMatcher.IsEqual = &isEqual typeMatcher.IsRegex = &isRegex return &typeMatcher } // Helper function for adding the ctx with timeout into an action. func execWithTimeout(fn func(context.Context, *kingpin.ParseContext) error) func(*kingpin.ParseContext) error { return func(x *kingpin.ParseContext) error { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() return fn(ctx, x) } } ================================================ FILE: cluster/advertise.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "errors" "fmt" "net" "github.com/hashicorp/go-sockaddr" ) type getIPFunc func() (string, error) // These are overridden in unit tests to mock the sockaddr functions. var ( getPrivateAddress getIPFunc = sockaddr.GetPrivateIP getPublicAddress getIPFunc = sockaddr.GetPublicIP ) // calculateAdvertiseAddress attempts to clone logic from deep within memberlist // (NetTransport.FinalAdvertiseAddr) in order to surface its conclusions to the // application, so we can provide more actionable error messages if the user has // inadvertently misconfigured their cluster. // // https://github.com/hashicorp/memberlist/blob/022f081/net_transport.go#L126 func calculateAdvertiseAddress(bindAddr, advertiseAddr string, allowInsecureAdvertise bool) (net.IP, error) { if advertiseAddr != "" { ip := net.ParseIP(advertiseAddr) if ip == nil { return nil, fmt.Errorf("failed to parse advertise addr '%s'", advertiseAddr) } if ip4 := ip.To4(); ip4 != nil { ip = ip4 } return ip, nil } if isAny(bindAddr) { return discoverAdvertiseAddress(allowInsecureAdvertise) } ip := net.ParseIP(bindAddr) if ip == nil { return nil, fmt.Errorf("failed to parse bind addr '%s'", bindAddr) } return ip, nil } // discoverAdvertiseAddress will attempt to get a single IP address to use as // the advertise address when one is not explicitly provided. It defaults to // using a private IP address, and if not found then using a public IP if // insecure advertising is allowed. func discoverAdvertiseAddress(allowInsecureAdvertise bool) (net.IP, error) { addr, err := getPrivateAddress() if err != nil { return nil, fmt.Errorf("failed to get private IP: %w", err) } if addr == "" && !allowInsecureAdvertise { return nil, errors.New("no private IP found, explicit advertise addr not provided") } if addr == "" { addr, err = getPublicAddress() if err != nil { return nil, fmt.Errorf("failed to get public IP: %w", err) } if addr == "" { return nil, errors.New("no private/public IP found, explicit advertise addr not provided") } } ip := net.ParseIP(addr) if ip == nil { return nil, fmt.Errorf("failed to parse discovered IP '%s'", addr) } return ip, nil } ================================================ FILE: cluster/advertise_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "errors" "net" "testing" "github.com/stretchr/testify/require" ) func TestCalculateAdvertiseAddress(t *testing.T) { old := getPrivateAddress defer func() { getPrivateAddress = old }() cases := []struct { name string privateIPFn getIPFunc publicIPFn getIPFunc bind, advertise string allowInsecureAdvertise bool expectedIP net.IP err bool }{ { name: "use provided bind address", bind: "192.0.2.1", advertise: "", expectedIP: net.ParseIP("192.0.2.1"), err: false, }, { name: "use provided advertise address", bind: "192.0.2.1", advertise: "192.0.2.2", expectedIP: net.ParseIP("192.0.2.2"), err: false, }, { name: "discover private ip address", privateIPFn: func() (string, error) { return "192.0.2.1", nil }, bind: "0.0.0.0", advertise: "", expectedIP: net.ParseIP("192.0.2.1"), err: false, }, { name: "error if getPrivateAddress errors", privateIPFn: func() (string, error) { return "", errors.New("some error") }, bind: "0.0.0.0", advertise: "", err: true, }, { name: "error if getPrivateAddress returns an invalid address", privateIPFn: func() (string, error) { return "invalid", nil }, bind: "0.0.0.0", advertise: "", err: true, }, { name: "error if getPrivateAddress returns an empty address", privateIPFn: func() (string, error) { return "", nil }, bind: "0.0.0.0", advertise: "", err: true, }, { name: "discover public advertise address", privateIPFn: func() (string, error) { return "", nil }, publicIPFn: func() (string, error) { return "192.0.2.1", nil }, bind: "0.0.0.0", advertise: "", allowInsecureAdvertise: true, expectedIP: net.ParseIP("192.0.2.1"), err: false, }, { name: "error if getPublicAddress errors", privateIPFn: func() (string, error) { return "", nil }, publicIPFn: func() (string, error) { return "", errors.New("some error") }, bind: "0.0.0.0", advertise: "", allowInsecureAdvertise: true, err: true, }, { name: "error if getPublicAddress returns an invalid address", privateIPFn: func() (string, error) { return "", nil }, publicIPFn: func() (string, error) { return "invalid", nil }, bind: "0.0.0.0", advertise: "", allowInsecureAdvertise: true, err: true, }, { name: "error if getPublicAddress returns an empty address", privateIPFn: func() (string, error) { return "", nil }, publicIPFn: func() (string, error) { return "", nil }, bind: "0.0.0.0", advertise: "", allowInsecureAdvertise: true, err: true, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { getPrivateAddress = c.privateIPFn getPublicAddress = c.publicIPFn got, err := calculateAdvertiseAddress(c.bind, c.advertise, c.allowInsecureAdvertise) if c.err { require.Error(t, err) return } require.NoError(t, err) require.Equal(t, c.expectedIP.String(), got.String()) }) } } ================================================ FILE: cluster/channel.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "log/slog" "sync" "time" "github.com/hashicorp/memberlist" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "google.golang.org/protobuf/proto" "github.com/prometheus/alertmanager/cluster/clusterpb" ) // Channel allows clients to send messages for a specific state type that will be // broadcasted in a best-effort manner. type Channel struct { key string send func([]byte) peers func() []*memberlist.Node sendOversize func(*memberlist.Node, []byte) error msgc chan []byte logger *slog.Logger oversizeGossipMessageFailureTotal prometheus.Counter oversizeGossipMessageDroppedTotal prometheus.Counter oversizeGossipMessageSentTotal prometheus.Counter oversizeGossipDuration prometheus.Histogram } // NewChannel creates a new Channel struct, which handles sending normal and // oversize messages to peers. func NewChannel( key string, send func([]byte), peers func() []*memberlist.Node, sendOversize func(*memberlist.Node, []byte) error, logger *slog.Logger, stopc <-chan struct{}, reg prometheus.Registerer, ) *Channel { if reg == nil { return nil } oversizeGossipMessageFailureTotal := promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_oversized_gossip_message_failure_total", Help: "Number of oversized gossip message sends that failed.", ConstLabels: prometheus.Labels{"key": key}, }) oversizeGossipMessageSentTotal := promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_oversized_gossip_message_sent_total", Help: "Number of oversized gossip message sent.", ConstLabels: prometheus.Labels{"key": key}, }) oversizeGossipMessageDroppedTotal := promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_oversized_gossip_message_dropped_total", Help: "Number of oversized gossip messages that were dropped due to a full message queue.", ConstLabels: prometheus.Labels{"key": key}, }) oversizeGossipDuration := promauto.With(reg).NewHistogram(prometheus.HistogramOpts{ Name: "alertmanager_oversize_gossip_message_duration_seconds", Help: "Duration of oversized gossip message requests.", ConstLabels: prometheus.Labels{"key": key}, Buckets: prometheus.DefBuckets, NativeHistogramBucketFactor: 1.1, NativeHistogramMaxBucketNumber: 100, NativeHistogramMinResetDuration: 1 * time.Hour, }) c := &Channel{ key: key, send: send, peers: peers, logger: logger, msgc: make(chan []byte, 200), sendOversize: sendOversize, oversizeGossipMessageFailureTotal: oversizeGossipMessageFailureTotal, oversizeGossipMessageDroppedTotal: oversizeGossipMessageDroppedTotal, oversizeGossipMessageSentTotal: oversizeGossipMessageSentTotal, oversizeGossipDuration: oversizeGossipDuration, } go c.handleOverSizedMessages(stopc) return c } // handleOverSizedMessages prevents memberlist from opening too many parallel // TCP connections to its peers. func (c *Channel) handleOverSizedMessages(stopc <-chan struct{}) { var wg sync.WaitGroup for { select { case b := <-c.msgc: for _, n := range c.peers() { wg.Add(1) go func(n *memberlist.Node) { defer wg.Done() c.oversizeGossipMessageSentTotal.Inc() start := time.Now() if err := c.sendOversize(n, b); err != nil { c.logger.Debug("failed to send reliable", "key", c.key, "node", n, "err", err) c.oversizeGossipMessageFailureTotal.Inc() return } c.oversizeGossipDuration.Observe(time.Since(start).Seconds()) }(n) } wg.Wait() case <-stopc: return } } } // Broadcast enqueues a message for broadcasting. func (c *Channel) Broadcast(b []byte) { b, err := proto.Marshal(&clusterpb.Part{Key: c.key, Data: b}) if err != nil { return } if OversizedMessage(b) { select { case c.msgc <- b: default: c.logger.Debug("oversized gossip channel full") c.oversizeGossipMessageDroppedTotal.Inc() } } else { c.send(b) } } // OversizedMessage indicates whether or not the byte payload should be sent // via TCP. func OversizedMessage(b []byte) bool { return len(b) > MaxGossipPacketSize/2 } ================================================ FILE: cluster/channel_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "bytes" "context" "io" "os" "testing" "github.com/hashicorp/memberlist" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/promslog" ) func TestNormalMessagesGossiped(t *testing.T) { var sent bool c := newChannel( func(_ []byte) { sent = true }, func() []*memberlist.Node { return nil }, func(_ *memberlist.Node, _ []byte) error { return nil }, ) c.Broadcast([]byte{}) if sent != true { t.Fatalf("small message not sent") } } func TestOversizedMessagesGossiped(t *testing.T) { var sent bool ctx, cancel := context.WithCancel(context.Background()) c := newChannel( func(_ []byte) {}, func() []*memberlist.Node { return []*memberlist.Node{{}} }, func(_ *memberlist.Node, _ []byte) error { sent = true; cancel(); return nil }, ) f, err := os.Open("/dev/zero") if err != nil { t.Fatalf("failed to open /dev/zero: %v", err) } defer f.Close() buf := new(bytes.Buffer) toCopy := int64(800) if n, err := io.CopyN(buf, f, toCopy); err != nil { t.Fatalf("failed to copy bytes: %v", err) } else if n != toCopy { t.Fatalf("wanted to copy %d bytes, only copied %d", toCopy, n) } c.Broadcast(buf.Bytes()) <-ctx.Done() if sent != true { t.Fatalf("oversized message not sent") } } func newChannel( send func([]byte), peers func() []*memberlist.Node, sendOversize func(*memberlist.Node, []byte) error, ) *Channel { return NewChannel( "test", send, peers, sendOversize, promslog.NewNopLogger(), make(chan struct{}), prometheus.NewRegistry(), ) } ================================================ FILE: cluster/cluster.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "context" "crypto/rand" "errors" "fmt" "log/slog" "net" "sort" "strconv" "strings" "sync" "time" "github.com/hashicorp/memberlist" "github.com/oklog/ulid/v2" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) // ClusterPeer represents a single Peer in a gossip cluster. type ClusterPeer interface { // Name returns the unique identifier of this peer in the cluster. Name() string // Status returns a status string representing the peer state. Status() string // Peers returns the peer nodes in the cluster. Peers() []ClusterMember } // ClusterMember interface that represents node peers in a cluster. type ClusterMember interface { // Name returns the name of the node Name() string // Address returns the IP address of the node Address() string } // ClusterChannel supports state broadcasting across peers. type ClusterChannel interface { Broadcast([]byte) } // Peer is a single peer in a gossip cluster. type Peer struct { mlist *memberlist.Memberlist delegate *delegate resolvedPeers []string resolvePeersTimeout time.Duration mtx sync.RWMutex states map[string]State stopc chan struct{} readyc chan struct{} peerLock sync.RWMutex peers map[string]peer failedPeers []peer knownPeers []string advertiseAddr string failedReconnectionsCounter prometheus.Counter reconnectionsCounter prometheus.Counter failedRefreshCounter prometheus.Counter refreshCounter prometheus.Counter peerLeaveCounter prometheus.Counter peerUpdateCounter prometheus.Counter peerJoinCounter prometheus.Counter logger *slog.Logger } // peer is an internal type used for bookkeeping. It holds the state of peers // in the cluster. type peer struct { status PeerStatus leaveTime time.Time *memberlist.Node } // PeerStatus is the state that a peer is in. type PeerStatus int const ( StatusNone PeerStatus = iota StatusAlive StatusFailed ) func (s PeerStatus) String() string { switch s { case StatusNone: return "none" case StatusAlive: return "alive" case StatusFailed: return "failed" default: panic(fmt.Sprintf("unknown PeerStatus: %d", s)) } } const ( DefaultPushPullInterval = 60 * time.Second DefaultGossipInterval = 200 * time.Millisecond DefaultTCPTimeout = 10 * time.Second DefaultProbeTimeout = 500 * time.Millisecond DefaultProbeInterval = 1 * time.Second DefaultReconnectInterval = 10 * time.Second DefaultReconnectTimeout = 6 * time.Hour DefaultRefreshInterval = 15 * time.Second DefaultResolvePeersTimeout = 15 * time.Second MaxGossipPacketSize = 1400 ) func Create( l *slog.Logger, reg prometheus.Registerer, bindAddr string, advertiseAddr string, knownPeers []string, waitIfEmpty bool, pushPullInterval time.Duration, gossipInterval time.Duration, tcpTimeout time.Duration, resolveTimeout time.Duration, probeTimeout time.Duration, probeInterval time.Duration, tlsTransportConfig *TLSTransportConfig, allowInsecureAdvertise bool, label string, name string, ) (*Peer, error) { bindHost, bindPortStr, err := net.SplitHostPort(bindAddr) if err != nil { return nil, fmt.Errorf("invalid listen address: %w", err) } bindPort, err := strconv.Atoi(bindPortStr) if err != nil { return nil, fmt.Errorf("address %s: invalid port: %w", bindAddr, err) } var advertiseHost string var advertisePort int if advertiseAddr != "" { var advertisePortStr string advertiseHost, advertisePortStr, err = net.SplitHostPort(advertiseAddr) if err != nil { return nil, fmt.Errorf("invalid advertise address: %w", err) } advertisePort, err = strconv.Atoi(advertisePortStr) if err != nil { return nil, fmt.Errorf("address %s: invalid port: %w", advertiseAddr, err) } } ctx, cancel := context.WithTimeout(context.Background(), resolveTimeout) defer cancel() resolvedPeers, err := resolvePeers(ctx, knownPeers, advertiseAddr, &net.Resolver{}, waitIfEmpty) if err != nil { return nil, fmt.Errorf("resolve peers: %w", err) } l.Debug("resolved peers to following addresses", "peers", strings.Join(resolvedPeers, ",")) // Initial validation of user-specified advertise address. addr, err := calculateAdvertiseAddress(bindHost, advertiseHost, allowInsecureAdvertise) if err != nil { l.Warn("couldn't deduce an advertise address: " + err.Error()) } else if hasNonlocal(resolvedPeers) && isUnroutable(addr.String()) { l.Warn("this node advertises itself on an unroutable address", "addr", addr.String()) l.Warn("this node will be unreachable in the cluster") l.Warn("provide --cluster.advertise-address as a routable IP address or hostname") } else if isAny(bindAddr) && advertiseHost == "" { // memberlist doesn't advertise properly when the bind address is empty or unspecified. l.Info("setting advertise address explicitly", "addr", addr.String(), "port", bindPort) advertiseHost = addr.String() advertisePort = bindPort } // Generate a random name if none is provided. if name == "" { id, err := ulid.New(ulid.Now(), rand.Reader) if err != nil { return nil, err } name = id.String() } p := &Peer{ states: map[string]State{}, stopc: make(chan struct{}), readyc: make(chan struct{}), logger: l, peers: map[string]peer{}, resolvedPeers: resolvedPeers, resolvePeersTimeout: resolveTimeout, knownPeers: knownPeers, } p.register(reg, name) retransmit := max(len(knownPeers)/2, 3) p.delegate = newDelegate(l, reg, p, retransmit) cfg := memberlist.DefaultLANConfig() cfg.Name = name cfg.BindAddr = bindHost cfg.BindPort = bindPort cfg.Delegate = p.delegate cfg.Ping = p.delegate cfg.Alive = p.delegate cfg.Events = p.delegate cfg.Conflict = p.delegate cfg.GossipInterval = gossipInterval cfg.PushPullInterval = pushPullInterval cfg.TCPTimeout = tcpTimeout cfg.ProbeTimeout = probeTimeout cfg.ProbeInterval = probeInterval cfg.Logger = slog.NewLogLogger(l.Handler(), slog.LevelDebug) cfg.GossipNodes = retransmit cfg.UDPBufferSize = MaxGossipPacketSize cfg.Label = label if advertiseHost != "" { cfg.AdvertiseAddr = advertiseHost cfg.AdvertisePort = advertisePort p.setInitialFailed(resolvedPeers, fmt.Sprintf("%s:%d", advertiseHost, advertisePort)) } else { p.setInitialFailed(resolvedPeers, bindAddr) } if tlsTransportConfig != nil { l.Info("using TLS for gossip") cfg.Transport, err = NewTLSTransport(context.Background(), l, reg, cfg.BindAddr, cfg.BindPort, tlsTransportConfig) if err != nil { return nil, fmt.Errorf("tls transport: %w", err) } } ml, err := memberlist.Create(cfg) if err != nil { return nil, fmt.Errorf("create memberlist: %w", err) } p.mlist = ml return p, nil } func (p *Peer) Join( reconnectInterval time.Duration, reconnectTimeout time.Duration, ) error { n, err := p.mlist.Join(p.resolvedPeers) if err != nil { p.logger.Warn("failed to join cluster", "err", err) if reconnectInterval != 0 { p.logger.Info(fmt.Sprintf("will retry joining cluster every %v", reconnectInterval.String())) } } else { p.logger.Debug("joined cluster", "peers", n) } if reconnectInterval != 0 { go p.runPeriodicTask( reconnectInterval, p.reconnect, ) } if reconnectTimeout != 0 { go p.runPeriodicTask( 5*time.Minute, func() { p.removeFailedPeers(reconnectTimeout) }, ) } go p.runPeriodicTask( DefaultRefreshInterval, p.refresh, ) return err } // All peers are initially added to the failed list. They will be removed from // this list in peerJoin when making their initial connection. func (p *Peer) setInitialFailed(peers []string, myAddr string) { if len(peers) == 0 { return } p.peerLock.Lock() defer p.peerLock.Unlock() now := time.Now() for _, peerAddr := range peers { if peerAddr == myAddr { // Don't add ourselves to the initially failing list, // we don't connect to ourselves. continue } host, port, err := net.SplitHostPort(peerAddr) if err != nil { continue } ip := net.ParseIP(host) if ip == nil { // Don't add textual addresses since memberlist only advertises // dotted decimal or IPv6 addresses. continue } portUint, err := strconv.ParseUint(port, 10, 16) if err != nil { continue } pr := peer{ status: StatusFailed, leaveTime: now, Node: &memberlist.Node{ Addr: ip, Port: uint16(portUint), }, } p.failedPeers = append(p.failedPeers, pr) p.peers[peerAddr] = pr } } func (p *Peer) register(reg prometheus.Registerer, name string) { peerInfo := promauto.With(reg).NewGauge( prometheus.GaugeOpts{ Name: "alertmanager_cluster_peer_info", Help: "A metric with a constant '1' value labeled by peer name.", ConstLabels: prometheus.Labels{"peer": name}, }, ) peerInfo.Set(1) promauto.With(reg).NewGaugeFunc(prometheus.GaugeOpts{ Name: "alertmanager_cluster_failed_peers", Help: "Number indicating the current number of failed peers in the cluster.", }, func() float64 { p.peerLock.RLock() defer p.peerLock.RUnlock() return float64(len(p.failedPeers)) }) p.failedReconnectionsCounter = promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_reconnections_failed_total", Help: "A counter of the number of failed cluster peer reconnection attempts.", }) p.reconnectionsCounter = promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_reconnections_total", Help: "A counter of the number of cluster peer reconnections.", }) p.failedRefreshCounter = promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_refresh_join_failed_total", Help: "A counter of the number of failed cluster peer joined attempts via refresh.", }) p.refreshCounter = promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_refresh_join_total", Help: "A counter of the number of cluster peer joined via refresh.", }) p.peerLeaveCounter = promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_peers_left_total", Help: "A counter of the number of peers that have left.", }) p.peerUpdateCounter = promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_peers_update_total", Help: "A counter of the number of peers that have updated metadata.", }) p.peerJoinCounter = promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_peers_joined_total", Help: "A counter of the number of peers that have joined.", }) } func (p *Peer) runPeriodicTask(d time.Duration, f func()) { tick := time.NewTicker(d) defer tick.Stop() for { select { case <-p.stopc: return case <-tick.C: f() } } } func (p *Peer) removeFailedPeers(timeout time.Duration) { p.peerLock.Lock() defer p.peerLock.Unlock() now := time.Now() keep := make([]peer, 0, len(p.failedPeers)) for _, pr := range p.failedPeers { if pr.leaveTime.Add(timeout).After(now) { keep = append(keep, pr) } else { p.logger.Debug("failed peer has timed out", "peer", pr.Node, "addr", pr.Address()) delete(p.peers, pr.Name) } } p.failedPeers = keep } func (p *Peer) reconnect() { p.peerLock.RLock() failedPeers := p.failedPeers p.peerLock.RUnlock() logger := p.logger.With("msg", "reconnect") for _, pr := range failedPeers { // No need to do book keeping on failedPeers here. If a // reconnect is successful, they will be announced in // peerJoin(). if _, err := p.mlist.Join([]string{pr.Address()}); err != nil { p.failedReconnectionsCounter.Inc() logger.Debug("failure", "peer", pr.Node, "addr", pr.Address(), "err", err) } else { p.reconnectionsCounter.Inc() logger.Debug("success", "peer", pr.Node, "addr", pr.Address()) } } } func (p *Peer) refresh() { logger := p.logger.With("msg", "refresh") ctx, cancel := context.WithTimeout(context.Background(), p.resolvePeersTimeout) defer cancel() resolvedPeers, err := resolvePeers(ctx, p.knownPeers, p.advertiseAddr, &net.Resolver{}, false) if err != nil { logger.Debug(fmt.Sprintf("%v", p.knownPeers), "err", err) return } members := p.mlist.Members() for _, peer := range resolvedPeers { var isPeerFound bool for _, member := range members { if member.Address() == peer { isPeerFound = true break } } if !isPeerFound { if _, err := p.mlist.Join([]string{peer}); err != nil { p.failedRefreshCounter.Inc() logger.Warn("failure", "addr", peer, "err", err) } else { p.refreshCounter.Inc() logger.Debug("success", "addr", peer) } } } } func (p *Peer) peerJoin(n *memberlist.Node) { p.peerLock.Lock() defer p.peerLock.Unlock() var oldStatus PeerStatus pr, ok := p.peers[n.Address()] if !ok { oldStatus = StatusNone pr = peer{ status: StatusAlive, Node: n, } } else { oldStatus = pr.status pr.Node = n pr.status = StatusAlive pr.leaveTime = time.Time{} } p.peers[n.Address()] = pr p.peerJoinCounter.Inc() if oldStatus == StatusFailed { p.logger.Debug("peer rejoined", "peer", pr.Node) p.failedPeers = removeOldPeer(p.failedPeers, pr.Address()) } } func (p *Peer) peerLeave(n *memberlist.Node) { p.peerLock.Lock() defer p.peerLock.Unlock() pr, ok := p.peers[n.Address()] if !ok { // Why are we receiving a leave notification from a node that // never joined? return } pr.status = StatusFailed pr.leaveTime = time.Now() p.failedPeers = append(p.failedPeers, pr) p.peers[n.Address()] = pr p.peerLeaveCounter.Inc() p.logger.Debug("peer left", "peer", pr.Node) } func (p *Peer) peerUpdate(n *memberlist.Node) { p.peerLock.Lock() defer p.peerLock.Unlock() pr, ok := p.peers[n.Address()] if !ok { // Why are we receiving an update from a node that never // joined? return } pr.Node = n p.peers[n.Address()] = pr p.peerUpdateCounter.Inc() p.logger.Debug("peer updated", "peer", pr.Node) } // AddState adds a new state that will be gossiped. It returns a channel to which // broadcast messages for the state can be sent. func (p *Peer) AddState(key string, s State, reg prometheus.Registerer) ClusterChannel { p.mtx.Lock() p.states[key] = s p.mtx.Unlock() send := func(b []byte) { p.delegate.bcast.QueueBroadcast(simpleBroadcast(b)) } peers := func() []*memberlist.Node { nodes := p.mlist.Members() for i, n := range nodes { if n.String() == p.Self().Name { nodes = append(nodes[:i], nodes[i+1:]...) break } } return nodes } sendOversize := func(n *memberlist.Node, b []byte) error { return p.mlist.SendReliable(n, b) } return NewChannel(key, send, peers, sendOversize, p.logger, p.stopc, reg) } // Leave the cluster, waiting up to timeout. func (p *Peer) Leave(timeout time.Duration) error { close(p.stopc) p.logger.Debug("leaving cluster") return p.mlist.Leave(timeout) } // Name returns the unique ID of this peer in the cluster. func (p *Peer) Name() string { return p.mlist.LocalNode().Name } // ClusterSize returns the current number of alive members in the cluster. func (p *Peer) ClusterSize() int { return p.mlist.NumMembers() } // Return true when router has settled. func (p *Peer) Ready() bool { select { case <-p.readyc: return true default: } return false } // Wait until Settle() has finished. func (p *Peer) WaitReady(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() case <-p.readyc: return nil } } // Return a status string representing the peer state. func (p *Peer) Status() string { if p.Ready() { return "ready" } return "settling" } // Info returns a JSON-serializable dump of cluster state. // Useful for debug. func (p *Peer) Info() map[string]any { p.mtx.RLock() defer p.mtx.RUnlock() return map[string]any{ "self": p.mlist.LocalNode(), "members": p.mlist.Members(), } } // Self returns the node information about the peer itself. func (p *Peer) Self() *memberlist.Node { return p.mlist.LocalNode() } // Member represents a member in the cluster. type Member struct { node *memberlist.Node } // Name implements cluster.ClusterMember. func (m Member) Name() string { return m.node.Name } // Address implements cluster.ClusterMember. func (m Member) Address() string { return m.node.Address() } // Peers returns the peers in the cluster. func (p *Peer) Peers() []ClusterMember { peers := make([]ClusterMember, 0, len(p.mlist.Members())) for _, member := range p.mlist.Members() { peers = append(peers, Member{ node: member, }) } return peers } // Position returns the position of the peer in the cluster. func (p *Peer) Position() int { all := p.mlist.Members() sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) k := 0 for _, n := range all { if n.Name == p.Self().Name { break } k++ } return k } // Settle waits until the mesh is ready (and sets the appropriate internal state when it is). // The idea is that we don't want to start "working" before we get a chance to know most of the alerts and/or silences. // Inspired from https://github.com/apache/cassandra/blob/7a40abb6a5108688fb1b10c375bb751cbb782ea4/src/java/org/apache/cassandra/gms/Gossiper.java // This is clearly not perfect or strictly correct but should prevent the alertmanager to send notification before it is obviously not ready. // This is especially important for those that do not have persistent storage. func (p *Peer) Settle(ctx context.Context, interval time.Duration) { const NumOkayRequired = 3 p.logger.Info("Waiting for gossip to settle...", "interval", interval) start := time.Now() nPeers := 0 nOkay := 0 totalPolls := 0 for { select { case <-ctx.Done(): elapsed := time.Since(start) p.logger.Info("gossip not settled but continuing anyway", "polls", totalPolls, "elapsed", elapsed) close(p.readyc) return case <-time.After(interval): } elapsed := time.Since(start) n := len(p.Peers()) if nOkay >= NumOkayRequired { p.logger.Info("gossip settled; proceeding", "elapsed", elapsed) break } if n == nPeers { nOkay++ p.logger.Debug("gossip looks settled", "elapsed", elapsed) } else { nOkay = 0 p.logger.Info("gossip not settled", "polls", totalPolls, "before", nPeers, "now", n, "elapsed", elapsed) } nPeers = n totalPolls++ } close(p.readyc) } // State is a piece of state that can be serialized and merged with other // serialized state. type State interface { // MarshalBinary serializes the underlying state. MarshalBinary() ([]byte, error) // Merge merges serialized state into the underlying state. Merge(b []byte) error } // We use a simple broadcast implementation in which items are never invalidated by others. type simpleBroadcast []byte func (b simpleBroadcast) Message() []byte { return []byte(b) } func (b simpleBroadcast) Invalidates(memberlist.Broadcast) bool { return false } func (b simpleBroadcast) Finished() {} func resolvePeers(ctx context.Context, peers []string, myAddress string, res *net.Resolver, waitIfEmpty bool) ([]string, error) { var resolvedPeers []string for _, peer := range peers { host, port, err := net.SplitHostPort(peer) if err != nil { return nil, fmt.Errorf("split host/port for peer %s: %w", peer, err) } retryCtx, cancel := context.WithCancel(ctx) defer cancel() ips, err := res.LookupIPAddr(ctx, host) if err != nil { // Assume direct address. resolvedPeers = append(resolvedPeers, peer) continue } if len(ips) == 0 { var lookupErrSpotted bool err := retry(2*time.Second, retryCtx.Done(), func() error { if lookupErrSpotted { // We need to invoke cancel in next run of retry when lookupErrSpotted to preserve LookupIPAddr error. cancel() } ips, err = res.LookupIPAddr(retryCtx, host) if err != nil { lookupErrSpotted = true return fmt.Errorf("IP Addr lookup for peer %s: %w", peer, err) } ips = removeMyAddr(ips, port, myAddress) if len(ips) == 0 { if !waitIfEmpty { return nil } return errors.New("empty IPAddr result. Retrying") } return nil }) if err != nil { return nil, err } } for _, ip := range ips { resolvedPeers = append(resolvedPeers, net.JoinHostPort(ip.String(), port)) } } return resolvedPeers, nil } func removeMyAddr(ips []net.IPAddr, targetPort, myAddr string) []net.IPAddr { var result []net.IPAddr for _, ip := range ips { if net.JoinHostPort(ip.String(), targetPort) == myAddr { continue } result = append(result, ip) } return result } func hasNonlocal(clusterPeers []string) bool { for _, peer := range clusterPeers { if host, _, err := net.SplitHostPort(peer); err == nil { peer = host } if ip := net.ParseIP(peer); ip != nil && !ip.IsLoopback() { return true } else if ip == nil && strings.ToLower(peer) != "localhost" { return true } } return false } func isUnroutable(addr string) bool { if host, _, err := net.SplitHostPort(addr); err == nil { addr = host } if ip := net.ParseIP(addr); ip != nil && (ip.IsUnspecified() || ip.IsLoopback()) { return true // typically 0.0.0.0 or localhost } else if ip == nil && strings.ToLower(addr) == "localhost" { return true } return false } func isAny(addr string) bool { if host, _, err := net.SplitHostPort(addr); err == nil { addr = host } return addr == "" || net.ParseIP(addr).IsUnspecified() } // retry executes f every interval seconds until timeout or no error is returned from f. func retry(interval time.Duration, stopc <-chan struct{}, f func() error) error { tick := time.NewTicker(interval) defer tick.Stop() var err error for { if err = f(); err == nil { return nil } select { case <-stopc: return err case <-tick.C: } } } func removeOldPeer(old []peer, addr string) []peer { new := make([]peer, 0, len(old)) for _, p := range old { if p.Address() != addr { new = append(new, p) } } return new } ================================================ FILE: cluster/cluster_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "context" "testing" "time" "github.com/hashicorp/go-sockaddr" "github.com/stretchr/testify/require" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/promslog" ) func TestClusterJoinAndReconnect(t *testing.T) { ip, _ := sockaddr.GetPrivateIP() if ip == "" { t.Skipf("skipping tests because no private IP address can be found") return } t.Run("TestJoinLeave", testJoinLeave) t.Run("TestReconnect", testReconnect) t.Run("TestRemoveFailedPeers", testRemoveFailedPeers) t.Run("TestInitiallyFailingPeers", testInitiallyFailingPeers) t.Run("TestTLSConnection", testTLSConnection) t.Run("TestRandomPeerNames", func(t *testing.T) { testPeerNames(t, "", "") }) t.Run("TestSetPeerNames", func(t *testing.T) { testPeerNames(t, "peer1", "peer2") }) t.Run("TestDuplicatePeerNames", func(t *testing.T) { testPeerNames(t, "peer", "peer") }) } func testJoinLeave(t *testing.T) { logger := promslog.NewNopLogger() p, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, nil, false, "", "", ) require.NoError(t, err) require.NotNil(t, p) err = p.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) require.False(t, p.Ready()) { ctx, cancel := context.WithCancel(context.Background()) cancel() require.Equal(t, context.Canceled, p.WaitReady(ctx)) } require.Equal(t, "settling", p.Status()) go p.Settle(context.Background(), 0*time.Second) require.NoError(t, p.WaitReady(context.Background())) require.Equal(t, "ready", p.Status()) // Create the peer who joins the first. p2, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{p.Self().Address()}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, nil, false, "", "", ) require.NoError(t, err) require.NotNil(t, p2) err = p2.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) go p2.Settle(context.Background(), 0*time.Second) require.NoError(t, p2.WaitReady(context.Background())) require.Eventually(t, func() bool { return p.ClusterSize() == 2 }, 5*time.Second, time.Second) p2.Leave(0 * time.Second) require.Eventually(t, func() bool { return p.ClusterSize() == 1 }, 5*time.Second, time.Second) require.Len(t, p.failedPeers, 1) require.Equal(t, p2.Self().Address(), p.peers[p2.Self().Address()].Address()) require.Equal(t, p2.Name(), p.failedPeers[0].Name) } func testReconnect(t *testing.T) { logger := promslog.NewNopLogger() p, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, nil, false, "", "", ) require.NoError(t, err) require.NotNil(t, p) err = p.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) go p.Settle(context.Background(), 0*time.Second) require.NoError(t, p.WaitReady(context.Background())) p2, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, nil, false, "", "", ) require.NoError(t, err) require.NotNil(t, p2) err = p2.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) go p2.Settle(context.Background(), 0*time.Second) require.NoError(t, p2.WaitReady(context.Background())) p.peerJoin(p2.Self()) p.peerLeave(p2.Self()) require.Equal(t, 1, p.ClusterSize()) require.Len(t, p.failedPeers, 1) p.reconnect() require.Equal(t, 2, p.ClusterSize()) require.Empty(t, p.failedPeers) require.Equal(t, StatusAlive, p.peers[p2.Self().Address()].status) } func testRemoveFailedPeers(t *testing.T) { logger := promslog.NewNopLogger() p, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, nil, false, "", "", ) require.NoError(t, err) require.NotNil(t, p) err = p.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) n := p.Self() now := time.Now() p1 := peer{ status: StatusFailed, leaveTime: now, Node: n, } p2 := peer{ status: StatusFailed, leaveTime: now.Add(-time.Hour), Node: n, } p3 := peer{ status: StatusFailed, leaveTime: now.Add(30 * -time.Minute), Node: n, } p.failedPeers = []peer{p1, p2, p3} p.removeFailedPeers(30 * time.Minute) require.Len(t, p.failedPeers, 1) require.Equal(t, p1, p.failedPeers[0]) } func testInitiallyFailingPeers(t *testing.T) { logger := promslog.NewNopLogger() myAddr := "1.2.3.4:5000" peerAddrs := []string{myAddr, "2.3.4.5:5000", "3.4.5.6:5000", "foo.example.com:5000"} p, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, nil, false, "", "", ) require.NoError(t, err) require.NotNil(t, p) err = p.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) p.setInitialFailed(peerAddrs, myAddr) // We shouldn't have added "our" bind addr and the FQDN address to the // failed peers list. require.Len(t, p.failedPeers, len(peerAddrs)-2) for _, addr := range peerAddrs { if addr == myAddr || addr == "foo.example.com:5000" { continue } pr, ok := p.peers[addr] require.True(t, ok) require.Equal(t, StatusFailed.String(), pr.status.String()) require.Equal(t, addr, pr.Address()) expectedLen := len(p.failedPeers) - 1 p.peerJoin(pr.Node) require.Len(t, p.failedPeers, expectedLen) } } func testTLSConnection(t *testing.T) { logger := promslog.NewNopLogger() tlsTransportConfig1, err := GetTLSTransportConfig("./testdata/tls_config_node1.yml") require.NoError(t, err) p1, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, tlsTransportConfig1, false, "", "", ) require.NoError(t, err) require.NotNil(t, p1) err = p1.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) require.False(t, p1.Ready()) require.Equal(t, "settling", p1.Status()) go p1.Settle(context.Background(), 0*time.Second) p1.WaitReady(context.Background()) require.Equal(t, "ready", p1.Status()) // Create the peer who joins the first. tlsTransportConfig2, err := GetTLSTransportConfig("./testdata/tls_config_node2.yml") require.NoError(t, err) p2, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{p1.Self().Address()}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, tlsTransportConfig2, false, "", "", ) require.NoError(t, err) require.NotNil(t, p2) err = p2.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) go p2.Settle(context.Background(), 0*time.Second) p2.WaitReady(context.Background()) require.Equal(t, "ready", p2.Status()) require.Eventually(t, func() bool { return p1.ClusterSize() == 2 }, 5*time.Second, time.Second) p2.Leave(0 * time.Second) require.Eventually(t, func() bool { return p1.ClusterSize() == 1 }, 5*time.Second, time.Second) require.Len(t, p1.failedPeers, 1) require.Equal(t, p2.Self().Address(), p1.peers[p2.Self().Address()].Address()) require.Equal(t, p2.Name(), p1.failedPeers[0].Name) } func testPeerNames(t *testing.T, name1, name2 string) { t.Helper() logger := promslog.NewNopLogger() p1, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, nil, false, "", name1, ) require.NoError(t, err) require.NotNil(t, p1) err = p1.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) require.False(t, p1.Ready()) { ctx, cancel := context.WithCancel(context.Background()) cancel() require.Equal(t, context.Canceled, p1.WaitReady(ctx)) } require.Equal(t, "settling", p1.Status()) go p1.Settle(context.Background(), 0*time.Second) require.NoError(t, p1.WaitReady(context.Background())) require.Equal(t, "ready", p1.Status()) // Create the peer who joins the first. p2, err := Create( logger, prometheus.NewRegistry(), "127.0.0.1:0", "", []string{p1.Self().Address()}, true, DefaultPushPullInterval, DefaultGossipInterval, DefaultTCPTimeout, DefaultResolvePeersTimeout, DefaultProbeTimeout, DefaultProbeInterval, nil, false, "", name2, ) require.NoError(t, err) require.NotNil(t, p2) err = p2.Join( DefaultReconnectInterval, DefaultReconnectTimeout, ) require.NoError(t, err) go p2.Settle(context.Background(), 0*time.Second) require.NoError(t, p2.WaitReady(context.Background())) if name1 != name2 { require.Eventually(t, func() bool { return p1.ClusterSize() == 2 }, 5*time.Second, time.Second) require.Eventually(t, func() bool { return p2.ClusterSize() == 2 }, 5*time.Second, time.Second) require.NotEqual(t, p1.Name(), p2.Name(), "peers should have different names") } } ================================================ FILE: cluster/clusterpb/cluster.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: cluster.proto package clusterpb import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type MemberlistMessage_Kind int32 const ( MemberlistMessage_STREAM MemberlistMessage_Kind = 0 MemberlistMessage_PACKET MemberlistMessage_Kind = 1 ) // Enum value maps for MemberlistMessage_Kind. var ( MemberlistMessage_Kind_name = map[int32]string{ 0: "STREAM", 1: "PACKET", } MemberlistMessage_Kind_value = map[string]int32{ "STREAM": 0, "PACKET": 1, } ) func (x MemberlistMessage_Kind) Enum() *MemberlistMessage_Kind { p := new(MemberlistMessage_Kind) *p = x return p } func (x MemberlistMessage_Kind) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (MemberlistMessage_Kind) Descriptor() protoreflect.EnumDescriptor { return file_cluster_proto_enumTypes[0].Descriptor() } func (MemberlistMessage_Kind) Type() protoreflect.EnumType { return &file_cluster_proto_enumTypes[0] } func (x MemberlistMessage_Kind) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use MemberlistMessage_Kind.Descriptor instead. func (MemberlistMessage_Kind) EnumDescriptor() ([]byte, []int) { return file_cluster_proto_rawDescGZIP(), []int{2, 0} } type Part struct { state protoimpl.MessageState `protogen:"open.v1"` Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Part) Reset() { *x = Part{} mi := &file_cluster_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Part) String() string { return protoimpl.X.MessageStringOf(x) } func (*Part) ProtoMessage() {} func (x *Part) ProtoReflect() protoreflect.Message { mi := &file_cluster_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Part.ProtoReflect.Descriptor instead. func (*Part) Descriptor() ([]byte, []int) { return file_cluster_proto_rawDescGZIP(), []int{0} } func (x *Part) GetKey() string { if x != nil { return x.Key } return "" } func (x *Part) GetData() []byte { if x != nil { return x.Data } return nil } type FullState struct { state protoimpl.MessageState `protogen:"open.v1"` Parts []*Part `protobuf:"bytes,1,rep,name=parts,proto3" json:"parts,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *FullState) Reset() { *x = FullState{} mi := &file_cluster_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *FullState) String() string { return protoimpl.X.MessageStringOf(x) } func (*FullState) ProtoMessage() {} func (x *FullState) ProtoReflect() protoreflect.Message { mi := &file_cluster_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use FullState.ProtoReflect.Descriptor instead. func (*FullState) Descriptor() ([]byte, []int) { return file_cluster_proto_rawDescGZIP(), []int{1} } func (x *FullState) GetParts() []*Part { if x != nil { return x.Parts } return nil } type MemberlistMessage struct { state protoimpl.MessageState `protogen:"open.v1"` Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` Kind MemberlistMessage_Kind `protobuf:"varint,2,opt,name=kind,proto3,enum=clusterpb.MemberlistMessage_Kind" json:"kind,omitempty"` FromAddr string `protobuf:"bytes,3,opt,name=from_addr,json=fromAddr,proto3" json:"from_addr,omitempty"` Msg []byte `protobuf:"bytes,4,opt,name=msg,proto3" json:"msg,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MemberlistMessage) Reset() { *x = MemberlistMessage{} mi := &file_cluster_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MemberlistMessage) String() string { return protoimpl.X.MessageStringOf(x) } func (*MemberlistMessage) ProtoMessage() {} func (x *MemberlistMessage) ProtoReflect() protoreflect.Message { mi := &file_cluster_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MemberlistMessage.ProtoReflect.Descriptor instead. func (*MemberlistMessage) Descriptor() ([]byte, []int) { return file_cluster_proto_rawDescGZIP(), []int{2} } func (x *MemberlistMessage) GetVersion() string { if x != nil { return x.Version } return "" } func (x *MemberlistMessage) GetKind() MemberlistMessage_Kind { if x != nil { return x.Kind } return MemberlistMessage_STREAM } func (x *MemberlistMessage) GetFromAddr() string { if x != nil { return x.FromAddr } return "" } func (x *MemberlistMessage) GetMsg() []byte { if x != nil { return x.Msg } return nil } var File_cluster_proto protoreflect.FileDescriptor const file_cluster_proto_rawDesc = "" + "\n" + "\rcluster.proto\x12\tclusterpb\",\n" + "\x04Part\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x12\n" + "\x04data\x18\x02 \x01(\fR\x04data\"2\n" + "\tFullState\x12%\n" + "\x05parts\x18\x01 \x03(\v2\x0f.clusterpb.PartR\x05parts\"\xb3\x01\n" + "\x11MemberlistMessage\x12\x18\n" + "\aversion\x18\x01 \x01(\tR\aversion\x125\n" + "\x04kind\x18\x02 \x01(\x0e2!.clusterpb.MemberlistMessage.KindR\x04kind\x12\x1b\n" + "\tfrom_addr\x18\x03 \x01(\tR\bfromAddr\x12\x10\n" + "\x03msg\x18\x04 \x01(\fR\x03msg\"\x1e\n" + "\x04Kind\x12\n" + "\n" + "\x06STREAM\x10\x00\x12\n" + "\n" + "\x06PACKET\x10\x01B6Z4github.com/prometheus/alertmanager/cluster/clusterpbb\x06proto3" var ( file_cluster_proto_rawDescOnce sync.Once file_cluster_proto_rawDescData []byte ) func file_cluster_proto_rawDescGZIP() []byte { file_cluster_proto_rawDescOnce.Do(func() { file_cluster_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_cluster_proto_rawDesc), len(file_cluster_proto_rawDesc))) }) return file_cluster_proto_rawDescData } var file_cluster_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_cluster_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_cluster_proto_goTypes = []any{ (MemberlistMessage_Kind)(0), // 0: clusterpb.MemberlistMessage.Kind (*Part)(nil), // 1: clusterpb.Part (*FullState)(nil), // 2: clusterpb.FullState (*MemberlistMessage)(nil), // 3: clusterpb.MemberlistMessage } var file_cluster_proto_depIdxs = []int32{ 1, // 0: clusterpb.FullState.parts:type_name -> clusterpb.Part 0, // 1: clusterpb.MemberlistMessage.kind:type_name -> clusterpb.MemberlistMessage.Kind 2, // [2:2] is the sub-list for method output_type 2, // [2:2] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name } func init() { file_cluster_proto_init() } func file_cluster_proto_init() { if File_cluster_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_cluster_proto_rawDesc), len(file_cluster_proto_rawDesc)), NumEnums: 1, NumMessages: 3, NumExtensions: 0, NumServices: 0, }, GoTypes: file_cluster_proto_goTypes, DependencyIndexes: file_cluster_proto_depIdxs, EnumInfos: file_cluster_proto_enumTypes, MessageInfos: file_cluster_proto_msgTypes, }.Build() File_cluster_proto = out.File file_cluster_proto_goTypes = nil file_cluster_proto_depIdxs = nil } ================================================ FILE: cluster/clusterpb/cluster.proto ================================================ syntax = "proto3"; package clusterpb; option go_package = "github.com/prometheus/alertmanager/cluster/clusterpb"; message Part { string key = 1; bytes data = 2; } message FullState { repeated Part parts = 1; } message MemberlistMessage { string version = 1; enum Kind { STREAM = 0; PACKET = 1; } Kind kind = 2; string from_addr = 3; bytes msg = 4; } ================================================ FILE: cluster/connection_pool.go ================================================ // Copyright 2020 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "crypto/tls" "errors" "fmt" "sync" "time" lru "github.com/hashicorp/golang-lru/v2" ) const capacity = 1024 type connectionPool struct { mtx sync.Mutex cache *lru.Cache[string, *tlsConn] tlsConfig *tls.Config } func newConnectionPool(tlsClientCfg *tls.Config) (*connectionPool, error) { cache, err := lru.NewWithEvict( capacity, func(_ string, conn *tlsConn) { conn.Close() }, ) if err != nil { return nil, fmt.Errorf("failed to create new LRU: %w", err) } return &connectionPool{ cache: cache, tlsConfig: tlsClientCfg, }, nil } // borrowConnection returns a *tlsConn from the pool. The connection does not // need to be returned to the pool because each connection has its own locking. func (pool *connectionPool) borrowConnection(addr string, timeout time.Duration) (*tlsConn, error) { pool.mtx.Lock() defer pool.mtx.Unlock() if pool.cache == nil { return nil, errors.New("connection pool closed") } key := fmt.Sprintf("%s/%d", addr, int64(timeout)) conn, exists := pool.cache.Get(key) if exists && conn.alive() { return conn, nil } conn, err := dialTLSConn(addr, timeout, pool.tlsConfig) if err != nil { return nil, err } pool.cache.Add(key, conn) return conn, nil } func (pool *connectionPool) shutdown() { pool.mtx.Lock() defer pool.mtx.Unlock() if pool.cache == nil { return } pool.cache.Purge() pool.cache = nil } ================================================ FILE: cluster/delegate.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "log/slog" "time" "github.com/hashicorp/memberlist" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "google.golang.org/protobuf/proto" "github.com/prometheus/alertmanager/cluster/clusterpb" ) const ( // Maximum number of messages to be held in the queue. maxQueueSize = 4096 fullState = "full_state" update = "update" ) // delegate implements memberlist.Delegate and memberlist.EventDelegate // and broadcasts its peer's state in the cluster. type delegate struct { *Peer logger *slog.Logger bcast *memberlist.TransmitLimitedQueue messagesReceived *prometheus.CounterVec messagesReceivedSize *prometheus.CounterVec messagesSent *prometheus.CounterVec messagesSentSize *prometheus.CounterVec messagesPruned prometheus.Counter nodeAlive *prometheus.CounterVec nodePingDuration *prometheus.HistogramVec conflictsCount prometheus.Counter } func newDelegate(l *slog.Logger, reg prometheus.Registerer, p *Peer, retransmit int) *delegate { bcast := &memberlist.TransmitLimitedQueue{ NumNodes: p.ClusterSize, RetransmitMult: retransmit, } messagesReceived := promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ Name: "alertmanager_cluster_messages_received_total", Help: "Total number of cluster messages received.", }, []string{"msg_type"}) messagesReceivedSize := promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ Name: "alertmanager_cluster_messages_received_size_total", Help: "Total size of cluster messages received.", }, []string{"msg_type"}) messagesSent := promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ Name: "alertmanager_cluster_messages_sent_total", Help: "Total number of cluster messages sent.", }, []string{"msg_type"}) messagesSentSize := promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ Name: "alertmanager_cluster_messages_sent_size_total", Help: "Total size of cluster messages sent.", }, []string{"msg_type"}) messagesPruned := promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_messages_pruned_total", Help: "Total number of cluster messages pruned.", }) promauto.With(reg).NewGaugeFunc(prometheus.GaugeOpts{ Name: "alertmanager_cluster_members", Help: "Number indicating current number of members in cluster.", }, func() float64 { return float64(p.ClusterSize()) }) promauto.With(reg).NewGaugeFunc(prometheus.GaugeOpts{ Name: "alertmanager_peer_position", Help: "Position the Alertmanager instance believes it's in. The position determines a peer's behavior in the cluster.", }, func() float64 { return float64(p.Position()) }) promauto.With(reg).NewGaugeFunc(prometheus.GaugeOpts{ Name: "alertmanager_cluster_health_score", Help: "Health score of the cluster. Lower values are better and zero means 'totally healthy'.", }, func() float64 { return float64(p.mlist.GetHealthScore()) }) promauto.With(reg).NewGaugeFunc(prometheus.GaugeOpts{ Name: "alertmanager_cluster_messages_queued", Help: "Number of cluster messages which are queued.", }, func() float64 { return float64(bcast.NumQueued()) }) nodeAlive := promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ Name: "alertmanager_cluster_alive_messages_total", Help: "Total number of received alive messages.", }, []string{"peer"}, ) nodePingDuration := promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ Name: "alertmanager_cluster_pings_seconds", Help: "Histogram of latencies for ping messages.", Buckets: []float64{.005, .01, .025, .05, .1, .25, .5}, NativeHistogramBucketFactor: 1.1, NativeHistogramMaxBucketNumber: 100, NativeHistogramMinResetDuration: 1 * time.Hour, }, []string{"peer"}, ) conflictsCount := promauto.With(reg).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_cluster_peer_name_conflicts_total", Help: "Total number of times memberlist has noticed conflicting peer names", }) messagesReceived.WithLabelValues(fullState) messagesReceivedSize.WithLabelValues(fullState) messagesReceived.WithLabelValues(update) messagesReceivedSize.WithLabelValues(update) messagesSent.WithLabelValues(fullState) messagesSentSize.WithLabelValues(fullState) messagesSent.WithLabelValues(update) messagesSentSize.WithLabelValues(update) d := &delegate{ logger: l, Peer: p, bcast: bcast, messagesReceived: messagesReceived, messagesReceivedSize: messagesReceivedSize, messagesSent: messagesSent, messagesSentSize: messagesSentSize, messagesPruned: messagesPruned, nodeAlive: nodeAlive, nodePingDuration: nodePingDuration, conflictsCount: conflictsCount, } go d.handleQueueDepth() return d } // NodeMeta retrieves meta-data about the current node when broadcasting an alive message. func (d *delegate) NodeMeta(limit int) []byte { return []byte{} } // NotifyMsg is the callback invoked when a user-level gossip message is received. func (d *delegate) NotifyMsg(b []byte) { d.messagesReceived.WithLabelValues(update).Inc() d.messagesReceivedSize.WithLabelValues(update).Add(float64(len(b))) var p clusterpb.Part if err := proto.Unmarshal(b, &p); err != nil { d.logger.Warn("decode broadcast", "err", err) return } d.mtx.RLock() s, ok := d.states[p.Key] d.mtx.RUnlock() if !ok { return } if err := s.Merge(p.Data); err != nil { d.logger.Warn("merge broadcast", "err", err, "key", p.Key) return } } // NotifyConflict is the callback when memberlist encounters two nodes with the same ID. func (d *delegate) NotifyConflict(existing, other *memberlist.Node) { d.logger.Warn("Found conflicting peer IDs", "peer", existing.Name) d.conflictsCount.Inc() } // GetBroadcasts is called when user data messages can be broadcasted. func (d *delegate) GetBroadcasts(overhead, limit int) [][]byte { msgs := d.bcast.GetBroadcasts(overhead, limit) d.messagesSent.WithLabelValues(update).Add(float64(len(msgs))) for _, m := range msgs { d.messagesSentSize.WithLabelValues(update).Add(float64(len(m))) } return msgs } // LocalState is called when gossip fetches local state. func (d *delegate) LocalState(_ bool) []byte { d.mtx.RLock() defer d.mtx.RUnlock() all := &clusterpb.FullState{ Parts: make([]*clusterpb.Part, 0, len(d.states)), } for key, s := range d.states { b, err := s.MarshalBinary() if err != nil { d.logger.Warn("encode local state", "err", err, "key", key) return nil } all.Parts = append(all.Parts, &clusterpb.Part{Key: key, Data: b}) } b, err := proto.Marshal(all) if err != nil { d.logger.Warn("encode local state", "err", err) return nil } d.messagesSent.WithLabelValues(fullState).Inc() d.messagesSentSize.WithLabelValues(fullState).Add(float64(len(b))) return b } func (d *delegate) MergeRemoteState(buf []byte, _ bool) { d.messagesReceived.WithLabelValues(fullState).Inc() d.messagesReceivedSize.WithLabelValues(fullState).Add(float64(len(buf))) var fs clusterpb.FullState if err := proto.Unmarshal(buf, &fs); err != nil { d.logger.Warn("merge remote state", "err", err) return } d.mtx.RLock() defer d.mtx.RUnlock() for _, p := range fs.Parts { s, ok := d.states[p.Key] if !ok { d.logger.Warn("unknown state key", "len", len(buf), "key", p.Key) continue } if err := s.Merge(p.Data); err != nil { d.logger.Warn("merge remote state", "err", err, "key", p.Key) return } } } // NotifyJoin is called if a peer joins the cluster. func (d *delegate) NotifyJoin(n *memberlist.Node) { d.logger.Debug("NotifyJoin", "node", n.Name, "addr", n.Address()) d.peerJoin(n) } // NotifyLeave is called if a peer leaves the cluster. func (d *delegate) NotifyLeave(n *memberlist.Node) { d.logger.Debug("NotifyLeave", "node", n.Name, "addr", n.Address()) d.peerLeave(n) } // NotifyUpdate is called if a cluster peer gets updated. func (d *delegate) NotifyUpdate(n *memberlist.Node) { d.logger.Debug("NotifyUpdate", "node", n.Name, "addr", n.Address()) d.peerUpdate(n) } // NotifyAlive implements the memberlist.AliveDelegate interface. func (d *delegate) NotifyAlive(peer *memberlist.Node) error { d.nodeAlive.WithLabelValues(peer.Name).Inc() return nil } // AckPayload implements the memberlist.PingDelegate interface. func (d *delegate) AckPayload() []byte { return []byte{} } // NotifyPingComplete implements the memberlist.PingDelegate interface. func (d *delegate) NotifyPingComplete(peer *memberlist.Node, rtt time.Duration, payload []byte) { d.nodePingDuration.WithLabelValues(peer.Name).Observe(rtt.Seconds()) } // handleQueueDepth ensures that the queue doesn't grow unbounded by pruning // older messages at regular interval. func (d *delegate) handleQueueDepth() { for { select { case <-d.stopc: return case <-time.After(15 * time.Minute): n := d.bcast.NumQueued() if n > maxQueueSize { d.logger.Warn("dropping messages because too many are queued", "current", n, "limit", maxQueueSize) d.bcast.Prune(maxQueueSize) d.messagesPruned.Add(float64(n - maxQueueSize)) } } } } ================================================ FILE: cluster/testdata/certs/ca-config.json ================================================ { "signing": { "default": { "expiry": "876000h" }, "profiles": { "massl": { "usages": ["signing", "key encipherment", "server auth", "client auth"], "expiry": "876000h" } } } } ================================================ FILE: cluster/testdata/certs/ca-csr.json ================================================ { "CN": "massl", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "AU", "L": "Melbourne", "O": "massl", "OU": "VIC", "ST": "Victoria" } ] } ================================================ FILE: cluster/testdata/certs/ca-key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAuljDjKVGwlyiuKTSHc1QpoZPX9dbgwU/9113ctI8U/ZwMWLp nZ4f/zVpf4LW5foM9zSEUGPiyJe/NaTZUOXkRBSIQ13QroK4OJ1XGacQKpTxewCb ChESZEfKWEhnP/Y7BYc4z1Li6Dkxh4TIElHwOVe62jbhNnzYlr4evmSuiuItAc8u hEYxncThPzmHEWPXKw8CFNhxCSYsjbb72UAIht0knMHQ7VXBX1VuuL0rolJBiToC va+I6CjG0c6qfi9/BcPsuW6cNjmQnwTg6SaSoGO/5zgbxBgy9MZQEot88d1T2XH6 rBANYsfojvyCXuytWnj04mvdAWwmFh0hhq+nxQIDAQABAoIBAQCwcL1vXUq7W4UD OaRtbWrQ0dk0ETBnxT/E0y33fRJ8GZovWM2EXSVEuukSP+uEQ5elNYeWqo0fi3cT ruvJSnMw9xPyXVDq+4C8slW3R1TqTK683VzvUizM4KC5qIyCpn1KBbgHrh6E7Sp1 e4cIuaawVN3qIg5qThmx2YA4nBIcEt68q9cpy3NgEe+EQf44zM/St+y8kSkDUOVw fNKX0WfZ/hPL1TAYpWiIgSf+m/V3d/1l/scvMYONcuSjXSORCyoeAWYtOQgf78wW 9j3kiBTaqDYCUZFnY/ltlZrm8ltAaKVJ0MmPKjVh8GJBXZp9fSVU8Y4ZIZRSeuBA OoStHGAdAoGBAMluMIE33hGny2V0dNzW23D84eXQK38AsdP632jQeuzxBknItg45 qAfzh8F8W10DQnSv5tj0bmOHfo0mG09bu9eo5nLLINOE7Ju/7ly/76RNJNJ4ADjx JKZi/PpvfP+s/fzel0X3OPgA+CJKzUHuqlU4V9BLc7focZAYtaM2w7rHAoGBAOzU eXpapkqYhbYRcsrVV57nZV0rLzsLVJBpJg2zC8un95ALrr0rlZfuPJfOCY/uuS1w f8ixRz2MkRWGreLHy35NB4GV0sF9VPn1jMp9SuBNvO0JRUMWuDAdVe8SCjXadrOh +m3yKJSkFKDchglUYnZKV1skgA/b9jjjnu2fvd0TAoGAVUTnFZxvzmuIp78fxWjS 5ka23hE8iHvjy4e00WsHzovNjKiBoQ35Orx16ItbJcm+dSUNhSQcItf104yhHPwJ Tab7PvcMQ15OxzP9lJfPu27Iuqv/9Bro1+Kpkt5lPNqffk9AHGcmX54RbHrb3yBI TOEYE14Nc3nbsRM0uQ3y13sCgYB5Om4QZpSWvKo9P4M+NqTKb3JglblwhOU9osVa 39ra3dkIgCJrLQM/KTEVF9+nMLDThLG0fqKT6/9cQHuECXet6Co+d/3RE6HK7Zmr ESWh2ckqoMM2i0uvPWT+ooJdfL2kR/bUDtAc/jyc9yUZY3ufR4Cd4/o1pAfOqR1y T4G1xwKBgQChE4VWawCVg2qanRjvZcdNk0zpZx4dxqqKYq/VHuSfjNLQixIZsgXT xx9BHuORn6c/nurqEStLwN3BzbpPU/j6YjMUmTslSH2sKhHwWNYGBZC52aJiOOda Bz6nAkihG0n2PjYt2T84w6FWHgLJuSsmiEVJcb+AOdyKh1MlzJiwMQ== -----END RSA PRIVATE KEY----- ================================================ FILE: cluster/testdata/certs/ca.csr ================================================ -----BEGIN CERTIFICATE REQUEST----- MIICpzCCAY8CAQAwYjELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIw EAYDVQQHEwlNZWxib3VybmUxDjAMBgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMx DjAMBgNVBAMTBW1hc3NsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA uljDjKVGwlyiuKTSHc1QpoZPX9dbgwU/9113ctI8U/ZwMWLpnZ4f/zVpf4LW5foM 9zSEUGPiyJe/NaTZUOXkRBSIQ13QroK4OJ1XGacQKpTxewCbChESZEfKWEhnP/Y7 BYc4z1Li6Dkxh4TIElHwOVe62jbhNnzYlr4evmSuiuItAc8uhEYxncThPzmHEWPX Kw8CFNhxCSYsjbb72UAIht0knMHQ7VXBX1VuuL0rolJBiToCva+I6CjG0c6qfi9/ BcPsuW6cNjmQnwTg6SaSoGO/5zgbxBgy9MZQEot88d1T2XH6rBANYsfojvyCXuyt Wnj04mvdAWwmFh0hhq+nxQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAJFmooMt TocElxCb3DGJTRUXxr4DqcATASIX35a2wV3MmPqUHHXr6BQkO/FRho66EsZf3DE/ mumou01K+KByxgsmw04CACjSeZ2t/g6pAsDCKrx/BwL3tAo09lG2Y2Ah0BND2Cta EZpTliU2MimZlk7UZb8VIXh2Tx56fZRoHLzO4U4+FY8ZR+tspxPRM7hLg/aUqA5D zGj6kByX8aYjxsmQokP4rx/w2mz6vwt4cZ1pXwr0RderkMIh9Har/0k9X1WIAP61 PNQx74qnaq+icjtN2+8gvJE/CJL/wfcwW6kQwEtX1xsTpnzyFaRoYpSPQrvkCtiW +WzgnOh7RvKyAYI= -----END CERTIFICATE REQUEST----- ================================================ FILE: cluster/testdata/certs/ca.pem ================================================ Certificate: Data: Version: 3 (0x2) Serial Number: 7a:d7:1c:f3:22:da:b1:20:31:bf:25:16:b6:04:d5:29:1e:a3:7c:12 Signature Algorithm: sha256WithRSAEncryption Issuer: C=AU, ST=Victoria, L=Melbourne, O=massl, OU=VIC, CN=massl Validity Not Before: Nov 6 22:02:17 2024 GMT Not After : Nov 1 22:02:17 2044 GMT Subject: C=AU, ST=Victoria, L=Melbourne, O=massl, OU=VIC, CN=massl Subject Public Key Info: Public Key Algorithm: rsaEncryption Public-Key: (2048 bit) Modulus: 00:ba:58:c3:8c:a5:46:c2:5c:a2:b8:a4:d2:1d:cd: 50:a6:86:4f:5f:d7:5b:83:05:3f:f7:5d:77:72:d2: 3c:53:f6:70:31:62:e9:9d:9e:1f:ff:35:69:7f:82: d6:e5:fa:0c:f7:34:84:50:63:e2:c8:97:bf:35:a4: d9:50:e5:e4:44:14:88:43:5d:d0:ae:82:b8:38:9d: 57:19:a7:10:2a:94:f1:7b:00:9b:0a:11:12:64:47: ca:58:48:67:3f:f6:3b:05:87:38:cf:52:e2:e8:39: 31:87:84:c8:12:51:f0:39:57:ba:da:36:e1:36:7c: d8:96:be:1e:be:64:ae:8a:e2:2d:01:cf:2e:84:46: 31:9d:c4:e1:3f:39:87:11:63:d7:2b:0f:02:14:d8: 71:09:26:2c:8d:b6:fb:d9:40:08:86:dd:24:9c:c1: d0:ed:55:c1:5f:55:6e:b8:bd:2b:a2:52:41:89:3a: 02:bd:af:88:e8:28:c6:d1:ce:aa:7e:2f:7f:05:c3: ec:b9:6e:9c:36:39:90:9f:04:e0:e9:26:92:a0:63: bf:e7:38:1b:c4:18:32:f4:c6:50:12:8b:7c:f1:dd: 53:d9:71:fa:ac:10:0d:62:c7:e8:8e:fc:82:5e:ec: ad:5a:78:f4:e2:6b:dd:01:6c:26:16:1d:21:86:af: a7:c5 Exponent: 65537 (0x10001) X509v3 extensions: X509v3 Key Usage: critical Certificate Sign, CRL Sign X509v3 Basic Constraints: critical CA:TRUE X509v3 Subject Key Identifier: 77:80:D3:12:52:AA:EA:09:C6:60:32:59:80:9B:C2:FB:87:E5:AD:90 Signature Algorithm: sha256WithRSAEncryption Signature Value: 92:f2:a4:8f:7d:04:f1:7e:08:b0:6b:3e:0c:b9:88:29:18:b6: ce:88:4e:84:b0:10:8b:ca:b5:d6:6a:fb:12:52:14:f2:4e:01: bb:b3:8b:a0:b4:65:d9:fd:d4:c7:6b:44:54:3a:e5:5b:c9:0e: bd:3c:3b:f7:41:0a:67:1d:5a:21:32:7c:42:3b:b1:37:b4:c0: 78:07:4b:ae:e2:18:77:90:85:33:70:46:20:61:1a:7a:67:38: 0a:cf:fc:1c:bd:d2:c6:1a:0e:09:5a:d5:36:74:8a:8e:66:0f: 1f:47:69:7a:17:a7:d3:bf:74:40:85:3f:80:a2:53:00:2a:65: 3c:3f:ca:44:d9:ec:71:cf:17:4e:3d:b0:1e:5e:e8:73:ab:0a: 27:95:02:88:2b:b0:46:9a:4d:a4:7d:05:ba:df:4c:e5:65:d3: 2b:12:fd:17:74:51:f2:bb:d1:0e:32:8c:e9:ee:42:5c:d7:3c: 85:60:f0:1a:52:fc:11:31:e1:12:8c:c9:a0:1f:1f:52:7e:d9: 1e:a0:c7:f7:48:05:9d:dc:f5:c1:59:5a:9b:e7:bd:a3:37:54: 8a:42:c7:10:d7:51:19:99:e2:e7:d3:56:66:18:4a:d0:d1:f6: 25:1d:c9:f9:48:60:43:cc:6f:9c:ba:95:03:3e:a0:5a:ad:26: d8:ce:4c:4a -----BEGIN CERTIFICATE----- MIIDlDCCAnygAwIBAgIUetcc8yLasSAxvyUWtgTVKR6jfBIwDQYJKoZIhvcNAQEL BQAwYjELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIwEAYDVQQHEwlN ZWxib3VybmUxDjAMBgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMxDjAMBgNVBAMT BW1hc3NsMB4XDTI0MTEwNjIyMDIxN1oXDTQ0MTEwMTIyMDIxN1owYjELMAkGA1UE BhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIwEAYDVQQHEwlNZWxib3VybmUxDjAM BgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMxDjAMBgNVBAMTBW1hc3NsMIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuljDjKVGwlyiuKTSHc1QpoZPX9db gwU/9113ctI8U/ZwMWLpnZ4f/zVpf4LW5foM9zSEUGPiyJe/NaTZUOXkRBSIQ13Q roK4OJ1XGacQKpTxewCbChESZEfKWEhnP/Y7BYc4z1Li6Dkxh4TIElHwOVe62jbh NnzYlr4evmSuiuItAc8uhEYxncThPzmHEWPXKw8CFNhxCSYsjbb72UAIht0knMHQ 7VXBX1VuuL0rolJBiToCva+I6CjG0c6qfi9/BcPsuW6cNjmQnwTg6SaSoGO/5zgb xBgy9MZQEot88d1T2XH6rBANYsfojvyCXuytWnj04mvdAWwmFh0hhq+nxQIDAQAB o0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU d4DTElKq6gnGYDJZgJvC+4flrZAwDQYJKoZIhvcNAQELBQADggEBAJLypI99BPF+ CLBrPgy5iCkYts6IToSwEIvKtdZq+xJSFPJOAbuzi6C0Zdn91MdrRFQ65VvJDr08 O/dBCmcdWiEyfEI7sTe0wHgHS67iGHeQhTNwRiBhGnpnOArP/By90sYaDgla1TZ0 io5mDx9HaXoXp9O/dECFP4CiUwAqZTw/ykTZ7HHPF049sB5e6HOrCieVAogrsEaa TaR9BbrfTOVl0ysS/Rd0UfK70Q4yjOnuQlzXPIVg8BpS/BEx4RKMyaAfH1J+2R6g x/dIBZ3c9cFZWpvnvaM3VIpCxxDXURmZ4ufTVmYYStDR9iUdyflIYEPMb5y6lQM+ oFqtJtjOTEo= -----END CERTIFICATE----- ================================================ FILE: cluster/testdata/certs/node1-csr.json ================================================ { "CN": "system:server", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "AU", "L": "Melbourne", "O": "system:node1", "OU": "massl", "ST": "Victoria" } ] } ================================================ FILE: cluster/testdata/certs/node1-key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEA1b9bm4rvDtpYsqgtCC52+L535d4/Q2O10fWD2i2CfRXXfYJQ 5cr4AV2iqScFsJSs7KwyQde/c4VWj/vEA2/SJHZFBlknKdCcrgHVebrvnzm6Guze ICutZSKocFXy9Kw+YmWuA64nHVfmSCKG07GhXhEsLsSCn4PTDYOiGAUm1GdSDxUp 8yUXec13Eb20mld0xE9kQnCnEWRnxMXtQJXoz9lpLc7DgXtN6nCXSG/CqdDPOduU nmseaxyAGpAFnUmxcqUuYAJUQ1hUOJhk0RVSsLTmu+FGdOxk79AxmmKQ2z9l/GuA VikVJGTxY4jRPezxHQ3bdqzzCIdJxTxLinftZQIDAQABAoIBADpxQtvphemau8vF feKRycfDVEcOmF+VoL4SkgWSke4fjbbsbbAW6e59qp7zY3PfgtSHVIp6Mgek+oEN xo9mAKAlkkPlFncxadWN/M921FPF1ePMxgMnzhYr/sAQUAikG76NrKGm+VzljrpE bnbtR4DP0zPKWSjCQ2+bgTNuHSrPwUtEngVT6ugjfWU1RitlvjTsZ9hSuOSBlS7P rjbQGaEh53PraDut8PIlF4wIF+nLeERFP/a6DC8Btpbv9P50YRosag6yU/G+OYX9 spvBPvRJGrubslKnNRz9AcjbVd3QhL+Tm7mV7iakK918jLWb95Ro4WW+9lT6IAi6 xRSOr9UCgYEA5wI3JhKkYa4PST7ALqmJSDkPH8+tctiEx+ovmnqBufFuLWFoO/rc EOYslnaZM3UVCnhrFv7+LxezSI5DyQu8dBEzf0RMICvXUNBkGC7ZJQL428fjXPhX 8mZIoJ0ol4hbamr8yTYlK0vGTwqN1bDj71w6NszuN4ecN1cKNWsMbnMCgYEA7N8Y MzHWNijMr7xZ1lXl4Ye9+kp3aTUjUYBBaxFr4bQ8y0fH48lzq3qOso5wgvp0DKYo uemD5QKbo81LKoTRLa+nxPq0UqKm9FiSWmnrcxMuph989oZ1ZFHA2B+nvbuMTF8J 8sESclTSbgkG87DpycJOUwG3XAcXM+80pXuzJscCgYB+Dzxu/09KqoRW8PJIxGVQ zypMrrS07iiPO2FcyCtQf8oi43vQ91Ttt91vAisZ5HNl8k5mDyJAKovANToSVOAy 6kwSz/9GswXdaMqmU7JVOyj4Lj0JN9AuS9ioJPrIrjVMfjORzYU8+i2uZlD94niP 3uE5lF0OWmdJ36qHefIftwKBgQDcPQZcO19H1iGS2FbTYeSvEK5ENM7YRG8FTXIF 4hnjrtjDzYb+tYVWEErznFrifYo/ZJMDYSqgWQ9reusDqqBvkR41mUDmgJMpJ91U MZ2YzmIWVbqz4QrvbtAWY0Bsuh/VtpwiWQAUy+coJj6PgJOvY3m91h+tcm5RfHz/ zIcjawKBgA6kDcOLOnWcvhP3XwtW5dcWlNuBBNEKna+kIT/5Vlrh91y2w7F54DNK i0w5CZCpbTugJmZ67XLHnfongC7e2vAQ3atoT96RU4mf9614qs9LMtGAbnuCLB8+ sT2rnaZKtzr83ensbYkbBxP/zmPBfFQ9FKcIYIA7En8zAIr2T3vJ -----END RSA PRIVATE KEY----- ================================================ FILE: cluster/testdata/certs/node1.csr ================================================ -----BEGIN CERTIFICATE REQUEST----- MIIC5TCCAc0CAQAwczELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIw EAYDVQQHEwlNZWxib3VybmUxFTATBgNVBAoTDHN5c3RlbTpub2RlMTEOMAwGA1UE CxMFbWFzc2wxFjAUBgNVBAMTDXN5c3RlbTpzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQDVv1ubiu8O2liyqC0ILnb4vnfl3j9DY7XR9YPaLYJ9 Fdd9glDlyvgBXaKpJwWwlKzsrDJB179zhVaP+8QDb9IkdkUGWScp0JyuAdV5uu+f Oboa7N4gK61lIqhwVfL0rD5iZa4DricdV+ZIIobTsaFeESwuxIKfg9MNg6IYBSbU Z1IPFSnzJRd5zXcRvbSaV3TET2RCcKcRZGfExe1AlejP2WktzsOBe03qcJdIb8Kp 0M8525Seax5rHIAakAWdSbFypS5gAlRDWFQ4mGTRFVKwtOa74UZ07GTv0DGaYpDb P2X8a4BWKRUkZPFjiNE97PEdDdt2rPMIh0nFPEuKd+1lAgMBAAGgLTArBgkqhkiG 9w0BCQ4xHjAcMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0B AQsFAAOCAQEAW/tTyJaBfWtbC9hYUmhh8lxUztv2+WT4xaR/jdQ46sk/87vKuwI6 4AkkGfiPLLqgW3xbQOwk5/ynRabttbsgTUHt744RtRFLzfcQKEBZoNPvrfHvmDil YqHIOx2SJ5hzIBwVlVSBn50hdSSED1Ip22DaU8GukzuacB8+2rhg3MOWJbKVt5aR 03H4XkAynLS1FHNOraDIv1eT58D3l4hanrNOZIa0xAuChd25qLO/JHvU/3wccGUA KNg3vGOy2Q8qVBrTFLn+yQHuOr/wSupXESO1jiI/h+txsBQnZ6oYfZnVJ+7o3Oln 3Hguw77aYeTAeZQPPbmJbDLegLG0ZC6RmA== -----END CERTIFICATE REQUEST----- ================================================ FILE: cluster/testdata/certs/node1.pem ================================================ -----BEGIN CERTIFICATE----- MIIEAjCCAuqgAwIBAgIUbYMGwSgQF8iRZ5xmhflInj8VZ0owDQYJKoZIhvcNAQEL BQAwYjELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIwEAYDVQQHEwlN ZWxib3VybmUxDjAMBgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMxDjAMBgNVBAMT BW1hc3NsMCAXDTIxMDUwNTE2MTYwMFoYDzIxMjEwNDExMTYxNjAwWjBzMQswCQYD VQQGEwJBVTERMA8GA1UECBMIVmljdG9yaWExEjAQBgNVBAcTCU1lbGJvdXJuZTEV MBMGA1UEChMMc3lzdGVtOm5vZGUxMQ4wDAYDVQQLEwVtYXNzbDEWMBQGA1UEAxMN c3lzdGVtOnNlcnZlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANW/ W5uK7w7aWLKoLQgudvi+d+XeP0NjtdH1g9otgn0V132CUOXK+AFdoqknBbCUrOys MkHXv3OFVo/7xANv0iR2RQZZJynQnK4B1Xm67585uhrs3iArrWUiqHBV8vSsPmJl rgOuJx1X5kgihtOxoV4RLC7Egp+D0w2DohgFJtRnUg8VKfMlF3nNdxG9tJpXdMRP ZEJwpxFkZ8TF7UCV6M/ZaS3Ow4F7Tepwl0hvwqnQzznblJ5rHmscgBqQBZ1JsXKl LmACVENYVDiYZNEVUrC05rvhRnTsZO/QMZpikNs/ZfxrgFYpFSRk8WOI0T3s8R0N 23as8wiHScU8S4p37WUCAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0l BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYE FGprx5v+KrO4DeOtA6kps4BL/zKyMB8GA1UdIwQYMBaAFHeA0xJSquoJxmAyWYCb wvuH5a2QMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsF AAOCAQEAmWTdMLyWOrNAS0uY+u3FUV3Hm50xF1PfxbT6wK1hu6vH6B63E0o9K2/1 U25Ie8Y2IzFocKMvbqC+mrY56G0bWoUlMONhthYqm8uTKtjlFO33A9I7WIT9Tw+B nnwZZO7+Ljkd30qSzBinCjrIEx31Vq2pr54ungd8+wK8nfz/zdZnJcqxcN9zvCXB GTE8yCuqGWKk/oDuIzVjr73U0QaWi+vThqJtBjhOIWQHHVJwbIyhuYzUaivgZPYB 8eKXWk4JH3eAcq5z5koNGyCcZd/k4WnvxZYxNBAkoQ6AWVfEMGOCaRjD1FTnMbpG BW79ndJqLmn8OH+DeCnSWhTWxAgg+Q== -----END CERTIFICATE----- ================================================ FILE: cluster/testdata/certs/node2-csr.json ================================================ { "CN": "system:server", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "AU", "L": "Melbourne", "O": "system:node2", "OU": "massl", "ST": "Victoria" } ] } ================================================ FILE: cluster/testdata/certs/node2-key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAtCtzT9vhRMTbhAg/pm8eBn+4IvVQeVqnHoEon9IKIx5fyvqS Q6Ui3xSik9kJq5FSAa1mScajJwfB1o6ycaSP6n+Q88Py4v7q65n0stCHoJCH0uPw MQyEhwX7nNilV9C4UZTyZ2StDdAjmMBHiN81EJAqH2d4Xtgrd/IIWhljSXm+aPbu QjSz8BtR/7+MswrCdlJ8y6gWi020kt6GSHjmaxI1jStGvBxxksK86v3J97wfNwWY 7GJi70uBrvO0pk5bYckDzUTKeN1QGvBnZ8uDXs7pPysvftJr85GzX0iE9YLMDxO3 qc/PlwCdxM8H6gHTTkLPizGZtpMF9Z497pW9YQIDAQABAoIBAFfQwdCPxHmnVbNB 7fwqNsFGKTLozMOJeuE0ZN+ZGZXKbTha70WHTLrcrO1RIRR9rTHiGXQmHEmez0zL mpAnfHn4mWcm/9DCHTCehpVNbH3HVFxm+yB9EG9bbCsjsVtfASfKaGgauvp7k44V UgiVeqDLE6zg2tunk3BQCOAZdbpOiXrdvoZiGx2Q4SMLPfzmfIyH4BUT836pLTmp o6/yNiFqQWfCgjeEAOQor4TcdzYIT+3wP51HfAjhZKMIvmjwL16ov1/QpmWRD4ni 4svzYpeMYpl5OrZkKeDS4ZIQBGjxk+fzPmfFUbfVRSI2gDORsah8HoRVI4LnwKWn 7kQDv0ECgYEA6V+KVb8bPzCZNbroEZFdug6YtT4yv5Mj3/kpMTIvA3vtu02v8e7F O56yT43QfUZA0Ar37O0HQ6mbpPsRE5RSr70i40RR+slMZVHX/AQViG7oQJGBijPt 1tFdLnb+1wSON3jYt2975Kw2IfgOXprWtEmL5zGuplEUjx9Lbdf1HjkCgYEAxaNe XgXdAiWFoY4Qq6xBRO/WNZCdn3Ysqx6snCtDRilxeNyDoE/6x2Ma9/NRBtIiulAb s09vDRfJKLbzocUhIn8BQ+GkbAS/A6+x2vcuGhK3F84xqZdbrCqvqdJS8K824jug vUCfCBJlyNRDz8kEsN5odLM1xkij93Jv23HvGGkCgYEAptcz6ctfalSPI9eEs5KO REbNK73UwBssaaISreYnsED4G5EVuUuvW8k/xxomtHj2OwWsa4ilSd1GtbL8aVf/ qT35ZCrixP0GjeTuGXC+CDTp+8dKqggoAAzbpi1SUVwjZEsT/EhKdZgcdzqE42Ol HWz7BQUCzEpo/U0tOtFKnxkCgYEAi05Vy8wyNbsg7/jlAzyNXPv4bxUaJTX00kDy xbkw2BmKI/i6xprZVwUiEzdsG3SuicjBXahVzFLBtXMPUy1R57DBwYkgjgriYMTM hlzIIBSk/aCXHMTVFwuXegoH8CJwexIwgHU2I0hkeiQ0EBfOuKRr2CYhdzvoZxhA g9tQ/lECgYAjPYoXfNI3rHCWUmaD5eDJZpE0xuJeiiy5auojykdAc7vVapNaIyMK G3EaU44RtXcSwH19TlH9UCm3MH1QiIwaBOzGcKj3Ut6ZyFKuWDUk4yqvps3uZU/h h16Tp49Ja7/4LY1uuEngg1KMEiWgk5jiU7G0H9zrtEiTj9c3FDKDvg== -----END RSA PRIVATE KEY----- ================================================ FILE: cluster/testdata/certs/node2.csr ================================================ -----BEGIN CERTIFICATE REQUEST----- MIIC5TCCAc0CAQAwczELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIw EAYDVQQHEwlNZWxib3VybmUxFTATBgNVBAoTDHN5c3RlbTpub2RlMjEOMAwGA1UE CxMFbWFzc2wxFjAUBgNVBAMTDXN5c3RlbTpzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQC0K3NP2+FExNuECD+mbx4Gf7gi9VB5WqcegSif0goj Hl/K+pJDpSLfFKKT2QmrkVIBrWZJxqMnB8HWjrJxpI/qf5Dzw/Li/urrmfSy0Ieg kIfS4/AxDISHBfuc2KVX0LhRlPJnZK0N0COYwEeI3zUQkCofZ3he2Ct38ghaGWNJ eb5o9u5CNLPwG1H/v4yzCsJ2UnzLqBaLTbSS3oZIeOZrEjWNK0a8HHGSwrzq/cn3 vB83BZjsYmLvS4Gu87SmTlthyQPNRMp43VAa8Gdny4Nezuk/Ky9+0mvzkbNfSIT1 gswPE7epz8+XAJ3EzwfqAdNOQs+LMZm2kwX1nj3ulb1hAgMBAAGgLTArBgkqhkiG 9w0BCQ4xHjAcMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0B AQsFAAOCAQEARh0Pi36mNmyprU4j25GWNqQYCJ6cBGnaPeiwr8/F3rsGsF4LTQdP xW2oBrEWyYRidNCkSMrPkcSiXu1Loy9APwSAXgJZWMYy0Ccdbd3P7dtGNOZkKaLA QKntGA5E1YAbzNhlt7NviGpqZ49K2aOgcGBTnDZ7xDzmg4uo3tcHgzOCwarYZT8l qVpc3jAyxRBOrxVKPZNFb4hAFvUm8k6/Etn5n4otN0JT3KGewbfQY50CxW5ShK52 QCs2PmFMYHHmG11FD3W755MxzhL6UmMy20GUgWWthGmR1LugcBgDtWO/7bqqC9tT XYDTDJ1j0g3Y0cvy2+kltrams4lGE3xs6g== -----END CERTIFICATE REQUEST----- ================================================ FILE: cluster/testdata/certs/node2.pem ================================================ -----BEGIN CERTIFICATE----- MIIEAjCCAuqgAwIBAgIUex5xEYsDJPUg8idU0Sql2ixGdTwwDQYJKoZIhvcNAQEL BQAwYjELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIwEAYDVQQHEwlN ZWxib3VybmUxDjAMBgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMxDjAMBgNVBAMT BW1hc3NsMCAXDTIxMDUwNTE2MTYwMFoYDzIxMjEwNDExMTYxNjAwWjBzMQswCQYD VQQGEwJBVTERMA8GA1UECBMIVmljdG9yaWExEjAQBgNVBAcTCU1lbGJvdXJuZTEV MBMGA1UEChMMc3lzdGVtOm5vZGUyMQ4wDAYDVQQLEwVtYXNzbDEWMBQGA1UEAxMN c3lzdGVtOnNlcnZlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALQr c0/b4UTE24QIP6ZvHgZ/uCL1UHlapx6BKJ/SCiMeX8r6kkOlIt8UopPZCauRUgGt ZknGoycHwdaOsnGkj+p/kPPD8uL+6uuZ9LLQh6CQh9Lj8DEMhIcF+5zYpVfQuFGU 8mdkrQ3QI5jAR4jfNRCQKh9neF7YK3fyCFoZY0l5vmj27kI0s/AbUf+/jLMKwnZS fMuoFotNtJLehkh45msSNY0rRrwccZLCvOr9yfe8HzcFmOxiYu9Lga7ztKZOW2HJ A81EynjdUBrwZ2fLg17O6T8rL37Sa/ORs19IhPWCzA8Tt6nPz5cAncTPB+oB005C z4sxmbaTBfWePe6VvWECAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0l BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYE FDNgivphLRqKzV8n29GJq6S2I+CQMB8GA1UdIwQYMBaAFHeA0xJSquoJxmAyWYCb wvuH5a2QMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsF AAOCAQEAnNG3nzycALGf+N8PuG4sUIkD+SYA1nOEgfD2KiGNyuTYHhGgFXTw8KzB olH05VidldBvC0+pl5EqZAp9qdzpw6Z5Mb0gdoZY6TeKDUo022G3BHLMUGLp8y+i KE6+awwgdJZ6vPbdnWAh7VM/HCUrGIIPmLFan13j/2RiMfaDxdMAowPmbVc8MLgA JHI6pPo8D1DacEvMM09qGtwQEUoREOWJ/SzTWl1nc/IAS1yOL1LCyKLcoj/HWqjG 3LXficQ7rf+Cpn1GnrKwMziT0OLDLxOs/+5d3nFSLxqF1lpykhPPkmHOHnuY8sMX Qdndn9QILdp5GNvqiVNQYcQa/gOb6g== -----END CERTIFICATE----- ================================================ FILE: cluster/testdata/empty_tls_config.yml ================================================ {} ================================================ FILE: cluster/testdata/tls_config_node1.yml ================================================ tls_server_config: cert_file: "certs/node1.pem" key_file: "certs/node1-key.pem" client_ca_file: "certs/ca.pem" client_auth_type: "VerifyClientCertIfGiven" tls_client_config: cert_file: "certs/node1.pem" key_file: "certs/node1-key.pem" ca_file: "certs/ca.pem" ================================================ FILE: cluster/testdata/tls_config_node2.yml ================================================ tls_server_config: cert_file: "certs/node2.pem" key_file: "certs/node2-key.pem" client_ca_file: "certs/ca.pem" client_auth_type: "VerifyClientCertIfGiven" tls_client_config: cert_file: "certs/node2.pem" key_file: "certs/node2-key.pem" ca_file: "certs/ca.pem" ================================================ FILE: cluster/testdata/tls_config_with_missing_client.yml ================================================ tls_server_config: cert_file: "certs/node2.pem" key_file: "certs/node2-key.pem" client_ca_file: "certs/ca.pem" client_auth_type: "VerifyClientCertIfGiven" ================================================ FILE: cluster/testdata/tls_config_with_missing_server.yml ================================================ tls_client_config: cert_file: "certs/node1.pem" key_file: "certs/node1-key.pem" ca_file: "certs/ca.pem" ================================================ FILE: cluster/tls_config.go ================================================ // Copyright 2020 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "fmt" "os" "path/filepath" "github.com/prometheus/common/config" "github.com/prometheus/exporter-toolkit/web" "gopkg.in/yaml.v2" ) type TLSTransportConfig struct { TLSServerConfig *web.TLSConfig `yaml:"tls_server_config"` TLSClientConfig *config.TLSConfig `yaml:"tls_client_config"` } func GetTLSTransportConfig(configPath string) (*TLSTransportConfig, error) { if configPath == "" { return nil, nil } bytes, err := os.ReadFile(configPath) if err != nil { return nil, err } cfg := &TLSTransportConfig{ TLSClientConfig: &config.TLSConfig{}, } if err := yaml.UnmarshalStrict(bytes, cfg); err != nil { return nil, err } if cfg.TLSServerConfig == nil { return nil, fmt.Errorf("missing 'tls_server_config' entry in the TLS configuration") } cfg.TLSServerConfig.SetDirectory(filepath.Dir(configPath)) cfg.TLSClientConfig.SetDirectory(filepath.Dir(configPath)) return cfg, nil } ================================================ FILE: cluster/tls_connection.go ================================================ // Copyright 2020 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "bufio" "crypto/tls" "encoding/binary" "errors" "fmt" "io" "net" "sync" "time" "github.com/hashicorp/memberlist" "google.golang.org/protobuf/proto" "github.com/prometheus/alertmanager/cluster/clusterpb" ) const ( version = "v0.1.0" uint32length = 4 ) // tlsConn wraps net.Conn with connection pooling data. type tlsConn struct { mtx sync.Mutex connection net.Conn live bool } func dialTLSConn(addr string, timeout time.Duration, tlsConfig *tls.Config) (*tlsConn, error) { dialer := &net.Dialer{Timeout: timeout} conn, err := tls.DialWithDialer(dialer, network, addr, tlsConfig) if err != nil { return nil, err } return &tlsConn{ connection: conn, live: true, }, nil } func rcvTLSConn(conn net.Conn) *tlsConn { return &tlsConn{ connection: conn, live: true, } } // Write writes a byte array into the connection. It returns the number of bytes written and an error. func (conn *tlsConn) Write(b []byte) (int, error) { conn.mtx.Lock() defer conn.mtx.Unlock() n, err := conn.connection.Write(b) if err != nil { conn.live = false } return n, err } func (conn *tlsConn) alive() bool { conn.mtx.Lock() defer conn.mtx.Unlock() return conn.live } func (conn *tlsConn) getRawConn() net.Conn { conn.mtx.Lock() defer conn.mtx.Unlock() raw := conn.connection conn.live = false conn.connection = nil return raw } // writePacket writes all the bytes in one operation so no concurrent write happens in between. // It prefixes the message length. func (conn *tlsConn) writePacket(fromAddr string, b []byte) error { msg, err := proto.Marshal( &clusterpb.MemberlistMessage{ Version: version, Kind: clusterpb.MemberlistMessage_PACKET, FromAddr: fromAddr, Msg: b, }, ) if err != nil { return fmt.Errorf("unable to marshal memeberlist packet message: %w", err) } buf := make([]byte, uint32length, uint32length+len(msg)) binary.LittleEndian.PutUint32(buf, uint32(len(msg))) _, err = conn.Write(append(buf, msg...)) return err } // writeStream simply signals that this is a stream connection by sending the connection type. func (conn *tlsConn) writeStream() error { msg, err := proto.Marshal( &clusterpb.MemberlistMessage{ Version: version, Kind: clusterpb.MemberlistMessage_STREAM, }, ) if err != nil { return fmt.Errorf("unable to marshal memeberlist stream message: %w", err) } buf := make([]byte, uint32length, uint32length+len(msg)) binary.LittleEndian.PutUint32(buf, uint32(len(msg))) _, err = conn.Write(append(buf, msg...)) return err } // read returns a packet for packet connections or an error if there is one. // It returns nothing if the connection is meant to be streamed. func (conn *tlsConn) read() (*memberlist.Packet, error) { if conn.connection == nil { return nil, errors.New("nil connection") } conn.mtx.Lock() reader := bufio.NewReader(conn.connection) lenBuf := make([]byte, uint32length) _, err := io.ReadFull(reader, lenBuf) if err != nil { return nil, fmt.Errorf("error reading message length: %w", err) } msgLen := binary.LittleEndian.Uint32(lenBuf) msgBuf := make([]byte, msgLen) _, err = io.ReadFull(reader, msgBuf) conn.mtx.Unlock() if err != nil { return nil, fmt.Errorf("error reading message: %w", err) } pb := clusterpb.MemberlistMessage{} err = proto.Unmarshal(msgBuf, &pb) if err != nil { return nil, fmt.Errorf("error parsing message: %w", err) } if pb.Version != version { return nil, errors.New("tls memberlist message version incompatible") } switch pb.Kind { case clusterpb.MemberlistMessage_STREAM: return nil, nil case clusterpb.MemberlistMessage_PACKET: return toPacket(&pb) default: return nil, errors.New("could not read from either stream or packet channel") } } func toPacket(pb *clusterpb.MemberlistMessage) (*memberlist.Packet, error) { addr, err := net.ResolveTCPAddr(network, pb.FromAddr) if err != nil { return nil, fmt.Errorf("error parsing packet sender address: %w", err) } return &memberlist.Packet{ Buf: pb.Msg, From: addr, Timestamp: time.Now(), }, nil } func (conn *tlsConn) Close() error { conn.mtx.Lock() defer conn.mtx.Unlock() conn.live = false if conn.connection == nil { return nil } return conn.connection.Close() } ================================================ FILE: cluster/tls_connection_test.go ================================================ // Copyright 2020 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "errors" "net" "testing" "time" "github.com/stretchr/testify/require" ) func TestWriteStream(t *testing.T) { w, r := net.Pipe() conn := &tlsConn{ connection: w, } defer r.Close() go func() { conn.writeStream() w.Close() }() packet, err := rcvTLSConn(r).read() require.NoError(t, err) require.Nil(t, packet) } func TestWritePacket(t *testing.T) { testCases := []struct { fromAddr string msg string }{ {fromAddr: "127.0.0.1:8001", msg: ""}, {fromAddr: "10.0.0.4:9094", msg: "hello"}, {fromAddr: "127.0.0.1:8001", msg: "0"}, } for _, tc := range testCases { w, r := net.Pipe() defer r.Close() go func() { conn := &tlsConn{connection: w} conn.writePacket(tc.fromAddr, []byte(tc.msg)) w.Close() }() packet, err := rcvTLSConn(r).read() require.NoError(t, err) require.Equal(t, tc.msg, string(packet.Buf)) require.Equal(t, tc.fromAddr, packet.From.String()) } } func TestRead_Nil(t *testing.T) { packet, err := (&tlsConn{}).read() require.Nil(t, packet) require.Error(t, err) } func TestTLSConn_Close(t *testing.T) { testCases := []string{ "foo", "bar", } for _, tc := range testCases { c := &tlsConn{ connection: &mockConn{ errMsg: tc, }, live: true, } err := c.Close() require.Equal(t, errors.New(tc), err, tc) require.False(t, c.alive()) require.True(t, c.connection.(*mockConn).closed) } } type mockConn struct { closed bool errMsg string } func (m *mockConn) Read(b []byte) (n int, err error) { panic("implement me") } func (m *mockConn) Write(b []byte) (n int, err error) { panic("implement me") } func (m *mockConn) Close() error { m.closed = true return errors.New(m.errMsg) } func (m *mockConn) LocalAddr() net.Addr { panic("implement me") } func (m *mockConn) RemoteAddr() net.Addr { panic("implement me") } func (m *mockConn) SetDeadline(t time.Time) error { panic("implement me") } func (m *mockConn) SetReadDeadline(t time.Time) error { panic("implement me") } func (m *mockConn) SetWriteDeadline(t time.Time) error { panic("implement me") } ================================================ FILE: cluster/tls_transport.go ================================================ // Copyright 2020 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Forked from https://github.com/mxinden/memberlist-tls-transport. // Implements Transport interface so that all gossip communications occur via TLS over TCP. package cluster import ( "context" "crypto/tls" "errors" "fmt" "log/slog" "net" "strings" "time" "github.com/hashicorp/go-sockaddr" "github.com/hashicorp/memberlist" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" common "github.com/prometheus/common/config" "github.com/prometheus/exporter-toolkit/web" ) const ( metricNamespace = "alertmanager" metricSubsystem = "tls_transport" network = "tcp" ) // TLSTransport is a Transport implementation that uses TLS over TCP for both // packet and stream operations. type TLSTransport struct { ctx context.Context cancel context.CancelFunc logger *slog.Logger bindAddr string bindPort int done chan struct{} listener net.Listener packetCh chan *memberlist.Packet streamCh chan net.Conn connPool *connectionPool tlsServerCfg *tls.Config tlsClientCfg *tls.Config packetsSent prometheus.Counter packetsRcvd prometheus.Counter streamsSent prometheus.Counter streamsRcvd prometheus.Counter readErrs prometheus.Counter writeErrs *prometheus.CounterVec } // NewTLSTransport returns a TLS transport with the given configuration. // On successful initialization, a tls listener will be created and listening. // A valid bindAddr is required. If bindPort == 0, the system will assign // a free port automatically. func NewTLSTransport( ctx context.Context, logger *slog.Logger, reg prometheus.Registerer, bindAddr string, bindPort int, cfg *TLSTransportConfig, ) (*TLSTransport, error) { if reg == nil { return nil, errors.New("missing Prometheus registry") } if cfg == nil { return nil, errors.New("must specify TLSTransportConfig") } tlsServerCfg, err := web.ConfigToTLSConfig(cfg.TLSServerConfig) if err != nil { return nil, fmt.Errorf("invalid TLS server config: %w", err) } tlsClientCfg, err := common.NewTLSConfig(cfg.TLSClientConfig) if err != nil { return nil, fmt.Errorf("invalid TLS client config: %w", err) } ip := net.ParseIP(bindAddr) if ip == nil { return nil, fmt.Errorf("invalid bind address \"%s\"", bindAddr) } addr := &net.TCPAddr{IP: ip, Port: bindPort} listener, err := tls.Listen(network, addr.String(), tlsServerCfg) if err != nil { return nil, fmt.Errorf("failed to start TLS listener on %q port %d: %w", bindAddr, bindPort, err) } connPool, err := newConnectionPool(tlsClientCfg) if err != nil { return nil, fmt.Errorf("failed to initialize tls transport connection pool: %w", err) } ctx, cancel := context.WithCancel(ctx) t := &TLSTransport{ ctx: ctx, cancel: cancel, logger: logger, bindAddr: bindAddr, bindPort: bindPort, done: make(chan struct{}), listener: listener, packetCh: make(chan *memberlist.Packet), streamCh: make(chan net.Conn), connPool: connPool, tlsServerCfg: tlsServerCfg, tlsClientCfg: tlsClientCfg, } t.registerMetrics(reg) go func() { t.listen() close(t.done) }() return t, nil } // FinalAdvertiseAddr is given the user's configured values (which // might be empty) and returns the desired IP and port to advertise to // the rest of the cluster. func (t *TLSTransport) FinalAdvertiseAddr(ip string, port int) (net.IP, int, error) { var advertiseAddr net.IP var advertisePort int if ip != "" { advertiseAddr = net.ParseIP(ip) if advertiseAddr == nil { return nil, 0, fmt.Errorf("failed to parse advertise address %q", ip) } if ip4 := advertiseAddr.To4(); ip4 != nil { advertiseAddr = ip4 } advertisePort = port } else { if t.bindAddr == "0.0.0.0" { // Otherwise, if we're not bound to a specific IP, let's // use a suitable private IP address. var err error ip, err = sockaddr.GetPrivateIP() if err != nil { return nil, 0, fmt.Errorf("failed to get interface addresses: %w", err) } if ip == "" { return nil, 0, fmt.Errorf("no private IP address found, and explicit IP not provided") } advertiseAddr = net.ParseIP(ip) if advertiseAddr == nil { return nil, 0, fmt.Errorf("failed to parse advertise address: %q", ip) } } else { advertiseAddr = t.listener.Addr().(*net.TCPAddr).IP } advertisePort = t.GetAutoBindPort() } return advertiseAddr, advertisePort, nil } // PacketCh returns a channel that can be read to receive incoming // packets from other peers. func (t *TLSTransport) PacketCh() <-chan *memberlist.Packet { return t.packetCh } // StreamCh returns a channel that can be read to handle incoming stream // connections from other peers. func (t *TLSTransport) StreamCh() <-chan net.Conn { return t.streamCh } // Shutdown is called when memberlist is shutting down; this gives the // TLS Transport a chance to clean up the listener and other goroutines. func (t *TLSTransport) Shutdown() error { t.logger.Debug("shutting down tls transport") t.cancel() err := t.listener.Close() t.connPool.shutdown() <-t.done return err } // WriteTo is a packet-oriented interface that borrows a connection // from the pool, and writes to it. It also returns a timestamp of when // the packet was written. func (t *TLSTransport) WriteTo(b []byte, addr string) (time.Time, error) { conn, err := t.connPool.borrowConnection(addr, DefaultTCPTimeout) if err != nil { t.writeErrs.WithLabelValues("packet").Inc() return time.Now(), fmt.Errorf("failed to dial: %w", err) } fromAddr := t.listener.Addr().String() err = conn.writePacket(fromAddr, b) if err != nil { t.writeErrs.WithLabelValues("packet").Inc() return time.Now(), fmt.Errorf("failed to write packet: %w", err) } t.packetsSent.Add(float64(len(b))) return time.Now(), nil } // DialTimeout is used to create a connection that allows memberlist // to perform two-way communications with a peer. func (t *TLSTransport) DialTimeout(addr string, timeout time.Duration) (net.Conn, error) { conn, err := dialTLSConn(addr, timeout, t.tlsClientCfg) if err != nil { t.writeErrs.WithLabelValues("stream").Inc() return nil, fmt.Errorf("failed to dial: %w", err) } err = conn.writeStream() netConn := conn.getRawConn() if err != nil { t.writeErrs.WithLabelValues("stream").Inc() return netConn, fmt.Errorf("failed to create stream connection: %w", err) } t.streamsSent.Inc() return netConn, nil } // GetAutoBindPort returns the bind port that was automatically given by the system // if a bindPort of 0 was specified during instantiation. func (t *TLSTransport) GetAutoBindPort() int { return t.listener.Addr().(*net.TCPAddr).Port } // listen starts up multiple handlers accepting concurrent connections. func (t *TLSTransport) listen() { for { select { case <-t.ctx.Done(): return default: conn, err := t.listener.Accept() if err != nil { // The error "use of closed network connection" is returned when the listener is closed. // It is not exported in a more reasonable way. See https://github.com/golang/go/issues/4373. if strings.Contains(err.Error(), "use of closed network connection") { return } t.readErrs.Inc() t.logger.Debug("error accepting connection", "err", err) } else { go t.handle(conn) } } } } func (t *TLSTransport) handle(conn net.Conn) { for { packet, err := rcvTLSConn(conn).read() if err != nil { t.logger.Debug("error reading from connection", "err", err) t.readErrs.Inc() return } select { case <-t.ctx.Done(): return default: if packet != nil { n := len(packet.Buf) t.packetCh <- packet t.packetsRcvd.Add(float64(n)) } else { t.streamCh <- conn t.streamsRcvd.Inc() return } } } } func (t *TLSTransport) registerMetrics(reg prometheus.Registerer) { t.packetsSent = promauto.With(reg).NewCounter( prometheus.CounterOpts{ Namespace: metricNamespace, Subsystem: metricSubsystem, Name: "packet_bytes_sent_total", Help: "The number of packet bytes sent to outgoing connections (excluding internal metadata).", }, ) t.packetsRcvd = promauto.With(reg).NewCounter( prometheus.CounterOpts{ Namespace: metricNamespace, Subsystem: metricSubsystem, Name: "packet_bytes_received_total", Help: "The number of packet bytes received from incoming connections (excluding internal metadata).", }, ) t.streamsSent = promauto.With(reg).NewCounter( prometheus.CounterOpts{ Namespace: metricNamespace, Subsystem: metricSubsystem, Name: "stream_connections_sent_total", Help: "The number of stream connections sent.", }, ) t.streamsRcvd = promauto.With(reg).NewCounter( prometheus.CounterOpts{ Namespace: metricNamespace, Subsystem: metricSubsystem, Name: "stream_connections_received_total", Help: "The number of stream connections received.", }, ) t.readErrs = promauto.With(reg).NewCounter( prometheus.CounterOpts{ Namespace: metricNamespace, Subsystem: metricSubsystem, Name: "read_errors_total", Help: "The number of errors encountered while reading from incoming connections.", }, ) t.writeErrs = promauto.With(reg).NewCounterVec( prometheus.CounterOpts{ Namespace: metricNamespace, Subsystem: metricSubsystem, Name: "write_errors_total", Help: "The number of errors encountered while writing to outgoing connections.", }, []string{"connection_type"}, ) } ================================================ FILE: cluster/tls_transport_test.go ================================================ // Copyright 2020 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package cluster import ( "bufio" "bytes" context2 "context" "fmt" "io" "net" "sync" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" ) var logger = promslog.NewNopLogger() func freeport() int { lis, _ := net.Listen(network, "127.0.0.1:0") defer lis.Close() return lis.Addr().(*net.TCPAddr).Port } func newTLSTransport(file, address string, port int) (*TLSTransport, error) { cfg, err := GetTLSTransportConfig(file) if err != nil { return nil, err } return NewTLSTransport(context2.Background(), promslog.NewNopLogger(), prometheus.NewRegistry(), address, port, cfg) } func TestNewTLSTransport(t *testing.T) { port := freeport() for _, tc := range []struct { bindAddr string bindPort int tlsConfFile string err string }{ { err: "must specify TLSTransportConfig", }, { tlsConfFile: "testdata/empty_tls_config.yml", err: "missing 'tls_server_config' entry in the TLS configuration", }, { tlsConfFile: "testdata/tls_config_with_missing_server.yml", err: "missing 'tls_server_config' entry in the TLS configuration", }, { err: "invalid bind address \"\"", tlsConfFile: "testdata/tls_config_node1.yml", }, { bindAddr: "abc123", err: "invalid bind address \"abc123\"", tlsConfFile: "testdata/tls_config_node1.yml", }, { bindAddr: localhost, bindPort: 0, tlsConfFile: "testdata/tls_config_node1.yml", }, { bindAddr: localhost, bindPort: port, tlsConfFile: "testdata/tls_config_node2.yml", }, { tlsConfFile: "testdata/tls_config_with_missing_client.yml", bindAddr: localhost, }, } { t.Run("", func(t *testing.T) { transport, err := newTLSTransport(tc.tlsConfFile, tc.bindAddr, tc.bindPort) if len(tc.err) > 0 { require.Error(t, err) require.Equal(t, tc.err, err.Error()) return } defer transport.Shutdown() require.NoError(t, err) require.Equal(t, tc.bindAddr, transport.bindAddr) require.Equal(t, tc.bindPort, transport.bindPort) require.NotNil(t, transport.listener) }) } } const localhost = "127.0.0.1" func TestFinalAdvertiseAddr(t *testing.T) { ports := [...]int{freeport(), freeport(), freeport()} testCases := []struct { bindAddr string bindPort int inputIP string inputPort int expectedIP string expectedPort int expectedError string }{ {bindAddr: localhost, bindPort: ports[0], inputIP: "10.0.0.5", inputPort: 54231, expectedIP: "10.0.0.5", expectedPort: 54231}, {bindAddr: localhost, bindPort: ports[1], inputIP: "invalid", inputPort: 54231, expectedError: "failed to parse advertise address \"invalid\""}, {bindAddr: "0.0.0.0", bindPort: 0, inputIP: "", inputPort: 0, expectedIP: "random"}, {bindAddr: localhost, bindPort: 0, inputIP: "", inputPort: 0, expectedIP: localhost}, {bindAddr: localhost, bindPort: ports[2], inputIP: "", inputPort: 0, expectedIP: localhost, expectedPort: ports[2]}, } for _, tc := range testCases { tlsConf := loadTLSTransportConfig(t, "testdata/tls_config_node1.yml") transport, err := NewTLSTransport(context2.Background(), logger, prometheus.NewRegistry(), tc.bindAddr, tc.bindPort, tlsConf) require.NoError(t, err) ip, port, err := transport.FinalAdvertiseAddr(tc.inputIP, tc.inputPort) if len(tc.expectedError) > 0 { require.Equal(t, tc.expectedError, err.Error()) } else { require.NoError(t, err) if tc.expectedPort == 0 { require.Less(t, tc.expectedPort, port) } else { require.Equal(t, tc.expectedPort, port) } if tc.expectedIP == "random" { require.NotNil(t, ip) } else { require.Equal(t, tc.expectedIP, ip.String()) } } transport.Shutdown() } } func TestWriteTo(t *testing.T) { tlsConf1 := loadTLSTransportConfig(t, "testdata/tls_config_node1.yml") t1, _ := NewTLSTransport(context2.Background(), logger, prometheus.NewRegistry(), "127.0.0.1", 0, tlsConf1) defer t1.Shutdown() tlsConf2 := loadTLSTransportConfig(t, "testdata/tls_config_node2.yml") t2, _ := NewTLSTransport(context2.Background(), logger, prometheus.NewRegistry(), "127.0.0.1", 0, tlsConf2) defer t2.Shutdown() from := fmt.Sprintf("%s:%d", t1.bindAddr, t1.GetAutoBindPort()) to := fmt.Sprintf("%s:%d", t2.bindAddr, t2.GetAutoBindPort()) sent := []byte(("test packet")) _, err := t1.WriteTo(sent, to) require.NoError(t, err) packet := <-t2.PacketCh() require.Equal(t, sent, packet.Buf) require.Equal(t, from, packet.From.String()) } func BenchmarkWriteTo(b *testing.B) { tlsConf1 := loadTLSTransportConfig(b, "testdata/tls_config_node1.yml") t1, _ := NewTLSTransport(context2.Background(), logger, prometheus.NewRegistry(), "127.0.0.1", 0, tlsConf1) defer t1.Shutdown() tlsConf2 := loadTLSTransportConfig(b, "testdata/tls_config_node2.yml") t2, _ := NewTLSTransport(context2.Background(), logger, prometheus.NewRegistry(), "127.0.0.1", 0, tlsConf2) defer t2.Shutdown() b.ResetTimer() from := fmt.Sprintf("%s:%d", t1.bindAddr, t1.GetAutoBindPort()) to := fmt.Sprintf("%s:%d", t2.bindAddr, t2.GetAutoBindPort()) sent := []byte(("test packet")) _, err := t1.WriteTo(sent, to) require.NoError(b, err) packet := <-t2.PacketCh() require.Equal(b, sent, packet.Buf) require.Equal(b, from, packet.From.String()) } func TestDialTimeout(t *testing.T) { tlsConf1 := loadTLSTransportConfig(t, "testdata/tls_config_node1.yml") t1, err := NewTLSTransport(context2.Background(), logger, prometheus.NewRegistry(), "127.0.0.1", 0, tlsConf1) require.NoError(t, err) defer t1.Shutdown() tlsConf2 := loadTLSTransportConfig(t, "testdata/tls_config_node2.yml") t2, err := NewTLSTransport(context2.Background(), logger, prometheus.NewRegistry(), "127.0.0.1", 0, tlsConf2) require.NoError(t, err) defer t2.Shutdown() addr := fmt.Sprintf("%s:%d", t2.bindAddr, t2.GetAutoBindPort()) from, err := t1.DialTimeout(addr, 5*time.Second) require.NoError(t, err) defer from.Close() var to net.Conn var wg sync.WaitGroup wg.Go(func() { to = <-t2.StreamCh() }) sent := []byte(("test stream")) m, err := from.Write(sent) require.NoError(t, err) require.Positive(t, m) wg.Wait() reader := bufio.NewReader(to) buf := make([]byte, len(sent)) n, err := io.ReadFull(reader, buf) require.NoError(t, err) require.Len(t, sent, n) require.Equal(t, sent, buf) } func TestShutdown(t *testing.T) { var buf bytes.Buffer promslogConfig := &promslog.Config{Writer: &buf} logger := promslog.New(promslogConfig) // Set logger to debug, otherwise it won't catch some logging from `Shutdown()` method. _ = promslogConfig.Level.Set("debug") tlsConf1 := loadTLSTransportConfig(t, "testdata/tls_config_node1.yml") t1, _ := NewTLSTransport(context2.Background(), logger, prometheus.NewRegistry(), "127.0.0.1", 0, tlsConf1) // Sleeping to make sure listeners have started and can subsequently be shut down gracefully. time.Sleep(500 * time.Millisecond) err := t1.Shutdown() require.NoError(t, err) require.NotContains(t, buf.String(), "use of closed network connection") require.Contains(t, buf.String(), "shutting down tls transport") } func loadTLSTransportConfig(tb testing.TB, filename string) *TLSTransportConfig { tb.Helper() config, err := GetTLSTransportConfig(filename) if err != nil { tb.Fatal(err) } return config } ================================================ FILE: cmd/alertmanager/main.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "net/url" "os" "os/signal" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/KimMachineGun/automemlimit/memlimit" "github.com/alecthomas/kingpin/v2" "github.com/prometheus/client_golang/prometheus" versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" promslogflag "github.com/prometheus/common/promslog/flag" "github.com/prometheus/common/route" "github.com/prometheus/common/version" "github.com/prometheus/exporter-toolkit/web" webflag "github.com/prometheus/exporter-toolkit/web/kingpinflag" "github.com/prometheus/alertmanager/api" "github.com/prometheus/alertmanager/cluster" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/config/receiver" "github.com/prometheus/alertmanager/dispatch" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/inhibit" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/provider/mem" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/tracing" "github.com/prometheus/alertmanager/types" "github.com/prometheus/alertmanager/ui" ) var ( requestDuration = promauto.NewHistogramVec( prometheus.HistogramOpts{ Name: "alertmanager_http_request_duration_seconds", Help: "Histogram of latencies for HTTP requests.", Buckets: prometheus.DefBuckets, NativeHistogramBucketFactor: 1.1, NativeHistogramMaxBucketNumber: 100, NativeHistogramMinResetDuration: 1 * time.Hour, }, []string{"handler", "method", "code"}, ) responseSize = promauto.NewHistogramVec( prometheus.HistogramOpts{ Name: "alertmanager_http_response_size_bytes", Help: "Histogram of response size for HTTP requests.", Buckets: prometheus.ExponentialBuckets(100, 10, 7), }, []string{"handler", "method"}, ) clusterEnabled = promauto.NewGauge( prometheus.GaugeOpts{ Name: "alertmanager_cluster_enabled", Help: "Indicates whether the clustering is enabled or not.", }, ) configuredReceivers = promauto.NewGauge( prometheus.GaugeOpts{ Name: "alertmanager_receivers", Help: "Number of configured receivers.", }, ) configuredIntegrations = promauto.NewGauge( prometheus.GaugeOpts{ Name: "alertmanager_integrations", Help: "Number of configured integrations.", }, ) configuredInhibitionRules = promauto.NewGauge( prometheus.GaugeOpts{ Name: "alertmanager_inhibition_rules", Help: "Number of configured inhibition rules.", }, ) promslogConfig = promslog.Config{} ) func instrumentHandler(handlerName string, handler http.HandlerFunc) http.HandlerFunc { handlerLabel := prometheus.Labels{"handler": handlerName} return promhttp.InstrumentHandlerDuration( requestDuration.MustCurryWith(handlerLabel), promhttp.InstrumentHandlerResponseSize( responseSize.MustCurryWith(handlerLabel), handler, ), ) } const defaultClusterAddr = "0.0.0.0:9094" func main() { os.Exit(run()) } func run() int { if os.Getenv("DEBUG") != "" { runtime.SetBlockProfileRate(20) runtime.SetMutexProfileFraction(20) } var ( configFile = kingpin.Flag("config.file", "Alertmanager configuration file name.").Default("alertmanager.yml").String() dataDir = kingpin.Flag("storage.path", "Base path for data storage.").Default("data/").String() retention = kingpin.Flag("data.retention", "How long to keep data for.").Default("120h").Duration() maintenanceInterval = kingpin.Flag("data.maintenance-interval", "Interval between garbage collection and snapshotting to disk of the silences and the notification logs.").Default("15m").Duration() maxSilences = kingpin.Flag("silences.max-silences", "Maximum number of silences, including expired silences. If negative or zero, no limit is set.").Default("0").Int() maxSilenceSizeBytes = kingpin.Flag("silences.max-silence-size-bytes", "Maximum silence size in bytes. If negative or zero, no limit is set.").Default("0").Int() alertGCInterval = kingpin.Flag("alerts.gc-interval", "Interval between alert GC.").Default("30m").Duration() perAlertNameLimit = kingpin.Flag("alerts.per-alertname-limit", "Maximum number of alerts per alertname. If negative or zero, no limit is set.").Default("0").Int() dispatchMaintenanceInterval = kingpin.Flag("dispatch.maintenance-interval", "Interval between maintenance of aggregation groups in the dispatcher.").Default("30s").Duration() DispatchStartDelay = kingpin.Flag("dispatch.start-delay", "Minimum amount of time to wait before dispatching alerts. This option should be synced with value of --rules.alert.resend-delay on Prometheus.").Default("0s").Duration() webConfig = webflag.AddFlags(kingpin.CommandLine, ":9093") externalURL = kingpin.Flag("web.external-url", "The URL under which Alertmanager is externally reachable (for example, if Alertmanager is served via a reverse proxy). Used for generating relative and absolute links back to Alertmanager itself. If the URL has a path portion, it will be used to prefix all HTTP endpoints served by Alertmanager. If omitted, relevant URL components will be derived automatically.").String() routePrefix = kingpin.Flag("web.route-prefix", "Prefix for the internal routes of web endpoints. Defaults to path of --web.external-url.").String() getConcurrency = kingpin.Flag("web.get-concurrency", "Maximum number of GET requests processed concurrently. If negative or zero, the limit is GOMAXPROC or 8, whichever is larger.").Default("0").Int() httpTimeout = kingpin.Flag("web.timeout", "Timeout for HTTP requests. If negative or zero, no timeout is set.").Default("0").Duration() memlimitRatio = kingpin.Flag("auto-gomemlimit.ratio", "The ratio of reserved GOMEMLIMIT memory to the detected maximum container or system memory. The value must be greater than 0 and less than or equal to 1."). Default("0.9").Float64() clusterBindAddr = kingpin.Flag("cluster.listen-address", "Listen address for cluster. Set to empty string to disable HA mode."). Default(defaultClusterAddr).String() clusterAdvertiseAddr = kingpin.Flag("cluster.advertise-address", "Explicit address to advertise in cluster.").String() clusterPeerName = kingpin.Flag("cluster.peer-name", "Explicit name of the peer, rather than generating a random one").Default("").String() peers = kingpin.Flag("cluster.peer", "Initial peers (may be repeated).").Strings() peerTimeout = kingpin.Flag("cluster.peer-timeout", "Time to wait between peers to send notifications.").Default("15s").Duration() peersResolveTimeout = kingpin.Flag("cluster.peers-resolve-timeout", "Time to resolve peers.").Default(cluster.DefaultResolvePeersTimeout.String()).Duration() gossipInterval = kingpin.Flag("cluster.gossip-interval", "Interval between sending gossip messages. By lowering this value (more frequent) gossip messages are propagated across the cluster more quickly at the expense of increased bandwidth.").Default(cluster.DefaultGossipInterval.String()).Duration() pushPullInterval = kingpin.Flag("cluster.pushpull-interval", "Interval for gossip state syncs. Setting this interval lower (more frequent) will increase convergence speeds across larger clusters at the expense of increased bandwidth usage.").Default(cluster.DefaultPushPullInterval.String()).Duration() tcpTimeout = kingpin.Flag("cluster.tcp-timeout", "Timeout for establishing a stream connection with a remote node for a full state sync, and for stream read and write operations.").Default(cluster.DefaultTCPTimeout.String()).Duration() probeTimeout = kingpin.Flag("cluster.probe-timeout", "Timeout to wait for an ack from a probed node before assuming it is unhealthy. This should be set to 99-percentile of RTT (round-trip time) on your network.").Default(cluster.DefaultProbeTimeout.String()).Duration() probeInterval = kingpin.Flag("cluster.probe-interval", "Interval between random node probes. Setting this lower (more frequent) will cause the cluster to detect failed nodes more quickly at the expense of increased bandwidth usage.").Default(cluster.DefaultProbeInterval.String()).Duration() settleTimeout = kingpin.Flag("cluster.settle-timeout", "Maximum time to wait for cluster connections to settle before evaluating notifications.").Default(cluster.DefaultPushPullInterval.String()).Duration() reconnectInterval = kingpin.Flag("cluster.reconnect-interval", "Interval between attempting to reconnect to lost peers.").Default(cluster.DefaultReconnectInterval.String()).Duration() peerReconnectTimeout = kingpin.Flag("cluster.reconnect-timeout", "Length of time to attempt to reconnect to a lost peer.").Default(cluster.DefaultReconnectTimeout.String()).Duration() tlsConfigFile = kingpin.Flag("cluster.tls-config", "[EXPERIMENTAL] Path to config yaml file that can enable mutual TLS within the gossip protocol.").Default("").String() allowInsecureAdvertise = kingpin.Flag("cluster.allow-insecure-public-advertise-address-discovery", "[EXPERIMENTAL] Allow alertmanager to discover and listen on a public IP address.").Bool() label = kingpin.Flag("cluster.label", "The cluster label is an optional string to include on each packet and stream. It uniquely identifies the cluster and prevents cross-communication issues when sending gossip messages.").Default("").String() featureFlags = kingpin.Flag("enable-feature", fmt.Sprintf("Comma-separated experimental features to enable. Valid options: %s", strings.Join(featurecontrol.AllowedFlags, ", "))).Default("").String() ) prometheus.MustRegister(versioncollector.NewCollector("alertmanager")) promslogflag.AddFlags(kingpin.CommandLine, &promslogConfig) kingpin.CommandLine.UsageWriter(os.Stdout) kingpin.Version(version.Print("alertmanager")) kingpin.CommandLine.GetFlag("help").Short('h') kingpin.Parse() logger := promslog.New(&promslogConfig) logger.Info("Starting Alertmanager", "version", version.Info()) startTime := time.Now() logger.Info("Build context", "build_context", version.BuildContext()) ff, err := featurecontrol.NewFlags(logger, *featureFlags) if err != nil { logger.Error("error parsing the feature flag list", "err", err) return 1 } compat.InitFromFlags(logger, ff) if ff.EnableAutoGOMEMLIMIT() { if *memlimitRatio <= 0.0 || *memlimitRatio > 1.0 { logger.Error("--auto-gomemlimit.ratio must be greater than 0 and less than or equal to 1.") return 1 } if _, err := memlimit.SetGoMemLimitWithOpts( memlimit.WithRatio(*memlimitRatio), memlimit.WithProvider( memlimit.ApplyFallback( memlimit.FromCgroup, memlimit.FromSystem, ), ), ); err != nil { logger.Warn("automemlimit", "msg", "Failed to set GOMEMLIMIT automatically", "err", err) } } if ff.EnableAutoGOMAXPROCS() { logger.Warn("automaxprocs", "msg", "This flag is deprecated and will be removed in the next release") } err = os.MkdirAll(*dataDir, 0o777) if err != nil { logger.Error("Unable to create data directory", "err", err) return 1 } tlsTransportConfig, err := cluster.GetTLSTransportConfig(*tlsConfigFile) if err != nil { logger.Error("unable to initialize TLS transport configuration for gossip mesh", "err", err) return 1 } var peer *cluster.Peer if *clusterBindAddr != "" { peer, err = cluster.Create( logger.With("component", "cluster"), prometheus.DefaultRegisterer, *clusterBindAddr, *clusterAdvertiseAddr, *peers, true, *pushPullInterval, *gossipInterval, *tcpTimeout, *peersResolveTimeout, *probeTimeout, *probeInterval, tlsTransportConfig, *allowInsecureAdvertise, *label, *clusterPeerName, ) if err != nil { logger.Error("unable to initialize gossip mesh", "err", err) return 1 } clusterEnabled.Set(1) } stopc := make(chan struct{}) var wg sync.WaitGroup notificationLogOpts := nflog.Options{ SnapshotFile: filepath.Join(*dataDir, "nflog"), Retention: *retention, Logger: logger.With("component", "nflog"), Metrics: prometheus.DefaultRegisterer, } notificationLog, err := nflog.New(notificationLogOpts) if err != nil { logger.Error("error creating notification log", "err", err) return 1 } if peer != nil { c := peer.AddState("nfl", notificationLog, prometheus.DefaultRegisterer) notificationLog.SetBroadcast(c.Broadcast) } wg.Go(func() { notificationLog.Maintenance(*maintenanceInterval, filepath.Join(*dataDir, "nflog"), stopc, nil) }) marker := types.NewMarker(prometheus.DefaultRegisterer) silenceOpts := silence.Options{ SnapshotFile: filepath.Join(*dataDir, "silences"), Retention: *retention, Limits: silence.Limits{ MaxSilences: func() int { return *maxSilences }, MaxSilenceSizeBytes: func() int { return *maxSilenceSizeBytes }, }, Logger: logger.With("component", "silences"), Metrics: prometheus.DefaultRegisterer, } silences, err := silence.New(silenceOpts) if err != nil { logger.Error("error creating silence", "err", err) return 1 } if peer != nil { c := peer.AddState("sil", silences, prometheus.DefaultRegisterer) silences.SetBroadcast(c.Broadcast) } // Start providers before router potentially sends updates. wg.Go(func() { silences.Maintenance(*maintenanceInterval, filepath.Join(*dataDir, "silences"), stopc, nil) }) defer func() { close(stopc) wg.Wait() }() silencer := silence.NewSilencer(silences, marker, logger) // Peer state listeners have been registered, now we can join and get the initial state. if peer != nil { err = peer.Join( *reconnectInterval, *peerReconnectTimeout, ) if err != nil { logger.Warn("unable to join gossip mesh", "err", err) } ctx, cancel := context.WithTimeout(context.Background(), *settleTimeout) defer func() { cancel() if err := peer.Leave(10 * time.Second); err != nil { logger.Warn("unable to leave gossip mesh", "err", err) } }() go peer.Settle(ctx, *gossipInterval*10) } alerts, err := mem.NewAlerts( context.Background(), marker, *alertGCInterval, *perAlertNameLimit, silencer, logger, prometheus.DefaultRegisterer, ff, ) if err != nil { logger.Error("error creating memory provider", "err", err) return 1 } defer alerts.Close() var disp atomic.Pointer[dispatch.Dispatcher] defer func() { disp.Load().Stop() }() groupFn := func(ctx context.Context, routeFilter func(*dispatch.Route) bool, alertFilter func(*types.Alert, time.Time) bool) (dispatch.AlertGroups, map[model.Fingerprint][]string, error) { return disp.Load().Groups(ctx, routeFilter, alertFilter) } // An interface value that holds a nil concrete value is non-nil. // Therefore we explicly pass an empty interface, to detect if the // cluster is not enabled in notify. var clusterPeer cluster.ClusterPeer if peer != nil { clusterPeer = peer } api, err := api.New(api.Options{ Alerts: alerts, Silences: silences, AlertStatusFunc: marker.Status, GroupMutedFunc: marker.Muted, Peer: clusterPeer, Timeout: *httpTimeout, Concurrency: *getConcurrency, Logger: logger.With("component", "api"), Registry: prometheus.DefaultRegisterer, RequestDuration: requestDuration, GroupFunc: groupFn, }) if err != nil { logger.Error("failed to create API", "err", err) return 1 } amURL, err := extURL(logger, os.Hostname, (*webConfig.WebListenAddresses)[0], *externalURL) if err != nil { logger.Error("failed to determine external URL", "err", err) return 1 } logger.Debug("external url", "externalUrl", amURL.String()) waitFunc := func() time.Duration { return 0 } if peer != nil { waitFunc = clusterWait(peer, *peerTimeout) } timeoutFunc := func(d time.Duration) time.Duration { if d < notify.MinTimeout { d = notify.MinTimeout } return d + waitFunc() } tracingManager := tracing.NewManager(logger.With("component", "tracing")) var ( inhibitor atomic.Pointer[inhibit.Inhibitor] tmpl *template.Template ) dispMetrics := dispatch.NewDispatcherMetrics(false, prometheus.DefaultRegisterer) pipelineBuilder := notify.NewPipelineBuilder(prometheus.DefaultRegisterer, ff) configLogger := logger.With("component", "configuration") configCoordinator := config.NewCoordinator( *configFile, prometheus.DefaultRegisterer, configLogger, ) configCoordinator.Subscribe(func(conf *config.Config) error { tmpl, err = template.FromGlobs(conf.Templates) if err != nil { return fmt.Errorf("failed to parse templates: %w", err) } tmpl.ExternalURL = amURL // Build the routing tree and record which receivers are used. routes := dispatch.NewRoute(conf.Route, nil) activeReceivers := make(map[string]struct{}) routes.Walk(func(r *dispatch.Route) { activeReceivers[r.RouteOpts.Receiver] = struct{}{} }) // Build the map of receiver to integrations. receivers := make(map[string][]notify.Integration, len(activeReceivers)) var integrationsNum int for _, rcv := range conf.Receivers { if _, found := activeReceivers[rcv.Name]; !found { // No need to build a receiver if no route is using it. configLogger.Info("skipping creation of receiver not referenced by any route", "receiver", rcv.Name) continue } integrations, err := receiver.BuildReceiverIntegrations(rcv, tmpl, logger) if err != nil { return err } // rcv.Name is guaranteed to be unique across all receivers. receivers[rcv.Name] = integrations integrationsNum += len(integrations) } // Build the map of time interval names to time interval definitions. timeIntervals := make(map[string][]timeinterval.TimeInterval, len(conf.MuteTimeIntervals)+len(conf.TimeIntervals)) for _, ti := range conf.MuteTimeIntervals { timeIntervals[ti.Name] = ti.TimeIntervals } for _, ti := range conf.TimeIntervals { timeIntervals[ti.Name] = ti.TimeIntervals } intervener := timeinterval.NewIntervener(timeIntervals) inhibitor.Load().Stop() disp.Load().Stop() newInhibitor := inhibit.NewInhibitor(alerts, conf.InhibitRules, marker, logger) inhibitor.Store(newInhibitor) // An interface value that holds a nil concrete value is non-nil. // Therefore we explicly pass an empty interface, to detect if the // cluster is not enabled in notify. var pipelinePeer notify.Peer if peer != nil { pipelinePeer = peer } pipeline := pipelineBuilder.New( receivers, waitFunc, newInhibitor, silencer, intervener, marker, notificationLog, pipelinePeer, ) configuredReceivers.Set(float64(len(activeReceivers))) configuredIntegrations.Set(float64(integrationsNum)) configuredInhibitionRules.Set(float64(len(conf.InhibitRules))) api.Update(conf, func(ctx context.Context, labels model.LabelSet) { inhibitor.Load().Mutes(ctx, labels) silencer.Mutes(ctx, labels) }) newDisp := dispatch.NewDispatcher( alerts, routes, pipeline, marker, timeoutFunc, *dispatchMaintenanceInterval, nil, logger, dispMetrics, ) routes.Walk(func(r *dispatch.Route) { if r.RouteOpts.RepeatInterval > *retention { configLogger.Warn( "repeat_interval is greater than the data retention period. It can lead to notifications being repeated more often than expected.", "repeat_interval", r.RouteOpts.RepeatInterval, "retention", *retention, "route", r.Key(), ) } if r.RouteOpts.RepeatInterval < r.RouteOpts.GroupInterval { configLogger.Warn( "repeat_interval is less than group_interval. Notifications will not repeat until the next group_interval.", "repeat_interval", r.RouteOpts.RepeatInterval, "group_interval", r.RouteOpts.GroupInterval, "route", r.Key(), ) } }) // first, start the inhibitor so the inhibition cache can populate // wait for this to load alerts before starting the dispatcher so // we don't accidentially notify for an alert that will be inhibited go newInhibitor.Run() newInhibitor.WaitForLoading() // next, start the dispatcher and wait for it to load before swapping the disp pointer. // This ensures that the API doesn't see the new dispatcher before it finishes populating // the aggrGroups go newDisp.Run(startTime.Add(*DispatchStartDelay)) newDisp.WaitForLoading() disp.Store(newDisp) err = tracingManager.ApplyConfig(conf.TracingConfig) if err != nil { return fmt.Errorf("failed to apply tracing config: %w", err) } go tracingManager.Run() return nil }) if err := configCoordinator.Reload(); err != nil { return 1 } // Make routePrefix default to externalURL path if empty string. if *routePrefix == "" { *routePrefix = amURL.Path } *routePrefix = "/" + strings.Trim(*routePrefix, "/") logger.Debug("route prefix", "routePrefix", *routePrefix) router := route.New().WithInstrumentation(instrumentHandler) if *routePrefix != "/" { router.Get("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, *routePrefix, http.StatusFound) }) router = router.WithPrefix(*routePrefix) } webReload := make(chan chan error) ui.Register(router, webReload, logger) mux := api.Register(router, *routePrefix) srv := &http.Server{ // instrument all handlers with tracing Handler: tracing.Middleware(mux), } srvc := make(chan struct{}) go func() { if err := web.ListenAndServe(srv, webConfig, logger); !errors.Is(err, http.ErrServerClosed) { logger.Error("Listen error", "err", err) close(srvc) } defer func() { if err := srv.Close(); err != nil { logger.Error("Error on closing the server", "err", err) } }() }() var ( hup = make(chan os.Signal, 1) term = make(chan os.Signal, 1) ) signal.Notify(hup, syscall.SIGHUP) signal.Notify(term, os.Interrupt, syscall.SIGTERM) for { select { case <-hup: // ignore error, already logged in `reload()` _ = configCoordinator.Reload() case errc := <-webReload: errc <- configCoordinator.Reload() case <-term: logger.Info("Received SIGTERM, exiting gracefully...") // shut down the tracing manager to flush any remaining spans. // this blocks for up to 5s tracingManager.Stop() return 0 case <-srvc: return 1 } } } // clusterWait returns a function that inspects the current peer state and returns // a duration of one base timeout for each peer with a higher ID than ourselves. func clusterWait(p *cluster.Peer, timeout time.Duration) func() time.Duration { return func() time.Duration { return time.Duration(p.Position()) * timeout } } func extURL(logger *slog.Logger, hostnamef func() (string, error), listen, external string) (*url.URL, error) { if external == "" { hostname, err := hostnamef() if err != nil { return nil, err } _, port, err := net.SplitHostPort(listen) if err != nil { return nil, err } if port == "" { logger.Warn("no port found for listen address", "address", listen) } external = fmt.Sprintf("http://%s:%s/", hostname, port) } u, err := url.Parse(external) if err != nil { return nil, err } if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("%q: invalid %q scheme, only 'http' and 'https' are supported", u.String(), u.Scheme) } ppref := strings.TrimRight(u.Path, "/") if ppref != "" && !strings.HasPrefix(ppref, "/") { ppref = "/" + ppref } u.Path = ppref return u, nil } ================================================ FILE: cmd/alertmanager/main_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "fmt" "testing" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" ) func TestExternalURL(t *testing.T) { hostname := "foo" for _, tc := range []struct { hostnameResolver func() (string, error) external string listen string expURL string err bool }{ { listen: ":9093", expURL: "http://" + hostname + ":9093", }, { listen: "localhost:9093", expURL: "http://" + hostname + ":9093", }, { listen: "localhost:", expURL: "http://" + hostname + ":", }, { external: "https://host.example.com", expURL: "https://host.example.com", }, { external: "https://host.example.com/", expURL: "https://host.example.com", }, { external: "http://host.example.com/alertmanager", expURL: "http://host.example.com/alertmanager", }, { external: "http://host.example.com/alertmanager/", expURL: "http://host.example.com/alertmanager", }, { external: "http://host.example.com/////alertmanager//", expURL: "http://host.example.com/////alertmanager", }, { err: true, }, { hostnameResolver: func() (string, error) { return "", fmt.Errorf("some error") }, err: true, }, { external: "://broken url string", err: true, }, { external: "host.example.com:8080", err: true, }, } { if tc.hostnameResolver == nil { tc.hostnameResolver = func() (string, error) { return hostname, nil } } t.Run(fmt.Sprintf("external=%q,listen=%q", tc.external, tc.listen), func(t *testing.T) { u, err := extURL(promslog.NewNopLogger(), tc.hostnameResolver, tc.listen, tc.external) if tc.err { require.Error(t, err) return } require.NoError(t, err) require.Equal(t, tc.expURL, u.String()) }) } } ================================================ FILE: cmd/amtool/README.md ================================================ # Generating amtool artifacts Amtool comes with the option to create a number of ease-of-use artifacts that can be created. ## Shell completion A bash completion script can be generated by calling `amtool --completion-script-bash`. The bash completion file can be added to `/etc/bash_completion.d/`. ## Man pages A man page can be generated by calling `amtool --help-man`. Man pages can be added to the man directory of your choice amtool --help-man > /usr/local/share/man/man1/amtool.1 sudo mandb Then you should be able to view the man pages as expected. ================================================ FILE: cmd/amtool/main.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import "github.com/prometheus/alertmanager/cli" func main() { cli.Execute() } ================================================ FILE: config/common/inhibitrule.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "fmt" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/matcher/compat" ) // InhibitRule defines an inhibition rule that mutes alerts that match the // target labels if an alert matching the source labels exists. // Both alerts have to have a set of labels being equal. type InhibitRule struct { // Name is an optional name for the inhibition rule. Name string `yaml:"name,omitempty" json:"name,omitempty"` // SourceMatch defines a set of labels that have to equal the given // value for source alerts. Deprecated. Remove before v1.0 release. SourceMatch map[string]string `yaml:"source_match,omitempty" json:"source_match,omitempty"` // SourceMatchRE defines pairs like SourceMatch but does regular expression // matching. Deprecated. Remove before v1.0 release. SourceMatchRE MatchRegexps `yaml:"source_match_re,omitempty" json:"source_match_re,omitempty"` // SourceMatchers defines a set of label matchers that have to be fulfilled for source alerts. SourceMatchers Matchers `yaml:"source_matchers,omitempty" json:"source_matchers,omitempty"` // TargetMatch defines a set of labels that have to equal the given // value for target alerts. Deprecated. Remove before v1.0 release. TargetMatch map[string]string `yaml:"target_match,omitempty" json:"target_match,omitempty"` // TargetMatchRE defines pairs like TargetMatch but does regular expression // matching. Deprecated. Remove before v1.0 release. TargetMatchRE MatchRegexps `yaml:"target_match_re,omitempty" json:"target_match_re,omitempty"` // TargetMatchers defines a set of label matchers that have to be fulfilled for target alerts. TargetMatchers Matchers `yaml:"target_matchers,omitempty" json:"target_matchers,omitempty"` // A set of labels that must be equal between the source and target alert // for them to be a match. Equal []string `yaml:"equal,omitempty" json:"equal,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for InhibitRule. func (r *InhibitRule) UnmarshalYAML(unmarshal func(any) error) error { type plain InhibitRule if err := unmarshal((*plain)(r)); err != nil { return err } for k := range r.SourceMatch { if !model.LabelNameRE.MatchString(k) { return fmt.Errorf("invalid label name %q", k) } } for k := range r.TargetMatch { if !model.LabelNameRE.MatchString(k) { return fmt.Errorf("invalid label name %q", k) } } for _, l := range r.Equal { labelName := model.LabelName(l) if !compat.IsValidLabelName(labelName) { return fmt.Errorf("invalid label name %q in equal list", l) } } return nil } ================================================ FILE: config/common/inhibitrule_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "testing" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/matcher/compat" ) func mustUnmarshalInhibitRule(t *testing.T, input string) InhibitRule { t.Helper() var r InhibitRule err := yaml.Unmarshal([]byte(input), &r) require.NoError(t, err) return r } func unmarshalInhibitRule(input string) (InhibitRule, error) { var r InhibitRule err := yaml.Unmarshal([]byte(input), &r) return r, err } const inhibitRuleEqualYAML = ` source_matchers: ['foo=bar'] target_matchers: ['bar=baz'] equal: ['qux', 'corge'] ` const inhibitRuleEqualUTF8YAML = ` source_matchers: ['foo=bar'] target_matchers: ['bar=baz'] equal: ['qux🙂', 'corge'] ` func TestInhibitRuleEqual(t *testing.T) { r := mustUnmarshalInhibitRule(t, inhibitRuleEqualYAML) // The inhibition rule should have the expected equal labels. require.Equal(t, []string{"qux", "corge"}, r.Equal) // Should not be able to unmarshal configuration with UTF-8 in equals list. _, err := unmarshalInhibitRule(inhibitRuleEqualUTF8YAML) require.Error(t, err) require.Equal(t, "invalid label name \"qux🙂\" in equal list", err.Error()) // Change the mode to UTF-8 mode. ff, err := featurecontrol.NewFlags(promslog.NewNopLogger(), featurecontrol.FeatureUTF8StrictMode) require.NoError(t, err) compat.InitFromFlags(promslog.NewNopLogger(), ff) // Restore the mode to classic at the end of the test. ff, err = featurecontrol.NewFlags(promslog.NewNopLogger(), featurecontrol.FeatureClassicMode) require.NoError(t, err) defer compat.InitFromFlags(promslog.NewNopLogger(), ff) r = mustUnmarshalInhibitRule(t, inhibitRuleEqualYAML) // The inhibition rule should have the expected equal labels. require.Equal(t, []string{"qux", "corge"}, r.Equal) // Should also be able to unmarshal configuration with UTF-8 in equals list. r = mustUnmarshalInhibitRule(t, inhibitRuleEqualUTF8YAML) // The inhibition rule should have the expected equal labels. require.Equal(t, []string{"qux🙂", "corge"}, r.Equal) } ================================================ FILE: config/common/matchers.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "encoding/json" "fmt" "regexp" "sort" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/pkg/labels" ) // MatchRegexps represents a map of Regexp. type MatchRegexps map[string]Regexp // UnmarshalYAML implements the yaml.Unmarshaler interface for MatchRegexps. func (m *MatchRegexps) UnmarshalYAML(unmarshal func(any) error) error { type plain MatchRegexps if err := unmarshal((*plain)(m)); err != nil { return err } for k, v := range *m { if !model.LabelNameRE.MatchString(k) { return fmt.Errorf("invalid label name %q", k) } if v.Regexp == nil { return fmt.Errorf("invalid regexp value for %q", k) } } return nil } // Regexp encapsulates a regexp.Regexp and makes it YAML marshalable. type Regexp struct { *regexp.Regexp Original string } // UnmarshalYAML implements the yaml.Unmarshaler interface for Regexp. func (re *Regexp) UnmarshalYAML(unmarshal func(any) error) error { var s string if err := unmarshal(&s); err != nil { return err } regex, err := regexp.Compile("^(?:" + s + ")$") if err != nil { return err } re.Regexp = regex re.Original = s return nil } // MarshalYAML implements the yaml.Marshaler interface for Regexp. func (re Regexp) MarshalYAML() (any, error) { if re.Original != "" { return re.Original, nil } return nil, nil } // UnmarshalJSON implements the json.Unmarshaler interface for Regexp. func (re *Regexp) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err != nil { return err } regex, err := regexp.Compile("^(?:" + s + ")$") if err != nil { return err } re.Regexp = regex re.Original = s return nil } // MarshalJSON implements the json.Marshaler interface for Regexp. func (re Regexp) MarshalJSON() ([]byte, error) { if re.Original != "" { return json.Marshal(re.Original) } return []byte("null"), nil } // Matchers is label.Matchers with an added UnmarshalYAML method to implement the yaml.Unmarshaler interface // and MarshalYAML to implement the yaml.Marshaler interface. type Matchers labels.Matchers // UnmarshalYAML implements the yaml.Unmarshaler interface for Matchers. func (m *Matchers) UnmarshalYAML(unmarshal func(any) error) error { var lines []string if err := unmarshal(&lines); err != nil { return err } for _, line := range lines { pm, err := compat.Matchers(line, "config") if err != nil { return err } *m = append(*m, pm...) } sort.Sort(labels.Matchers(*m)) return nil } // MarshalYAML implements the yaml.Marshaler interface for Matchers. func (m Matchers) MarshalYAML() (any, error) { result := make([]string, len(m)) for i, matcher := range m { result[i] = matcher.String() } return result, nil } // UnmarshalJSON implements the json.Unmarshaler interface for Matchers. func (m *Matchers) UnmarshalJSON(data []byte) error { var lines []string if err := json.Unmarshal(data, &lines); err != nil { return err } for _, line := range lines { pm, err := compat.Matchers(line, "config") if err != nil { return err } *m = append(*m, pm...) } sort.Sort(labels.Matchers(*m)) return nil } // MarshalJSON implements the json.Marshaler interface for Matchers. func (m Matchers) MarshalJSON() ([]byte, error) { if len(m) == 0 { return []byte("[]"), nil } result := make([]string, len(m)) for i, matcher := range m { result[i] = matcher.String() } return json.Marshal(result) } ================================================ FILE: config/common/matchers_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "encoding/json" "regexp" "testing" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) func TestMarshalRegexpWithNilValue(t *testing.T) { r := &Regexp{} out, err := json.Marshal(r) require.NoError(t, err) require.Equal(t, "null", string(out)) out, err = yaml.Marshal(r) require.NoError(t, err) require.Equal(t, "null\n", string(out)) } func TestUnmarshalEmptyRegexp(t *testing.T) { b := []byte(`""`) { var re Regexp err := json.Unmarshal(b, &re) require.NoError(t, err) require.Equal(t, regexp.MustCompile("^(?:)$"), re.Regexp) require.Empty(t, re.Original) } { var re Regexp err := yaml.Unmarshal(b, &re) require.NoError(t, err) require.Equal(t, regexp.MustCompile("^(?:)$"), re.Regexp) require.Empty(t, re.Original) } } func TestUnmarshalNullRegexp(t *testing.T) { input := []byte(`null`) { var re Regexp err := json.Unmarshal(input, &re) require.NoError(t, err) require.Empty(t, re.Original) } { var re Regexp err := yaml.Unmarshal(input, &re) // Interestingly enough, unmarshalling `null` in YAML doesn't even call UnmarshalYAML. require.NoError(t, err) require.Nil(t, re.Regexp) require.Empty(t, re.Original) } } func TestMarshalEmptyMatchers(t *testing.T) { r := Matchers{} out, err := json.Marshal(r) require.NoError(t, err) require.Equal(t, "[]", string(out)) out, err = yaml.Marshal(r) require.NoError(t, err) require.Equal(t, "[]\n", string(out)) } ================================================ FILE: config/common/notifierconfig.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common // NotifierConfig contains base options common across all notifier configurations. type NotifierConfig struct { VSendResolved bool `yaml:"send_resolved" json:"send_resolved"` } func (nc *NotifierConfig) SendResolved() bool { return nc.VSendResolved } ================================================ FILE: config/common/url.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "encoding/json" "errors" "fmt" "html/template" "net/url" "strings" commoncfg "github.com/prometheus/common/config" ) const SecretToken = "" var SecretTokenJSON string func init() { b, err := json.Marshal(SecretToken) if err != nil { panic(err) } SecretTokenJSON = string(b) } // URL is a custom type that represents an HTTP or HTTPS URL and allows validation at configuration load time. type URL struct { *url.URL } // Copy makes a deep-copy of the struct. func (u *URL) Copy() *URL { v := *u.URL return &URL{&v} } // MarshalYAML implements the yaml.Marshaler interface for URL. func (u URL) MarshalYAML() (any, error) { if u.URL != nil { return u.String(), nil } return nil, nil } // UnmarshalYAML implements the yaml.Unmarshaler interface for URL. func (u *URL) UnmarshalYAML(unmarshal func(any) error) error { var s string if err := unmarshal(&s); err != nil { return err } urlp, err := ParseURL(s) if err != nil { return err } u.URL = urlp.URL return nil } // MarshalJSON implements the json.Marshaler interface for URL. func (u URL) MarshalJSON() ([]byte, error) { if u.URL != nil { return json.Marshal(u.String()) } return []byte("null"), nil } // UnmarshalJSON implements the json.Marshaler interface for URL. func (u *URL) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err != nil { return err } urlp, err := ParseURL(s) if err != nil { return err } u.URL = urlp.URL return nil } // SecretURL is a URL that must not be revealed on marshaling. type SecretURL URL // MarshalYAML implements the yaml.Marshaler interface for SecretURL. func (s SecretURL) MarshalYAML() (any, error) { if s.URL != nil { if commoncfg.MarshalSecretValue { return s.String(), nil } return SecretToken, nil } return nil, nil } // UnmarshalYAML implements the yaml.Unmarshaler interface for SecretURL. func (s *SecretURL) UnmarshalYAML(unmarshal func(any) error) error { var str string if err := unmarshal(&str); err != nil { return err } // In order to deserialize a previously serialized configuration (eg from // the Alertmanager API with amtool), `` needs to be treated // specially, as it isn't a valid URL. if str == SecretToken { s.URL = &url.URL{} return nil } return unmarshal((*URL)(s)) } // MarshalJSON implements the json.Marshaler interface for SecretURL. func (s SecretURL) MarshalJSON() ([]byte, error) { if s.URL == nil { return json.Marshal("") } if commoncfg.MarshalSecretValue { return json.Marshal(s.String()) } return json.Marshal(SecretToken) } // UnmarshalJSON implements the json.Marshaler interface for SecretURL. func (s *SecretURL) UnmarshalJSON(data []byte) error { // In order to deserialize a previously serialized configuration (eg from // the Alertmanager API with amtool), `` needs to be treated // specially, as it isn't a valid URL. if string(data) == SecretToken || string(data) == SecretTokenJSON { s.URL = &url.URL{} return nil } // Redact the secret URL in case of errors if err := json.Unmarshal(data, (*URL)(s)); err != nil { if commoncfg.MarshalSecretValue { return err } return errors.New(strings.ReplaceAll(err.Error(), string(data), "[REDACTED]")) } return nil } // containsTemplating checks if the string contains template syntax. func containsTemplating(s string) (bool, error) { if !strings.Contains(s, "{{") { return false, nil } // If it contains template syntax, validate it's actually a valid templ. _, err := template.New("").Parse(s) if err != nil { return true, err } return true, nil } // SecretTemplateURL is a Secret string that represents a URL which may contain // Go template syntax. Unlike SecretURL, it allows templated values and only // validates non-templated URLs at unmarshal time. type SecretTemplateURL commoncfg.Secret // MarshalYAML implements the yaml.Marshaler interface for SecretTemplateURL. func (s SecretTemplateURL) MarshalYAML() (any, error) { if s != "" { if commoncfg.MarshalSecretValue { return string(s), nil } return SecretToken, nil } return nil, nil } // UnmarshalYAML implements the yaml.Unmarshaler interface for SecretTemplateURL. func (s *SecretTemplateURL) UnmarshalYAML(unmarshal func(any) error) error { type plain commoncfg.Secret if err := unmarshal((*plain)(s)); err != nil { return err } urlStr := string(*s) // Skip validation for empty strings or secret token if urlStr == "" || urlStr == SecretToken { return nil } // Check if the URL contains template syntax isTemplated, err := containsTemplating(urlStr) if err != nil { return fmt.Errorf("invalid template syntax: %w", err) } // Only validate as URL if it's not templated if !isTemplated { if _, err := ParseURL(urlStr); err != nil { return fmt.Errorf("invalid URL: %w", err) } } return nil } // MarshalJSON implements the json.Marshaler interface for SecretTemplateURL. func (s SecretTemplateURL) MarshalJSON() ([]byte, error) { return commoncfg.Secret(s).MarshalJSON() } // UnmarshalJSON implements the json.Unmarshaler interface for SecretTemplateURL. func (s *SecretTemplateURL) UnmarshalJSON(data []byte) error { if string(data) == SecretToken || string(data) == SecretTokenJSON { *s = "" return nil } // Just unmarshal as a string since Secret doesn't have UnmarshalJSON var str string if err := json.Unmarshal(data, &str); err != nil { return err } *s = SecretTemplateURL(str) return nil } func MustParseURL(s string) *URL { u, err := ParseURL(s) if err != nil { panic(err) } return u } func ParseURL(s string) (*URL, error) { u, err := url.Parse(s) if err != nil { return nil, err } if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("unsupported scheme %q for URL", u.Scheme) } if u.Host == "" { return nil, errors.New("missing host for URL") } return &URL{u}, nil } ================================================ FILE: config/common/url_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package common import ( "encoding/json" "net/url" "testing" commoncfg "github.com/prometheus/common/config" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) func TestJSONMarshalHideSecretURL(t *testing.T) { urlp, err := url.Parse("http://example.com/") if err != nil { t.Fatal(err) } u := &SecretURL{urlp} c, err := json.Marshal(u) if err != nil { t.Fatal(err) } // u003c -> "<" // u003e -> ">" require.Equal(t, "\"\\u003csecret\\u003e\"", string(c), "SecretURL not properly elided in JSON.") // Check that the marshaled data can be unmarshaled again. out := &SecretURL{} err = json.Unmarshal(c, out) if err != nil { t.Fatal(err) } c, err = yaml.Marshal(u) if err != nil { t.Fatal(err) } require.Equal(t, "\n", string(c), "SecretURL not properly elided in YAML.") // Check that the marshaled data can be unmarshaled again. out = &SecretURL{} err = yaml.Unmarshal(c, &out) if err != nil { t.Fatal(err) } } func TestUnmarshalSecretURL(t *testing.T) { b := []byte(`"http://example.com/se cret"`) var u SecretURL err := json.Unmarshal(b, &u) if err != nil { t.Fatal(err) } require.Equal(t, "http://example.com/se%20cret", u.String(), "SecretURL not properly unmarshaled in JSON.") err = yaml.Unmarshal(b, &u) if err != nil { t.Fatal(err) } require.Equal(t, "http://example.com/se%20cret", u.String(), "SecretURL not properly unmarshaled in YAML.") } func TestHideSecretURL(t *testing.T) { b := []byte(`"://wrongurl/"`) var u SecretURL err := json.Unmarshal(b, &u) require.Error(t, err) require.NotContains(t, err.Error(), "wrongurl") } func TestShowMarshalSecretURL(t *testing.T) { commoncfg.MarshalSecretValue = true defer func() { commoncfg.MarshalSecretValue = false }() b := []byte(`"://wrongurl/"`) var u SecretURL err := json.Unmarshal(b, &u) require.Error(t, err) require.Contains(t, err.Error(), "wrongurl") } func TestMarshalURL(t *testing.T) { for name, tc := range map[string]struct { input *URL expectedJSON string expectedYAML string }{ "url": { input: MustParseURL("http://example.com/"), expectedJSON: "\"http://example.com/\"", expectedYAML: "http://example.com/\n", }, "wrapped nil value": { input: &URL{}, expectedJSON: "null", expectedYAML: "null\n", }, "wrapped empty URL": { input: &URL{&url.URL{}}, expectedJSON: "\"\"", expectedYAML: "\"\"\n", }, } { t.Run(name, func(t *testing.T) { j, err := json.Marshal(tc.input) require.NoError(t, err) require.Equal(t, tc.expectedJSON, string(j), "URL not properly marshaled into JSON.") y, err := yaml.Marshal(tc.input) require.NoError(t, err) require.Equal(t, tc.expectedYAML, string(y), "URL not properly marshaled into YAML.") }) } } func TestUnmarshalNilURL(t *testing.T) { b := []byte(`null`) { var u URL err := json.Unmarshal(b, &u) require.Error(t, err, "unsupported scheme \"\" for URL") } { var u URL err := yaml.Unmarshal(b, &u) require.NoError(t, err) } } func TestUnmarshalEmptyURL(t *testing.T) { b := []byte(`""`) { var u URL err := json.Unmarshal(b, &u) require.Error(t, err, "unsupported scheme \"\" for URL") require.Equal(t, (*url.URL)(nil), u.URL) } { var u URL err := yaml.Unmarshal(b, &u) require.Error(t, err, "unsupported scheme \"\" for URL") require.Equal(t, (*url.URL)(nil), u.URL) } } func TestUnmarshalURL(t *testing.T) { b := []byte(`"http://example.com/a b"`) var u URL err := json.Unmarshal(b, &u) if err != nil { t.Fatal(err) } require.Equal(t, "http://example.com/a%20b", u.String(), "URL not properly unmarshaled in JSON.") err = yaml.Unmarshal(b, &u) if err != nil { t.Fatal(err) } require.Equal(t, "http://example.com/a%20b", u.String(), "URL not properly unmarshaled in YAML.") } func TestUnmarshalInvalidURL(t *testing.T) { for _, b := range [][]byte{ []byte(`"://example.com"`), []byte(`"http:example.com"`), []byte(`"telnet://example.com"`), } { var u URL err := json.Unmarshal(b, &u) if err == nil { t.Errorf("Expected an error unmarshaling %q from JSON", string(b)) } err = yaml.Unmarshal(b, &u) if err == nil { t.Errorf("Expected an error unmarshaling %q from YAML", string(b)) } t.Logf("%s", err) } } func TestUnmarshalRelativeURL(t *testing.T) { b := []byte(`"/home"`) var u URL err := json.Unmarshal(b, &u) if err == nil { t.Errorf("Expected an error parsing URL") } err = yaml.Unmarshal(b, &u) if err == nil { t.Errorf("Expected an error parsing URL") } } ================================================ FILE: config/config.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "cmp" "encoding/json" "errors" "fmt" "net" "os" "path/filepath" "strings" "text/template" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "gopkg.in/yaml.v2" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/tracing" ) // containsTemplating checks if the string contains template syntax. func containsTemplating(s string) (bool, error) { if !strings.Contains(s, "{{") { return false, nil } // If it contains template syntax, validate it's actually a valid templ. _, err := template.New("").Parse(s) if err != nil { return true, err } return true, nil } // SecretTemplateURL is a Secret string that represents a URL which may contain // Go template syntax. Unlike SecretURL, it allows templated values and only // validates non-templated URLs at unmarshal time. type SecretTemplateURL commoncfg.Secret // MarshalYAML implements the yaml.Marshaler interface for SecretTemplateURL. func (s SecretTemplateURL) MarshalYAML() (any, error) { if s != "" { if commoncfg.MarshalSecretValue { return string(s), nil } return amcommoncfg.SecretToken, nil } return nil, nil } // UnmarshalYAML implements the yaml.Unmarshaler interface for SecretTemplateURL. func (s *SecretTemplateURL) UnmarshalYAML(unmarshal func(any) error) error { type plain commoncfg.Secret if err := unmarshal((*plain)(s)); err != nil { return err } urlStr := string(*s) // Skip validation for empty strings or secret token if urlStr == "" || urlStr == amcommoncfg.SecretToken { return nil } // Check if the URL contains template syntax isTemplated, err := containsTemplating(urlStr) if err != nil { return fmt.Errorf("invalid template syntax: %w", err) } // Only validate as URL if it's not templated if !isTemplated { if _, err := amcommoncfg.ParseURL(urlStr); err != nil { return fmt.Errorf("invalid URL: %w", err) } } return nil } // MarshalJSON implements the json.Marshaler interface for SecretTemplateURL. func (s SecretTemplateURL) MarshalJSON() ([]byte, error) { return commoncfg.Secret(s).MarshalJSON() } // UnmarshalJSON implements the json.Unmarshaler interface for SecretTemplateURL. func (s *SecretTemplateURL) UnmarshalJSON(data []byte) error { if string(data) == amcommoncfg.SecretToken || string(data) == amcommoncfg.SecretTokenJSON { *s = "" return nil } // Just unmarshal as a string since Secret doesn't have UnmarshalJSON var str string if err := json.Unmarshal(data, &str); err != nil { return err } *s = SecretTemplateURL(str) return nil } // Load parses the YAML input s into a Config. func Load(s string) (*Config, error) { cfg := &Config{} err := yaml.UnmarshalStrict([]byte(s), cfg) if err != nil { return nil, err } // Check if we have a root route. We cannot check for it in the // UnmarshalYAML method because it won't be called if the input is empty // (e.g. the config file is empty or only contains whitespace). if cfg.Route == nil { return nil, errors.New("no route provided in config") } // Check if continue in root route. if cfg.Route.Continue { return nil, errors.New("cannot have continue in root route") } cfg.original = s return cfg, nil } // LoadFile parses the given YAML file into a Config. func LoadFile(filename string) (*Config, error) { content, err := os.ReadFile(filename) if err != nil { return nil, err } cfg, err := Load(string(content)) if err != nil { return nil, err } resolveFilepaths(filepath.Dir(filename), cfg) return cfg, nil } // resolveFilepaths joins all relative paths in a configuration // with a given base directory. func resolveFilepaths(baseDir string, cfg *Config) { join := func(fp string) string { if len(fp) > 0 && !filepath.IsAbs(fp) { fp = filepath.Join(baseDir, fp) } return fp } for i, tf := range cfg.Templates { cfg.Templates[i] = join(tf) } cfg.Global.HTTPConfig.SetDirectory(baseDir) for _, receiver := range cfg.Receivers { for _, cfg := range receiver.OpsGenieConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.PagerdutyConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.PushoverConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.SlackConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.VictorOpsConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.WebhookConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.WechatConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.SNSConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.TelegramConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.DiscordConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.WebexConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.MSTeamsConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.MSTeamsV2Configs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.JiraConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.RocketchatConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } for _, cfg := range receiver.MattermostConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } } } // MuteTimeInterval represents a named set of time intervals for which a route should be muted. type MuteTimeInterval struct { Name string `yaml:"name" json:"name"` TimeIntervals []timeinterval.TimeInterval `yaml:"time_intervals" json:"time_intervals"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for MuteTimeInterval. func (mt *MuteTimeInterval) UnmarshalYAML(unmarshal func(any) error) error { type plain MuteTimeInterval if err := unmarshal((*plain)(mt)); err != nil { return err } if mt.Name == "" { return errors.New("missing name in mute time interval") } return nil } // TimeInterval represents a named set of time intervals for which a route should be muted. type TimeInterval struct { Name string `yaml:"name" json:"name"` TimeIntervals []timeinterval.TimeInterval `yaml:"time_intervals" json:"time_intervals"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for MuteTimeInterval. func (ti *TimeInterval) UnmarshalYAML(unmarshal func(any) error) error { type plain TimeInterval if err := unmarshal((*plain)(ti)); err != nil { return err } if ti.Name == "" { return errors.New("missing name in time interval") } return nil } // Config is the top-level configuration for Alertmanager's config files. type Config struct { Global *GlobalConfig `yaml:"global,omitempty" json:"global,omitempty"` Route *Route `yaml:"route,omitempty" json:"route,omitempty"` InhibitRules []amcommoncfg.InhibitRule `yaml:"inhibit_rules,omitempty" json:"inhibit_rules,omitempty"` Receivers []Receiver `yaml:"receivers,omitempty" json:"receivers,omitempty"` Templates []string `yaml:"templates" json:"templates"` // Deprecated. Remove before v1.0 release. MuteTimeIntervals []MuteTimeInterval `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty"` TimeIntervals []TimeInterval `yaml:"time_intervals,omitempty" json:"time_intervals,omitempty"` TracingConfig tracing.TracingConfig `yaml:"tracing,omitempty" json:"tracing,omitempty"` // original is the input from which the config was parsed. original string } func (c Config) String() string { b, err := yaml.Marshal(c) if err != nil { return fmt.Sprintf("", err) } return string(b) } // UnmarshalYAML implements the yaml.Unmarshaler interface for Config. func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { // We want to set c to the defaults and then overwrite it with the input. // To make unmarshal fill the plain data struct rather than calling UnmarshalYAML // again, we have to hide it using a type indirection. type plain Config if err := unmarshal((*plain)(c)); err != nil { return err } // If a global block was open but empty the default global config is overwritten. // We have to restore it here. if c.Global == nil { c.Global = &GlobalConfig{} *c.Global = DefaultGlobalConfig() } if c.Global.SlackAppToken != "" && len(c.Global.SlackAppTokenFile) > 0 { return errors.New("at most one of slack_app_token & slack_app_token_file must be configured") } if c.Global.SlackAPIURL != nil && len(c.Global.SlackAPIURLFile) > 0 { return errors.New("at most one of slack_api_url & slack_api_url_file must be configured") } if (c.Global.SlackAppToken != "" || len(c.Global.SlackAppTokenFile) > 0) && (c.Global.SlackAPIURL != nil || len(c.Global.SlackAPIURLFile) > 0) { // Support transition from workaround suggested in https://github.com/prometheus/alertmanager/issues/2513, // where users might set `slack_api_url` at the top level and then have `http_config` with individual // bearer tokens in the receivers. if c.Global.SlackAPIURL.String() != c.Global.SlackAppURL.String() { return errors.New("at most one of slack_app_token/slack_app_token_file & slack_api_url/slack_api_url_file must be configured") } } if c.Global.OpsGenieAPIKey != "" && len(c.Global.OpsGenieAPIKeyFile) > 0 { return errors.New("at most one of opsgenie_api_key & opsgenie_api_key_file must be configured") } if c.Global.VictorOpsAPIKey != "" && len(c.Global.VictorOpsAPIKeyFile) > 0 { return errors.New("at most one of victorops_api_key & victorops_api_key_file must be configured") } if c.Global.TelegramBotToken != "" && len(c.Global.TelegramBotTokenFile) > 0 { return errors.New("at most one of telegram_bot_token & telegram_bot_token_file must be configured") } if len(c.Global.SMTPAuthPassword) > 0 && len(c.Global.SMTPAuthPasswordFile) > 0 { return errors.New("at most one of smtp_auth_password & smtp_auth_password_file must be configured") } if c.Global.RocketchatToken != nil && len(c.Global.RocketchatTokenFile) > 0 { return errors.New("at most one of rocketchat_token & rocketchat_token_file must be configured") } if c.Global.RocketchatTokenID != nil && len(c.Global.RocketchatTokenIDFile) > 0 { return errors.New("at most one of rocketchat_token_id & rocketchat_token_id_file must be configured") } if len(c.Global.SMTPAuthSecret) > 0 && len(c.Global.SMTPAuthSecretFile) > 0 { return fmt.Errorf("at most one of smtp_auth_secret & smtp_auth_secret_file must be configured") } if c.Global.WeChatAPISecret != "" && len(c.Global.WeChatAPISecretFile) > 0 { return errors.New("at most one of wechat_api_secret & wechat_api_secret_file must be configured") } if c.Global.MattermostWebhookURL != nil && len(c.Global.MattermostWebhookURLFile) > 0 { return errors.New("at most one of mattermost_webhook_url & mattermost_webhook_url_file must be configured") } names := map[string]struct{}{} for _, rcv := range c.Receivers { if _, ok := names[rcv.Name]; ok { return fmt.Errorf("notification config name %q is not unique", rcv.Name) } for _, wh := range rcv.WebhookConfigs { if wh == nil { return errors.New("missing webhook config") } wh.HTTPConfig = cmp.Or(wh.HTTPConfig, c.Global.HTTPConfig) } for _, ec := range rcv.EmailConfigs { if ec == nil { return errors.New("missing email config") } ec.TLSConfig = cmp.Or(ec.TLSConfig, c.Global.SMTPTLSConfig) ec.Smarthost = cmp.Or(ec.Smarthost, c.Global.SMTPSmarthost) if ec.Smarthost.String() == "" { return errors.New("no global SMTP smarthost set") } ec.From = cmp.Or(ec.From, c.Global.SMTPFrom) if ec.From == "" { return errors.New("no global SMTP from set") } ec.Hello = cmp.Or(ec.Hello, c.Global.SMTPHello) ec.AuthUsername = cmp.Or(ec.AuthUsername, c.Global.SMTPAuthUsername) if ec.AuthPassword == "" && ec.AuthPasswordFile == "" { ec.AuthPassword = c.Global.SMTPAuthPassword ec.AuthPasswordFile = c.Global.SMTPAuthPasswordFile } ec.AuthSecret = cmp.Or(ec.AuthSecret, c.Global.SMTPAuthSecret) ec.AuthSecretFile = cmp.Or(ec.AuthSecretFile, c.Global.SMTPAuthSecretFile) ec.AuthIdentity = cmp.Or(ec.AuthIdentity, c.Global.SMTPAuthIdentity) if ec.RequireTLS == nil { ec.RequireTLS = new(bool) *ec.RequireTLS = c.Global.SMTPRequireTLS } if ec.ForceImplicitTLS == nil { ec.ForceImplicitTLS = c.Global.SMTPForceImplicitTLS } } for _, sc := range rcv.SlackConfigs { if sc == nil { sc = &SlackConfig{} } sc.AppURL = cmp.Or(sc.AppURL, c.Global.SlackAppURL) if sc.AppURL == nil { return errors.New("no global Slack App URL set") } // we only want to set the app token from global if there's no local authorization or webhook url if sc.AppToken == "" && len(sc.AppTokenFile) == 0 && (sc.HTTPConfig == nil || sc.HTTPConfig.Authorization == nil) && sc.APIURL == nil { sc.AppToken = c.Global.SlackAppToken sc.AppTokenFile = c.Global.SlackAppTokenFile } if sc.APIURL == nil && len(sc.APIURLFile) == 0 { sc.APIURL = c.Global.SlackAPIURL sc.APIURLFile = c.Global.SlackAPIURLFile } if sc.APIURL == nil && len(sc.APIURLFile) == 0 && sc.AppToken == "" && len(sc.AppTokenFile) == 0 { return errors.New("no Slack API URL nor App token set either inline or in a file") } if sc.HTTPConfig == nil { // we don't want to change the global http config when setting the receiver's http config, do we do a copy httpconfig := *c.Global.HTTPConfig sc.HTTPConfig = &httpconfig } if sc.AppToken != "" || len(sc.AppTokenFile) != 0 { if sc.HTTPConfig.Authorization != nil { return errors.New("http authorization can't be set when using Slack App tokens") } sc.HTTPConfig.Authorization = &commoncfg.Authorization{ Type: "Bearer", Credentials: commoncfg.Secret(sc.AppToken), CredentialsFile: sc.AppTokenFile, } sc.APIURL = (*amcommoncfg.SecretURL)(sc.AppURL) } } for _, poc := range rcv.PushoverConfigs { if poc == nil { return errors.New("missing pushover config") } poc.HTTPConfig = cmp.Or(poc.HTTPConfig, c.Global.HTTPConfig) } for _, pdc := range rcv.PagerdutyConfigs { if pdc == nil { return errors.New("missing pagerduty config") } pdc.HTTPConfig = cmp.Or(pdc.HTTPConfig, c.Global.HTTPConfig) pdc.URL = cmp.Or(pdc.URL, c.Global.PagerdutyURL) if pdc.URL == nil { return errors.New("no global PagerDuty URL set") } } for _, iio := range rcv.IncidentioConfigs { if iio == nil { return errors.New("missing incidentio config") } iio.HTTPConfig = cmp.Or(iio.HTTPConfig, c.Global.HTTPConfig) } for _, ogc := range rcv.OpsGenieConfigs { if ogc == nil { ogc = &OpsGenieConfig{} } ogc.HTTPConfig = cmp.Or(ogc.HTTPConfig, c.Global.HTTPConfig) ogc.APIURL = cmp.Or(ogc.APIURL, c.Global.OpsGenieAPIURL) if ogc.APIURL == nil { return errors.New("no global OpsGenie URL set") } if !strings.HasSuffix(ogc.APIURL.Path, "/") { ogc.APIURL.Path += "/" } ogc.APIKey = cmp.Or(ogc.APIKey, c.Global.OpsGenieAPIKey) ogc.APIKeyFile = cmp.Or(ogc.APIKeyFile, c.Global.OpsGenieAPIKeyFile) if ogc.APIKey == "" && len(ogc.APIKeyFile) == 0 { return errors.New("no global OpsGenie API Key set either inline or in a file") } } for _, wcc := range rcv.WechatConfigs { if wcc == nil { wcc = &WechatConfig{} } wcc.HTTPConfig = cmp.Or(wcc.HTTPConfig, c.Global.HTTPConfig) wcc.APIURL = cmp.Or(wcc.APIURL, c.Global.WeChatAPIURL) if wcc.APIURL == nil { return errors.New("no global Wechat URL set") } if wcc.APISecret == "" && len(wcc.APISecretFile) == 0 { if c.Global.WeChatAPISecret == "" && len(c.Global.WeChatAPISecretFile) == 0 { return errors.New("no global Wechat Api Secret set either inline or in a file") } wcc.APISecret = c.Global.WeChatAPISecret wcc.APISecretFile = c.Global.WeChatAPISecretFile } wcc.CorpID = cmp.Or(wcc.CorpID, c.Global.WeChatAPICorpID) if wcc.CorpID == "" { return errors.New("no global Wechat CorpID set") } if !strings.HasSuffix(wcc.APIURL.Path, "/") { wcc.APIURL.Path += "/" } } for _, voc := range rcv.VictorOpsConfigs { if voc == nil { return errors.New("missing victorops config") } voc.HTTPConfig = cmp.Or(voc.HTTPConfig, c.Global.HTTPConfig) voc.APIURL = cmp.Or(voc.APIURL, c.Global.VictorOpsAPIURL) if voc.APIURL == nil { return errors.New("no global VictorOps URL set") } if !strings.HasSuffix(voc.APIURL.Path, "/") { voc.APIURL.Path += "/" } voc.APIKey = cmp.Or(voc.APIKey, c.Global.VictorOpsAPIKey) voc.APIKeyFile = cmp.Or(voc.APIKeyFile, c.Global.VictorOpsAPIKeyFile) if voc.APIKey == "" && len(voc.APIKeyFile) == 0 { return errors.New("no global VictorOps API Key set") } } for _, sns := range rcv.SNSConfigs { if sns == nil { return errors.New("missing sns config") } sns.HTTPConfig = cmp.Or(sns.HTTPConfig, c.Global.HTTPConfig) } for _, telegram := range rcv.TelegramConfigs { if telegram == nil { return errors.New("missing telegram config") } telegram.HTTPConfig = cmp.Or(telegram.HTTPConfig, c.Global.HTTPConfig) telegram.APIUrl = cmp.Or(telegram.APIUrl, c.Global.TelegramAPIUrl) if telegram.BotToken == "" && len(telegram.BotTokenFile) == 0 { if c.Global.TelegramBotToken == "" && len(c.Global.TelegramBotTokenFile) == 0 { return errors.New("missing bot_token or bot_token_file on telegram_config") } telegram.BotToken = c.Global.TelegramBotToken telegram.BotTokenFile = c.Global.TelegramBotTokenFile } } for _, discord := range rcv.DiscordConfigs { if discord == nil { return errors.New("missing discord config") } discord.HTTPConfig = cmp.Or(discord.HTTPConfig, c.Global.HTTPConfig) if discord.WebhookURL == nil && len(discord.WebhookURLFile) == 0 { return errors.New("no discord webhook URL or URLFile provided") } } for _, webex := range rcv.WebexConfigs { if webex == nil { return errors.New("missing webex config") } webex.HTTPConfig = cmp.Or(webex.HTTPConfig, c.Global.HTTPConfig) webex.APIURL = cmp.Or(webex.APIURL, c.Global.WebexAPIURL) if webex.APIURL == nil { return errors.New("no global Webex URL set") } } for _, msteams := range rcv.MSTeamsConfigs { if msteams == nil { return errors.New("missing msteams config") } msteams.HTTPConfig = cmp.Or(msteams.HTTPConfig, c.Global.HTTPConfig) if msteams.WebhookURL == nil && len(msteams.WebhookURLFile) == 0 { return errors.New("no msteams webhook URL or URLFile provided") } } for _, msteamsv2 := range rcv.MSTeamsV2Configs { if msteamsv2 == nil { return errors.New("missing msteamsv2 config") } msteamsv2.HTTPConfig = cmp.Or(msteamsv2.HTTPConfig, c.Global.HTTPConfig) if msteamsv2.WebhookURL == nil && len(msteamsv2.WebhookURLFile) == 0 { return errors.New("no msteamsv2 webhook URL or URLFile provided") } } for _, jira := range rcv.JiraConfigs { if jira == nil { return errors.New("missing jira config") } jira.HTTPConfig = cmp.Or(jira.HTTPConfig, c.Global.HTTPConfig) jira.APIURL = cmp.Or(jira.APIURL, c.Global.JiraAPIURL) if jira.APIURL == nil { return errors.New("no global Jira Cloud URL set") } } for _, rocketchat := range rcv.RocketchatConfigs { if rocketchat == nil { rocketchat = &RocketchatConfig{} } rocketchat.HTTPConfig = cmp.Or(rocketchat.HTTPConfig, c.Global.HTTPConfig) rocketchat.APIURL = cmp.Or(rocketchat.APIURL, c.Global.RocketchatAPIURL) rocketchat.TokenID = cmp.Or(rocketchat.TokenID, c.Global.RocketchatTokenID) rocketchat.TokenIDFile = cmp.Or(rocketchat.TokenIDFile, c.Global.RocketchatTokenIDFile) if rocketchat.TokenID == nil && len(rocketchat.TokenIDFile) == 0 { return errors.New("no global Rocketchat TokenID set either inline or in a file") } rocketchat.Token = cmp.Or(rocketchat.Token, c.Global.RocketchatToken) rocketchat.TokenFile = cmp.Or(rocketchat.TokenFile, c.Global.RocketchatTokenFile) if rocketchat.Token == nil && len(rocketchat.TokenFile) == 0 { return errors.New("no global Rocketchat Token set either inline or in a file") } } for _, mattermost := range rcv.MattermostConfigs { if mattermost == nil { return errors.New("missing mattermost config") } mattermost.HTTPConfig = cmp.Or(mattermost.HTTPConfig, c.Global.HTTPConfig) if mattermost.WebhookURL == nil && len(mattermost.WebhookURLFile) == 0 { if c.Global.MattermostWebhookURL == nil && len(c.Global.MattermostWebhookURLFile) == 0 { return errors.New("missing webhook_url or webhook_url_file on mattermost_config") } mattermost.WebhookURL = c.Global.MattermostWebhookURL mattermost.WebhookURLFile = c.Global.MattermostWebhookURLFile } } names[rcv.Name] = struct{}{} } // The root route must not have any matchers as it is the fallback node // for all alerts. if c.Route == nil { return errors.New("no routes provided") } if len(c.Route.Receiver) == 0 { return errors.New("root route must specify a default receiver") } if len(c.Route.Match) > 0 || len(c.Route.MatchRE) > 0 || len(c.Route.Matchers) > 0 { return errors.New("root route must not have any matchers") } if len(c.Route.MuteTimeIntervals) > 0 { return errors.New("root route must not have any mute time intervals") } if len(c.Route.ActiveTimeIntervals) > 0 { return errors.New("root route must not have any active time intervals") } // Validate that all receivers used in the routing tree are defined. if err := checkReceiver(c.Route, names); err != nil { return err } tiNames := make(map[string]struct{}) // read mute time intervals until deprecated for _, mt := range c.MuteTimeIntervals { if _, ok := tiNames[mt.Name]; ok { return fmt.Errorf("mute time interval %q is not unique", mt.Name) } tiNames[mt.Name] = struct{}{} } for _, mt := range c.TimeIntervals { if _, ok := tiNames[mt.Name]; ok { return fmt.Errorf("time interval %q is not unique", mt.Name) } tiNames[mt.Name] = struct{}{} } return checkTimeInterval(c.Route, tiNames) } // checkReceiver returns an error if a node in the routing tree // references a receiver not in the given map. func checkReceiver(r *Route, receivers map[string]struct{}) error { for _, sr := range r.Routes { if err := checkReceiver(sr, receivers); err != nil { return err } } if r.Receiver == "" { return nil } if _, ok := receivers[r.Receiver]; !ok { return fmt.Errorf("undefined receiver %q used in route", r.Receiver) } return nil } func checkTimeInterval(r *Route, timeIntervals map[string]struct{}) error { for _, sr := range r.Routes { if err := checkTimeInterval(sr, timeIntervals); err != nil { return err } } for _, ti := range r.ActiveTimeIntervals { if _, ok := timeIntervals[ti]; !ok { return fmt.Errorf("undefined time interval %q used in route", ti) } } for _, tm := range r.MuteTimeIntervals { if _, ok := timeIntervals[tm]; !ok { return fmt.Errorf("undefined time interval %q used in route", tm) } } return nil } // DefaultGlobalConfig returns GlobalConfig with default values. func DefaultGlobalConfig() GlobalConfig { defaultHTTPConfig := commoncfg.DefaultHTTPClientConfig defaultSMTPTLSConfig := commoncfg.TLSConfig{} return GlobalConfig{ ResolveTimeout: model.Duration(5 * time.Minute), HTTPConfig: &defaultHTTPConfig, SMTPHello: "localhost", SMTPRequireTLS: true, SMTPTLSConfig: &defaultSMTPTLSConfig, PagerdutyURL: amcommoncfg.MustParseURL("https://events.pagerduty.com/v2/enqueue"), OpsGenieAPIURL: amcommoncfg.MustParseURL("https://api.opsgenie.com/"), WeChatAPIURL: amcommoncfg.MustParseURL("https://qyapi.weixin.qq.com/cgi-bin/"), VictorOpsAPIURL: amcommoncfg.MustParseURL("https://alert.victorops.com/integrations/generic/20131114/alert/"), TelegramAPIUrl: amcommoncfg.MustParseURL("https://api.telegram.org"), WebexAPIURL: amcommoncfg.MustParseURL("https://webexapis.com/v1/messages"), RocketchatAPIURL: amcommoncfg.MustParseURL("https://open.rocket.chat/"), SlackAppURL: amcommoncfg.MustParseURL("https://slack.com/api/chat.postMessage"), } } // HostPort represents a "host:port" network address. type HostPort struct { Host string Port string } // UnmarshalYAML implements the yaml.Unmarshaler interface for HostPort. func (hp *HostPort) UnmarshalYAML(unmarshal func(any) error) error { var ( s string err error ) if err = unmarshal(&s); err != nil { return err } if s == "" { return nil } hp.Host, hp.Port, err = net.SplitHostPort(s) if err != nil { return err } if hp.Port == "" { return fmt.Errorf("address %q: port cannot be empty", s) } return nil } // UnmarshalJSON implements the json.Unmarshaler interface for HostPort. func (hp *HostPort) UnmarshalJSON(data []byte) error { var ( s string err error ) if err = json.Unmarshal(data, &s); err != nil { return err } if s == "" { return nil } hp.Host, hp.Port, err = net.SplitHostPort(s) if err != nil { return err } if hp.Port == "" { return fmt.Errorf("address %q: port cannot be empty", s) } return nil } // MarshalYAML implements the yaml.Marshaler interface for HostPort. func (hp HostPort) MarshalYAML() (any, error) { return hp.String(), nil } // MarshalJSON implements the json.Marshaler interface for HostPort. func (hp HostPort) MarshalJSON() ([]byte, error) { return json.Marshal(hp.String()) } func (hp HostPort) String() string { if hp.Host == "" && hp.Port == "" { return "" } return net.JoinHostPort(hp.Host, hp.Port) } // GlobalConfig defines configuration parameters that are valid globally // unless overwritten. type GlobalConfig struct { // ResolveTimeout is the time after which an alert is declared resolved // if it has not been updated. ResolveTimeout model.Duration `yaml:"resolve_timeout" json:"resolve_timeout"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` JiraAPIURL *amcommoncfg.URL `yaml:"jira_api_url,omitempty" json:"jira_api_url,omitempty"` SMTPFrom string `yaml:"smtp_from,omitempty" json:"smtp_from,omitempty"` SMTPHello string `yaml:"smtp_hello,omitempty" json:"smtp_hello,omitempty"` SMTPSmarthost HostPort `yaml:"smtp_smarthost,omitempty" json:"smtp_smarthost,omitempty"` SMTPAuthUsername string `yaml:"smtp_auth_username,omitempty" json:"smtp_auth_username,omitempty"` SMTPAuthPassword commoncfg.Secret `yaml:"smtp_auth_password,omitempty" json:"smtp_auth_password,omitempty"` SMTPAuthPasswordFile string `yaml:"smtp_auth_password_file,omitempty" json:"smtp_auth_password_file,omitempty"` SMTPAuthSecret commoncfg.Secret `yaml:"smtp_auth_secret,omitempty" json:"smtp_auth_secret,omitempty"` SMTPAuthSecretFile string `yaml:"smtp_auth_secret_file,omitempty" json:"smtp_auth_secret_file,omitempty"` SMTPAuthIdentity string `yaml:"smtp_auth_identity,omitempty" json:"smtp_auth_identity,omitempty"` SMTPRequireTLS bool `yaml:"smtp_require_tls" json:"smtp_require_tls,omitempty"` SMTPTLSConfig *commoncfg.TLSConfig `yaml:"smtp_tls_config,omitempty" json:"smtp_tls_config,omitempty"` SMTPForceImplicitTLS *bool `yaml:"smtp_force_implicit_tls,omitempty" json:"smtp_force_implicit_tls,omitempty"` SlackAPIURL *amcommoncfg.SecretURL `yaml:"slack_api_url,omitempty" json:"slack_api_url,omitempty"` SlackAPIURLFile string `yaml:"slack_api_url_file,omitempty" json:"slack_api_url_file,omitempty"` SlackAppToken commoncfg.Secret `yaml:"slack_app_token,omitempty" json:"slack_app_token,omitempty"` SlackAppTokenFile string `yaml:"slack_app_token_file,omitempty" json:"slack_app_token_file,omitempty"` SlackAppURL *amcommoncfg.URL `yaml:"slack_app_url,omitempty" json:"slack_app_url,omitempty"` PagerdutyURL *amcommoncfg.URL `yaml:"pagerduty_url,omitempty" json:"pagerduty_url,omitempty"` OpsGenieAPIURL *amcommoncfg.URL `yaml:"opsgenie_api_url,omitempty" json:"opsgenie_api_url,omitempty"` OpsGenieAPIKey commoncfg.Secret `yaml:"opsgenie_api_key,omitempty" json:"opsgenie_api_key,omitempty"` OpsGenieAPIKeyFile string `yaml:"opsgenie_api_key_file,omitempty" json:"opsgenie_api_key_file,omitempty"` WeChatAPIURL *amcommoncfg.URL `yaml:"wechat_api_url,omitempty" json:"wechat_api_url,omitempty"` WeChatAPISecret commoncfg.Secret `yaml:"wechat_api_secret,omitempty" json:"wechat_api_secret,omitempty"` WeChatAPISecretFile string `yaml:"wechat_api_secret_file,omitempty" json:"wechat_api_secret_file,omitempty"` WeChatAPICorpID string `yaml:"wechat_api_corp_id,omitempty" json:"wechat_api_corp_id,omitempty"` VictorOpsAPIURL *amcommoncfg.URL `yaml:"victorops_api_url,omitempty" json:"victorops_api_url,omitempty"` VictorOpsAPIKey commoncfg.Secret `yaml:"victorops_api_key,omitempty" json:"victorops_api_key,omitempty"` VictorOpsAPIKeyFile string `yaml:"victorops_api_key_file,omitempty" json:"victorops_api_key_file,omitempty"` TelegramAPIUrl *amcommoncfg.URL `yaml:"telegram_api_url,omitempty" json:"telegram_api_url,omitempty"` TelegramBotToken commoncfg.Secret `yaml:"telegram_bot_token,omitempty" json:"telegram_bot_token,omitempty"` TelegramBotTokenFile string `yaml:"telegram_bot_token_file,omitempty" json:"telegram_bot_token_file,omitempty"` WebexAPIURL *amcommoncfg.URL `yaml:"webex_api_url,omitempty" json:"webex_api_url,omitempty"` RocketchatAPIURL *amcommoncfg.URL `yaml:"rocketchat_api_url,omitempty" json:"rocketchat_api_url,omitempty"` RocketchatToken *commoncfg.Secret `yaml:"rocketchat_token,omitempty" json:"rocketchat_token,omitempty"` RocketchatTokenFile string `yaml:"rocketchat_token_file,omitempty" json:"rocketchat_token_file,omitempty"` RocketchatTokenID *commoncfg.Secret `yaml:"rocketchat_token_id,omitempty" json:"rocketchat_token_id,omitempty"` RocketchatTokenIDFile string `yaml:"rocketchat_token_id_file,omitempty" json:"rocketchat_token_id_file,omitempty"` MattermostWebhookURL *amcommoncfg.SecretURL `yaml:"mattermost_webhook_url,omitempty" json:"mattermost_webhook_url,omitempty"` MattermostWebhookURLFile string `yaml:"mattermost_webhook_url_file,omitempty" json:"mattermost_webhook_url_file,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for GlobalConfig. func (c *GlobalConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultGlobalConfig() type plain GlobalConfig return unmarshal((*plain)(c)) } // A Route is a node that contains definitions of how to handle alerts. type Route struct { Receiver string `yaml:"receiver,omitempty" json:"receiver,omitempty"` GroupByStr []string `yaml:"group_by,omitempty" json:"group_by,omitempty"` GroupBy []model.LabelName `yaml:"-" json:"-"` GroupByAll bool `yaml:"-" json:"-"` // Deprecated. Remove before v1.0 release. Match map[string]string `yaml:"match,omitempty" json:"match,omitempty"` // Deprecated. Remove before v1.0 release. MatchRE amcommoncfg.MatchRegexps `yaml:"match_re,omitempty" json:"match_re,omitempty"` Matchers amcommoncfg.Matchers `yaml:"matchers,omitempty" json:"matchers,omitempty"` MuteTimeIntervals []string `yaml:"mute_time_intervals,omitempty" json:"mute_time_intervals,omitempty"` ActiveTimeIntervals []string `yaml:"active_time_intervals,omitempty" json:"active_time_intervals,omitempty"` Continue bool `yaml:"continue" json:"continue,omitempty"` Routes []*Route `yaml:"routes,omitempty" json:"routes,omitempty"` GroupWait *model.Duration `yaml:"group_wait,omitempty" json:"group_wait,omitempty"` GroupInterval *model.Duration `yaml:"group_interval,omitempty" json:"group_interval,omitempty"` RepeatInterval *model.Duration `yaml:"repeat_interval,omitempty" json:"repeat_interval,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for Route. func (r *Route) UnmarshalYAML(unmarshal func(any) error) error { type plain Route if err := unmarshal((*plain)(r)); err != nil { return err } for k := range r.Match { if !model.LabelNameRE.MatchString(k) { return fmt.Errorf("invalid label name %q", k) } } for _, l := range r.GroupByStr { if l == "..." { r.GroupByAll = true } else { labelName := model.LabelName(l) if !compat.IsValidLabelName(labelName) { return fmt.Errorf("invalid label name %q in group_by list", l) } r.GroupBy = append(r.GroupBy, labelName) } } if r.GroupByStr != nil && len(r.GroupByStr) == 0 { r.GroupBy = make([]model.LabelName, 0) } if len(r.GroupBy) > 0 && r.GroupByAll { return errors.New("cannot have wildcard group_by (`...`) and other labels at the same time") } groupBy := map[model.LabelName]struct{}{} for _, ln := range r.GroupBy { if _, ok := groupBy[ln]; ok { return fmt.Errorf("duplicated label %q in group_by", ln) } groupBy[ln] = struct{}{} } if r.GroupInterval != nil && time.Duration(*r.GroupInterval) == time.Duration(0) { return errors.New("group_interval cannot be zero") } if r.RepeatInterval != nil && time.Duration(*r.RepeatInterval) == time.Duration(0) { return errors.New("repeat_interval cannot be zero") } return nil } // Receiver configuration provides configuration on how to contact a receiver. type Receiver struct { // A unique identifier for this receiver. Name string `yaml:"name" json:"name"` DiscordConfigs []*DiscordConfig `yaml:"discord_configs,omitempty" json:"discord_configs,omitempty"` EmailConfigs []*EmailConfig `yaml:"email_configs,omitempty" json:"email_configs,omitempty"` IncidentioConfigs []*IncidentioConfig `yaml:"incidentio_configs,omitempty" json:"incidentio_configs,omitempty"` PagerdutyConfigs []*PagerdutyConfig `yaml:"pagerduty_configs,omitempty" json:"pagerduty_configs,omitempty"` SlackConfigs []*SlackConfig `yaml:"slack_configs,omitempty" json:"slack_configs,omitempty"` WebhookConfigs []*WebhookConfig `yaml:"webhook_configs,omitempty" json:"webhook_configs,omitempty"` OpsGenieConfigs []*OpsGenieConfig `yaml:"opsgenie_configs,omitempty" json:"opsgenie_configs,omitempty"` WechatConfigs []*WechatConfig `yaml:"wechat_configs,omitempty" json:"wechat_configs,omitempty"` PushoverConfigs []*PushoverConfig `yaml:"pushover_configs,omitempty" json:"pushover_configs,omitempty"` VictorOpsConfigs []*VictorOpsConfig `yaml:"victorops_configs,omitempty" json:"victorops_configs,omitempty"` SNSConfigs []*SNSConfig `yaml:"sns_configs,omitempty" json:"sns_configs,omitempty"` TelegramConfigs []*TelegramConfig `yaml:"telegram_configs,omitempty" json:"telegram_configs,omitempty"` WebexConfigs []*WebexConfig `yaml:"webex_configs,omitempty" json:"webex_configs,omitempty"` MSTeamsConfigs []*MSTeamsConfig `yaml:"msteams_configs,omitempty" json:"msteams_configs,omitempty"` MSTeamsV2Configs []*MSTeamsV2Config `yaml:"msteamsv2_configs,omitempty" json:"msteamsv2_configs,omitempty"` JiraConfigs []*JiraConfig `yaml:"jira_configs,omitempty" json:"jira_configs,omitempty"` RocketchatConfigs []*RocketchatConfig `yaml:"rocketchat_configs,omitempty" json:"rocketchat_configs,omitempty"` MattermostConfigs []*MattermostConfig `yaml:"mattermost_configs,omitempty" json:"mattermost_configs,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for Receiver. func (c *Receiver) UnmarshalYAML(unmarshal func(any) error) error { type plain Receiver if err := unmarshal((*plain)(c)); err != nil { return err } if c.Name == "" { return errors.New("missing name in receiver") } return nil } ================================================ FILE: config/config_fuzz_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import "testing" func FuzzLoad(f *testing.F) { f.Add(` global: resolve_timeout: 5m route: group_by: ['alertname'] group_wait: 10s group_interval: 10s repeat_interval: 1h receiver: 'web.hook' receivers: - name: 'web.hook' webhook_configs: - url: 'http://127.0.0.1:5001/' `) f.Fuzz(func(t *testing.T, configText string) { _, _ = Load(configText) }) } ================================================ FILE: config/config_test.go ================================================ // Copyright 2016 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "encoding/json" "fmt" "os" "reflect" "regexp" "strings" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" amcommoncfg "github.com/prometheus/alertmanager/config/common" ) func TestLoadEmptyString(t *testing.T) { var in string _, err := Load(in) expected := "no route provided in config" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestDefaultReceiverExists(t *testing.T) { in := ` route: group_wait: 30s ` _, err := Load(in) expected := "root route must specify a default receiver" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestReceiverNameIsUnique(t *testing.T) { in := ` route: receiver: team-X receivers: - name: 'team-X' - name: 'team-X' ` _, err := Load(in) expected := "notification config name \"team-X\" is not unique" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestReceiverExists(t *testing.T) { in := ` route: receiver: team-X receivers: - name: 'team-Y' ` _, err := Load(in) expected := "undefined receiver \"team-X\" used in route" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestReceiverExistsForDeepSubRoute(t *testing.T) { in := ` route: receiver: team-X routes: - match: foo: bar routes: - match: foo: bar receiver: nonexistent receivers: - name: 'team-X' ` _, err := Load(in) expected := "undefined receiver \"nonexistent\" used in route" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestReceiverHasName(t *testing.T) { in := ` route: receivers: - name: '' ` _, err := Load(in) expected := "missing name in receiver" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestMuteTimeExists(t *testing.T) { in := ` route: receiver: team-Y routes: - match: severity: critical mute_time_intervals: - business_hours receivers: - name: 'team-Y' ` _, err := Load(in) expected := "undefined time interval \"business_hours\" used in route" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestActiveTimeExists(t *testing.T) { in := ` route: receiver: team-Y routes: - match: severity: critical active_time_intervals: - business_hours receivers: - name: 'team-Y' ` _, err := Load(in) expected := "undefined time interval \"business_hours\" used in route" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestTimeIntervalHasName(t *testing.T) { in := ` time_intervals: - name: time_intervals: - times: - start_time: '09:00' end_time: '17:00' receivers: - name: 'team-X-mails' route: receiver: 'team-X-mails' routes: - match: severity: critical mute_time_intervals: - business_hours ` _, err := Load(in) expected := "missing name in time interval" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestMuteTimeNoDuplicates(t *testing.T) { in := ` mute_time_intervals: - name: duplicate time_intervals: - times: - start_time: '09:00' end_time: '17:00' - name: duplicate time_intervals: - times: - start_time: '10:00' end_time: '14:00' receivers: - name: 'team-X-mails' route: receiver: 'team-X-mails' routes: - match: severity: critical mute_time_intervals: - business_hours ` _, err := Load(in) expected := "mute time interval \"duplicate\" is not unique" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestGroupByHasNoDuplicatedLabels(t *testing.T) { in := ` route: group_by: ['alertname', 'cluster', 'service', 'cluster'] receivers: - name: 'team-X-mails' ` _, err := Load(in) expected := "duplicated label \"cluster\" in group_by" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestWildcardGroupByWithOtherGroupByLabels(t *testing.T) { in := ` route: group_by: ['alertname', 'cluster', '...'] receiver: team-X-mails receivers: - name: 'team-X-mails' ` _, err := Load(in) expected := "cannot have wildcard group_by (`...`) and other labels at the same time" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestGroupByInvalidLabel(t *testing.T) { in := ` route: group_by: ['-invalid-'] receiver: team-X-mails receivers: - name: 'team-X-mails' ` _, err := Load(in) expected := "invalid label name \"-invalid-\" in group_by list" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestRootRouteExists(t *testing.T) { in := ` receivers: - name: 'team-X-mails' ` _, err := Load(in) expected := "no routes provided" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestRootRouteNoMuteTimes(t *testing.T) { in := ` mute_time_intervals: - name: my_mute_time time_intervals: - times: - start_time: '09:00' end_time: '17:00' receivers: - name: 'team-X-mails' route: receiver: 'team-X-mails' mute_time_intervals: - my_mute_time ` _, err := Load(in) expected := "root route must not have any mute time intervals" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestRootRouteNoActiveTimes(t *testing.T) { in := ` time_intervals: - name: my_active_time time_intervals: - times: - start_time: '09:00' end_time: '17:00' receivers: - name: 'team-X-mails' route: receiver: 'team-X-mails' active_time_intervals: - my_active_time ` _, err := Load(in) expected := "root route must not have any active time intervals" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestRootRouteHasNoMatcher(t *testing.T) { testCases := []struct { name string in string }{ { name: "Test deprecated matchers on root route not allowed", in: ` route: receiver: 'team-X' match: severity: critical receivers: - name: 'team-X' `, }, { name: "Test matchers not allowed on root route", in: ` route: receiver: 'team-X' matchers: - severity=critical receivers: - name: 'team-X' `, }, } expected := "root route must not have any matchers" for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { _, err := Load(tc.in) if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } }) } } func TestContinueErrorInRouteRoot(t *testing.T) { in := ` route: receiver: team-X-mails continue: true receivers: - name: 'team-X-mails' ` _, err := Load(in) expected := "cannot have continue in root route" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestGroupIntervalIsGreaterThanZero(t *testing.T) { in := ` route: receiver: team-X-mails group_interval: 0s receivers: - name: 'team-X-mails' ` _, err := Load(in) expected := "group_interval cannot be zero" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestRepeatIntervalIsGreaterThanZero(t *testing.T) { in := ` route: receiver: team-X-mails repeat_interval: 0s receivers: - name: 'team-X-mails' ` _, err := Load(in) expected := "repeat_interval cannot be zero" if err == nil { t.Fatalf("no error returned, expected:\n%q", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%q\ngot:\n%q", expected, err.Error()) } } func TestHideConfigSecrets(t *testing.T) { c, err := LoadFile("testdata/conf.good.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.good.yml", err) } // String method must not reveal authentication credentials. s := c.String() if strings.Count(s, "") != 13 || strings.Contains(s, "mysecret") { t.Fatal("config's String method reveals authentication credentials.") } } func TestJSONMarshal(t *testing.T) { c, err := LoadFile("testdata/conf.good.yml") if err != nil { t.Errorf("Error parsing %s: %s", "testdata/conf.good.yml", err) } _, err = json.Marshal(c) if err != nil { t.Fatal("JSON Marshaling failed:", err) } } func TestJSONUnmarshal(t *testing.T) { c, err := LoadFile("testdata/conf.good.yml") if err != nil { t.Errorf("Error parsing %s: %s", "testdata/conf.good.yml", err) } _, err = json.Marshal(c) if err != nil { t.Fatal("JSON Marshaling failed:", err) } } func TestMarshalIdempotency(t *testing.T) { c, err := LoadFile("testdata/conf.good.yml") if err != nil { t.Errorf("Error parsing %s: %s", "testdata/conf.good.yml", err) } marshaled, err := yaml.Marshal(c) if err != nil { t.Fatal("YAML Marshaling failed:", err) } c = new(Config) if err := yaml.Unmarshal(marshaled, c); err != nil { t.Fatal("YAML Unmarshaling failed:", err) } } func TestGroupByAllNotMarshaled(t *testing.T) { in := ` route: receiver: team-X-mails group_by: [...] receivers: - name: 'team-X-mails' ` c, err := Load(in) if err != nil { t.Fatal("load failed:", err) } dat, err := yaml.Marshal(c) if err != nil { t.Fatal("YAML Marshaling failed:", err) } if strings.Contains(string(dat), "groupbyall") { t.Fatal("groupbyall found in config file") } } func TestEmptyFieldsAndRegex(t *testing.T) { boolFoo := true regexpFoo := amcommoncfg.Regexp{ Regexp: regexp.MustCompile("^(?:^(foo1|foo2|baz)$)$"), Original: "^(foo1|foo2|baz)$", } expectedConf := Config{ Global: &GlobalConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{ FollowRedirects: true, EnableHTTP2: true, }, ResolveTimeout: model.Duration(5 * time.Minute), SMTPSmarthost: HostPort{Host: "localhost", Port: "25"}, SMTPFrom: "alertmanager@example.org", SMTPTLSConfig: &commoncfg.TLSConfig{ InsecureSkipVerify: false, }, SlackAPIURL: (*amcommoncfg.SecretURL)(amcommoncfg.MustParseURL("http://slack.example.com/")), SlackAppURL: amcommoncfg.MustParseURL("https://slack.com/api/chat.postMessage"), SMTPRequireTLS: true, PagerdutyURL: amcommoncfg.MustParseURL("https://events.pagerduty.com/v2/enqueue"), OpsGenieAPIURL: amcommoncfg.MustParseURL("https://api.opsgenie.com/"), WeChatAPIURL: amcommoncfg.MustParseURL("https://qyapi.weixin.qq.com/cgi-bin/"), VictorOpsAPIURL: amcommoncfg.MustParseURL("https://alert.victorops.com/integrations/generic/20131114/alert/"), TelegramAPIUrl: amcommoncfg.MustParseURL("https://api.telegram.org"), WebexAPIURL: amcommoncfg.MustParseURL("https://webexapis.com/v1/messages"), RocketchatAPIURL: amcommoncfg.MustParseURL("https://open.rocket.chat/"), }, Templates: []string{ "/etc/alertmanager/template/*.tmpl", }, Route: &Route{ Receiver: "team-X-mails", GroupBy: []model.LabelName{ "alertname", "cluster", "service", }, GroupByStr: []string{ "alertname", "cluster", "service", }, GroupByAll: false, Routes: []*Route{ { Receiver: "team-X-mails", MatchRE: map[string]amcommoncfg.Regexp{ "service": regexpFoo, }, }, }, }, Receivers: []Receiver{ { Name: "team-X-mails", EmailConfigs: []*EmailConfig{ { To: "team-X+alerts@example.org", From: "alertmanager@example.org", Smarthost: HostPort{Host: "localhost", Port: "25"}, HTML: "{{ template \"email.default.html\" . }}", RequireTLS: &boolFoo, TLSConfig: &commoncfg.TLSConfig{ InsecureSkipVerify: false, }, }, }, }, }, } // Load a non-empty configuration to ensure that all fields are overwritten. // See https://github.com/prometheus/alertmanager/issues/1649. _, err := LoadFile("testdata/conf.good.yml") if err != nil { t.Errorf("Error parsing %s: %s", "testdata/conf.good.yml", err) } config, err := LoadFile("testdata/conf.empty-fields.yml") if err != nil { t.Errorf("Error parsing %s: %s", "testdata/conf.empty-fields.yml", err) } configGot, err := yaml.Marshal(config) if err != nil { t.Fatal("YAML Marshaling failed:", err) } configExp, err := yaml.Marshal(expectedConf) if err != nil { t.Fatalf("%s", err) } if !reflect.DeepEqual(configGot, configExp) { t.Fatalf("%s: unexpected config result: \n\n%s\n expected\n\n%s", "testdata/conf.empty-fields.yml", configGot, configExp) } } func TestEmptyConfigOfIntegration(t *testing.T) { baseConfigTmpl := ` global: route: receiver: 'test-receiver' receivers: - name: 'test-receiver' %s: - ` tests := []struct { integration string // The key name in YAML (e.g., webhook_configs) expectedErr string // The unique error message expected for this integration }{ { integration: "discord_configs", expectedErr: "missing discord config", }, { integration: "email_configs", expectedErr: "missing email config", }, { integration: "incidentio_configs", expectedErr: "missing incidentio config", }, { integration: "pagerduty_configs", expectedErr: "missing pagerduty config", }, { integration: "webhook_configs", expectedErr: "missing webhook config", }, { integration: "pushover_configs", expectedErr: "missing pushover config", }, { integration: "victorops_configs", expectedErr: "missing victorops config", }, { integration: "sns_configs", expectedErr: "missing sns config", }, { integration: "telegram_configs", expectedErr: "missing telegram config", }, { integration: "webex_configs", expectedErr: "missing webex config", }, { integration: "msteams_configs", expectedErr: "missing msteams config", }, { integration: "msteamsv2_configs", expectedErr: "missing msteamsv2 config", }, { integration: "jira_configs", expectedErr: "missing jira config", }, { integration: "mattermost_configs", expectedErr: "missing mattermost config", }, { integration: "slack_configs", expectedErr: "no Slack API URL nor App token set either inline or in a file", }, { integration: "opsgenie_configs", expectedErr: "no global OpsGenie API Key set either inline or in a file", }, { integration: "wechat_configs", expectedErr: "no global Wechat Api Secret set either inline or in a file", }, { integration: "rocketchat_configs", expectedErr: "no global Rocketchat TokenID set either inline or in a file", }, } for _, tc := range tests { t.Run(tc.integration, func(t *testing.T) { in := fmt.Sprintf(baseConfigTmpl, tc.integration) _, err := Load(in) require.Error(t, err, "Expected empty configuration to be an error for %s", tc.integration) require.ErrorContains(t, err, tc.expectedErr) }) } } func TestGlobalAndLocalHTTPConfig(t *testing.T) { config, err := LoadFile("testdata/conf.http-config.good.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf-http-config.good.yml", err) } if config.Global.HTTPConfig.FollowRedirects { t.Fatalf("global HTTP config should not follow redirects") } if !config.Receivers[0].SlackConfigs[0].HTTPConfig.FollowRedirects { t.Fatalf("global HTTP config should follow redirects") } } func TestSMTPHello(t *testing.T) { c, err := LoadFile("testdata/conf.good.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.good.yml", err) } const refValue = "host.example.org" hostName := c.Global.SMTPHello if hostName != refValue { t.Errorf("Invalid SMTP Hello hostname: %s\nExpected: %s", hostName, refValue) } } func TestSMTPBothPasswordAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.smtp-both-password-and-file.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.smtp-both-password-and-file.yml", err) } if err.Error() != "at most one of smtp_auth_password & smtp_auth_password_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of auth_password & auth_password_file must be configured", err.Error()) } } func TestSMTPNoUsernameOrPassword(t *testing.T) { _, err := LoadFile("testdata/conf.smtp-no-username-or-password.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.smtp-no-username-or-password.yml", err) } } func TestGlobalAndLocalSMTPPassword(t *testing.T) { config, err := LoadFile("testdata/conf.smtp-password-global-and-local.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.smtp-password-global-and-local.yml", err) } require.Equal(t, "/tmp/globaluserpassword", config.Receivers[0].EmailConfigs[0].AuthPasswordFile, "first email should use password file /tmp/globaluserpassword") require.Emptyf(t, config.Receivers[0].EmailConfigs[0].AuthPassword, "password field should be empty when file provided") require.Equal(t, "/tmp/localuser1password", config.Receivers[0].EmailConfigs[1].AuthPasswordFile, "second email should use password file /tmp/localuser1password") require.Emptyf(t, config.Receivers[0].EmailConfigs[1].AuthPassword, "password field should be empty when file provided") require.Equal(t, commoncfg.Secret("mysecret"), config.Receivers[0].EmailConfigs[2].AuthPassword, "third email should use password mysecret") require.Emptyf(t, config.Receivers[0].EmailConfigs[2].AuthPasswordFile, "file field should be empty when password provided") require.Equal(t, commoncfg.Secret("myprecious"), config.Receivers[0].EmailConfigs[3].AuthSecret, "fourth email should use secret myprecious") require.Equal(t, "/tmp/localuser4secret", config.Receivers[0].EmailConfigs[4].AuthSecretFile, "fifth email should use secret file /tmp/localuser4secret") } func TestGroupByAll(t *testing.T) { c, err := LoadFile("testdata/conf.group-by-all.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.group-by-all.yml", err) } if !c.Route.GroupByAll { t.Errorf("Invalid group by all param: expected to by true") } } func TestVictorOpsDefaultAPIKey(t *testing.T) { conf, err := LoadFile("testdata/conf.victorops-default-apikey.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.victorops-default-apikey.yml", err) } defaultKey := conf.Global.VictorOpsAPIKey overrideKey := commoncfg.Secret("qwe456") if defaultKey != conf.Receivers[0].VictorOpsConfigs[0].APIKey { t.Fatalf("Invalid victorops key: %s\nExpected: %s", conf.Receivers[0].VictorOpsConfigs[0].APIKey, defaultKey) } if overrideKey != conf.Receivers[1].VictorOpsConfigs[0].APIKey { t.Errorf("Invalid victorops key: %s\nExpected: %s", conf.Receivers[1].VictorOpsConfigs[0].APIKey, string(overrideKey)) } } func TestVictorOpsDefaultAPIKeyFile(t *testing.T) { conf, err := LoadFile("testdata/conf.victorops-default-apikey-file.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.victorops-default-apikey-file.yml", err) } defaultKey := conf.Global.VictorOpsAPIKeyFile overrideKey := "/override_file" if defaultKey != conf.Receivers[0].VictorOpsConfigs[0].APIKeyFile { t.Fatalf("Invalid VictorOps key_file: %s\nExpected: %s", conf.Receivers[0].VictorOpsConfigs[0].APIKeyFile, defaultKey) } if overrideKey != conf.Receivers[1].VictorOpsConfigs[0].APIKeyFile { t.Errorf("Invalid VictorOps key_file: %s\nExpected: %s", conf.Receivers[1].VictorOpsConfigs[0].APIKeyFile, overrideKey) } } func TestVictorOpsBothAPIKeyAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.victorops-both-file-and-apikey.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.victorops-both-file-and-apikey.yml", err) } if err.Error() != "at most one of victorops_api_key & victorops_api_key_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of victorops_api_key & victorops_api_key_file must be configured", err.Error()) } } func TestVictorOpsNoAPIKey(t *testing.T) { _, err := LoadFile("testdata/conf.victorops-no-apikey.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.victorops-no-apikey.yml", err) } if err.Error() != "no global VictorOps API Key set" { t.Errorf("Expected: %s\nGot: %s", "no global VictorOps API Key set", err.Error()) } } func TestTelegramDefaultBotToken(t *testing.T) { conf, err := LoadFile("testdata/conf.telegram-default-bot-token.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.telegram-default-bot-token.yml", err) } defaultBotToken := conf.Global.TelegramBotToken overrideBotToken := commoncfg.Secret("qwe456") if defaultBotToken != conf.Receivers[0].TelegramConfigs[0].BotToken { t.Fatalf("Invalid telegram bot token: %s\nExpected: %s", conf.Receivers[0].TelegramConfigs[0].BotToken, defaultBotToken) } if overrideBotToken != conf.Receivers[1].TelegramConfigs[0].BotToken { t.Errorf("Invalid telegram bot token: %s\nExpected: %s", conf.Receivers[1].TelegramConfigs[0].BotToken, string(overrideBotToken)) } } func TestTelegramDefaultBotTokenFile(t *testing.T) { conf, err := LoadFile("testdata/conf.telegram-default-bot-token-file.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.telegram-default-bot-token-file.yml", err) } defaultBotToken := conf.Global.TelegramBotTokenFile overrideBotToken := "/override_file" if defaultBotToken != conf.Receivers[0].TelegramConfigs[0].BotTokenFile { t.Fatalf("Invalid telegram bot token file: %s\nExpected: %s", conf.Receivers[0].TelegramConfigs[0].BotTokenFile, defaultBotToken) } if overrideBotToken != conf.Receivers[1].TelegramConfigs[0].BotTokenFile { t.Errorf("Invalid telegram bot token file: %s\nExpected: %s", conf.Receivers[1].TelegramConfigs[0].BotTokenFile, overrideBotToken) } } func TestTelegramBothBotTokenAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.telegram-both-bot-token-and-file.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.telegram-both-bot-token-and-file.yml", err) } if err.Error() != "at most one of telegram_bot_token & telegram_bot_token_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of telegram_bot_token & telegram_bot_token_file must be configured", err.Error()) } } func TestTelegramValidReceiverBothBotTokenAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.telegram-valid-receiver-both-bot-token-and-file.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.telegram-valid-receiver-both-bot-token-and-file.yml", err) } if err.Error() != "at most one of telegram_bot_token & telegram_bot_token_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of telegram_bot_token & telegram_bot_token_file must be configured", err.Error()) } } func TestTelegramNoBotToken(t *testing.T) { _, err := LoadFile("testdata/conf.telegram-no-bot-token.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.telegram-no-bot-token.yml", err) } if err.Error() != "missing bot_token or bot_token_file on telegram_config" { t.Errorf("Expected: %s\nGot: %s", "missing bot_token or bot_token_file on telegram_config", err.Error()) } } func TestOpsGenieDefaultAPIKey(t *testing.T) { conf, err := LoadFile("testdata/conf.opsgenie-default-apikey.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.opsgenie-default-apikey.yml", err) } defaultKey := conf.Global.OpsGenieAPIKey if defaultKey != conf.Receivers[0].OpsGenieConfigs[0].APIKey { t.Fatalf("Invalid OpsGenie key: %s\nExpected: %s", conf.Receivers[0].OpsGenieConfigs[0].APIKey, defaultKey) } if defaultKey == conf.Receivers[1].OpsGenieConfigs[0].APIKey { t.Errorf("Invalid OpsGenie key: %s\nExpected: %s", conf.Receivers[1].OpsGenieConfigs[0].APIKey, "qwe456") } } func TestOpsGenieDefaultAPIKeyFile(t *testing.T) { conf, err := LoadFile("testdata/conf.opsgenie-default-apikey-file.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.opsgenie-default-apikey-file.yml", err) } defaultKey := conf.Global.OpsGenieAPIKeyFile if defaultKey != conf.Receivers[0].OpsGenieConfigs[0].APIKeyFile { t.Fatalf("Invalid OpsGenie key_file: %s\nExpected: %s", conf.Receivers[0].OpsGenieConfigs[0].APIKeyFile, defaultKey) } if defaultKey == conf.Receivers[1].OpsGenieConfigs[0].APIKeyFile { t.Errorf("Invalid OpsGenie key_file: %s\nExpected: %s", conf.Receivers[1].OpsGenieConfigs[0].APIKeyFile, "/override_file") } } func TestOpsGenieBothAPIKeyAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.opsgenie-both-file-and-apikey.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.opsgenie-both-file-and-apikey.yml", err) } if err.Error() != "at most one of opsgenie_api_key & opsgenie_api_key_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of opsgenie_api_key & opsgenie_api_key_file must be configured", err.Error()) } } func TestOpsGenieNoAPIKey(t *testing.T) { _, err := LoadFile("testdata/conf.opsgenie-no-apikey.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.opsgenie-no-apikey.yml", err) } if err.Error() != "no global OpsGenie API Key set either inline or in a file" { t.Errorf("Expected: %s\nGot: %s", "no global OpsGenie API Key set either inline or in a file", err.Error()) } } func TestOpsGenieDeprecatedTeamSpecified(t *testing.T) { _, err := LoadFile("testdata/conf.opsgenie-default-apikey-old-team.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.opsgenie-default-apikey-old-team.yml", err) } const expectedErr = `yaml: unmarshal errors: line 16: field teams not found in type config.plain` if err.Error() != expectedErr { t.Errorf("Expected: %s\nGot: %s", expectedErr, err.Error()) } } func TestSlackBothAPIURLAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.slack-both-file-and-url.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-both-file-and-url.yml", err) } if err.Error() != "at most one of slack_api_url & slack_api_url_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of slack_api_url & slack_api_url_file must be configured", err.Error()) } } func TestSlackBothAppTokenAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.slack-both-file-and-token.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-both-file-and-token.yml", err) } if err.Error() != "at most one of slack_app_token & slack_app_token_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of slack_app_token & slack_app_token_file must be configured", err.Error()) } } func TestSlackBothAppTokenAndAPIURL(t *testing.T) { _, err := LoadFile("testdata/conf.slack-both-url-and-token.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-both-url-and-token.yml", err) } if err.Error() != "at most one of slack_app_token/slack_app_token_file & slack_api_url/slack_api_url_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of slack_app_token/slack_app_token_file & slack_api_url/slack_api_url_file must be configured", err.Error()) } } func TestSlackUpdateMessageWebhookURL(t *testing.T) { _, err := LoadFile("testdata/conf.slack-update-message-and-webhook.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-update-message-and-webhook", err) } if err.Error() != "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage" { t.Errorf("Expected: %s\nGot: %s", "update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage", err.Error()) } } func TestSlackGlobalAppToken(t *testing.T) { conf, err := LoadFile("testdata/conf.slack-default-app-token.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-default-app-token.yml", err) } // no override defaultToken := conf.Global.SlackAppToken firstAuth := commoncfg.Authorization{ Type: "Bearer", Credentials: commoncfg.Secret(defaultToken), } firstConfig := conf.Receivers[0].SlackConfigs[0] if firstConfig.AppToken != defaultToken { t.Fatalf("Invalid Slack App token: %s\nExpected: %s", firstConfig.AppToken, defaultToken) } if firstConfig.APIURL.String() != conf.Global.SlackAppURL.String() { t.Fatalf("Expected API URL: %s\nGot: %s", conf.Global.SlackAppURL.String(), firstConfig.APIURL.String()) } if firstConfig.HTTPConfig == nil || firstConfig.HTTPConfig.Authorization == nil { t.Fatalf("Error configuring Slack App authorization: %s", firstConfig.HTTPConfig) } if firstConfig.HTTPConfig.Authorization.Type != firstAuth.Type { t.Fatalf("Error configuring Slack App authorization type: %s\nExpected: %s", firstConfig.HTTPConfig.Authorization.Type, firstAuth.Type) } if firstConfig.HTTPConfig.Authorization.Credentials != firstAuth.Credentials { t.Fatalf("Error configuring Slack App authorization credentials: %s\nExpected: %s", firstConfig.HTTPConfig.Authorization.Credentials, firstAuth.Credentials) } // inline override inlineToken := "xoxb-1234-xxxxxx" secondAuth := commoncfg.Authorization{ Type: "Bearer", Credentials: commoncfg.Secret(inlineToken), } secondConfig := conf.Receivers[0].SlackConfigs[1] if secondConfig.AppToken != commoncfg.Secret(inlineToken) { t.Fatalf("Invalid Slack App token: %s\nExpected: %s", secondConfig.AppToken, inlineToken) } if secondConfig.HTTPConfig == nil || secondConfig.HTTPConfig.Authorization == nil { t.Fatalf("Error configuring Slack App authorization: %s", secondConfig.HTTPConfig) } if secondConfig.HTTPConfig.Authorization.Type != secondAuth.Type { t.Fatalf("Error configuring Slack App authorization type: %s\nExpected: %s", secondConfig.HTTPConfig.Authorization.Type, secondAuth.Type) } if secondConfig.HTTPConfig.Authorization.Credentials != secondAuth.Credentials { t.Fatalf("Error configuring Slack App authorization credentials: %s\nExpected: %s", secondConfig.HTTPConfig.Authorization.Credentials, secondAuth.Credentials) } // custom app url thirdConfig := conf.Receivers[0].SlackConfigs[2] if thirdConfig.AppURL.String() != "http://api.fakeslack.example/" { t.Fatalf("Invalid Slack URL: %s\nExpected: %s", thirdConfig.APIURL.String(), "http://mysecret.example.com/") } // workaround override workaroundToken := "xoxb-my-bot-token" fourthAuth := commoncfg.Authorization{ Type: "Bearer", Credentials: commoncfg.Secret(workaroundToken), } fourthConfig := conf.Receivers[0].SlackConfigs[3] if fourthConfig.AppToken != "" { t.Fatalf("Invalid Slack App token: %q\nExpected: %q", fourthConfig.AppToken, "") } if fourthConfig.HTTPConfig == nil || fourthConfig.HTTPConfig.Authorization == nil { t.Fatalf("Error configuring Slack App authorization: %s", fourthConfig.HTTPConfig) } if fourthConfig.HTTPConfig.Authorization.Type != fourthAuth.Type { t.Fatalf("Error configuring Slack App authorization type: %s\nExpected: %s", fourthConfig.HTTPConfig.Authorization.Type, fourthAuth.Type) } if fourthConfig.HTTPConfig.Authorization.Credentials != fourthAuth.Credentials { t.Fatalf("Error configuring Slack App authorization credentials: %s\nExpected: %s", fourthConfig.HTTPConfig.Authorization.Credentials, fourthAuth.Credentials) } // override the global file with an inline webhook URL apiURL := "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" fifthConfig := conf.Receivers[0].SlackConfigs[4] if fifthConfig.APIURL.String() != apiURL || fifthConfig.APIURLFile != "" { t.Fatalf("Invalid Slack URL: %s\nExpected: %s", fifthConfig.APIURL.String(), apiURL) } } func TestSlackNoAPIURL(t *testing.T) { _, err := LoadFile("testdata/conf.slack-no-api-url-or-token.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-no-api-url-or-token.yml", err) } if err.Error() != "no Slack API URL nor App token set either inline or in a file" { t.Errorf("Expected: %s\nGot: %s", "no Slack API URL nor App token set either inline or in a file", err.Error()) } } func TestSlackGlobalAPIURLFile(t *testing.T) { conf, err := LoadFile("testdata/conf.slack-default-api-url-file.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-default-api-url-file.yml", err) } // no override firstConfig := conf.Receivers[0].SlackConfigs[0] if firstConfig.APIURLFile != "/global_file" || firstConfig.APIURL != nil { t.Fatalf("Invalid Slack URL file: %s\nExpected: %s", firstConfig.APIURLFile, "/global_file") } // override the file secondConfig := conf.Receivers[0].SlackConfigs[1] if secondConfig.APIURLFile != "/override_file" || secondConfig.APIURL != nil { t.Fatalf("Invalid Slack URL file: %s\nExpected: %s", secondConfig.APIURLFile, "/override_file") } // override the global file with an inline URL thirdConfig := conf.Receivers[0].SlackConfigs[2] if thirdConfig.APIURL.String() != "http://mysecret.example.com/" || thirdConfig.APIURLFile != "" { t.Fatalf("Invalid Slack URL: %s\nExpected: %s", thirdConfig.APIURL.String(), "http://mysecret.example.com/") } } func TestValidSNSConfig(t *testing.T) { _, err := LoadFile("testdata/conf.sns-topic-arn.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.sns-topic-arn.yml\"", err) } } func TestInvalidSNSConfig(t *testing.T) { _, err := LoadFile("testdata/conf.sns-invalid.yml") if err == nil { t.Fatalf("expected error with missing fields on SNS config") } const expectedErr = `must provide either a Target ARN, Topic ARN, or Phone Number for SNS config` if err.Error() != expectedErr { t.Errorf("Expected: %s\nGot: %s", expectedErr, err.Error()) } } func TestRocketchatDefaultToken(t *testing.T) { conf, err := LoadFile("testdata/conf.rocketchat-default-token.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.rocketchat-default-token.yml", err) } defaultToken := conf.Global.RocketchatToken overrideToken := commoncfg.Secret("token456") if defaultToken != conf.Receivers[0].RocketchatConfigs[0].Token { t.Fatalf("Invalid rocketchat key: %s\nExpected: %s", string(*conf.Receivers[0].RocketchatConfigs[0].Token), string(*defaultToken)) } if overrideToken != *conf.Receivers[1].RocketchatConfigs[0].Token { t.Errorf("Invalid rocketchat key: %s\nExpected: %s", string(*conf.Receivers[1].RocketchatConfigs[0].Token), string(overrideToken)) } } func TestRocketchatDefaultTokenID(t *testing.T) { conf, err := LoadFile("testdata/conf.rocketchat-default-token.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.rocketchat-default-token.yml", err) } defaultTokenID := conf.Global.RocketchatTokenID overrideTokenID := commoncfg.Secret("id456") if defaultTokenID != conf.Receivers[0].RocketchatConfigs[0].TokenID { t.Fatalf("Invalid rocketchat key: %s\nExpected: %s", string(*conf.Receivers[0].RocketchatConfigs[0].TokenID), string(*defaultTokenID)) } if overrideTokenID != *conf.Receivers[1].RocketchatConfigs[0].TokenID { t.Errorf("Invalid rocketchat key: %s\nExpected: %s", string(*conf.Receivers[1].RocketchatConfigs[0].TokenID), string(overrideTokenID)) } } func TestRocketchatDefaultTokenFile(t *testing.T) { conf, err := LoadFile("testdata/conf.rocketchat-default-token-file.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.rocketchat-default-token-file.yml", err) } defaultTokenFile := conf.Global.RocketchatTokenFile overrideTokenFile := "/override_file" if defaultTokenFile != conf.Receivers[0].RocketchatConfigs[0].TokenFile { t.Fatalf("Invalid Rocketchat key_file: %s\nExpected: %s", conf.Receivers[0].RocketchatConfigs[0].TokenFile, defaultTokenFile) } if overrideTokenFile != conf.Receivers[1].RocketchatConfigs[0].TokenFile { t.Errorf("Invalid Rocketchat key_file: %s\nExpected: %s", conf.Receivers[1].RocketchatConfigs[0].TokenFile, overrideTokenFile) } } func TestRocketchatDefaultIDTokenFile(t *testing.T) { conf, err := LoadFile("testdata/conf.rocketchat-default-token-file.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.rocketchat-default-token-file.yml", err) } defaultTokenIDFile := conf.Global.RocketchatTokenIDFile overrideTokenIDFile := "/override_file" if defaultTokenIDFile != conf.Receivers[0].RocketchatConfigs[0].TokenIDFile { t.Fatalf("Invalid Rocketchat key_file: %s\nExpected: %s", conf.Receivers[0].RocketchatConfigs[0].TokenIDFile, defaultTokenIDFile) } if overrideTokenIDFile != conf.Receivers[1].RocketchatConfigs[0].TokenIDFile { t.Errorf("Invalid Rocketchat key_file: %s\nExpected: %s", conf.Receivers[1].RocketchatConfigs[0].TokenIDFile, overrideTokenIDFile) } } func TestRocketchatBothTokenAndTokenFile(t *testing.T) { _, err := LoadFile("testdata/conf.rocketchat-both-token-and-tokenfile.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.rocketchat-both-token-and-tokenfile.yml", err) } if err.Error() != "at most one of rocketchat_token & rocketchat_token_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of rocketchat_token & rocketchat_token_file must be configured", err.Error()) } } func TestRocketchatBothTokenIDAndTokenIDFile(t *testing.T) { _, err := LoadFile("testdata/conf.rocketchat-both-tokenid-and-tokenidfile.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.rocketchat-both-tokenid-and-tokenidfile.yml", err) } if err.Error() != "at most one of rocketchat_token_id & rocketchat_token_id_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of rocketchat_token_id & rocketchat_token_id_file must be configured", err.Error()) } } func TestRocketchatNoToken(t *testing.T) { _, err := LoadFile("testdata/conf.rocketchat-no-token.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.rocketchat-no-token.yml", err) } if err.Error() != "no global Rocketchat Token set either inline or in a file" { t.Errorf("Expected: %s\nGot: %s", "no global Rocketchat Token set either inline or in a file", err.Error()) } } func TestUnmarshalHostPort(t *testing.T) { for _, tc := range []struct { in string exp HostPort jsonOut string yamlOut string err bool }{ { in: `""`, exp: HostPort{}, yamlOut: `"" `, jsonOut: `""`, }, { in: `"localhost:25"`, exp: HostPort{Host: "localhost", Port: "25"}, yamlOut: `localhost:25 `, jsonOut: `"localhost:25"`, }, { in: `":25"`, exp: HostPort{Host: "", Port: "25"}, yamlOut: `:25 `, jsonOut: `":25"`, }, { in: `"localhost"`, err: true, }, { in: `"localhost:"`, err: true, }, { in: `"[fd12:3456:789a::1]:25"`, exp: HostPort{Host: "fd12:3456:789a::1", Port: "25"}, yamlOut: `'[fd12:3456:789a::1]:25' `, jsonOut: `"[fd12:3456:789a::1]:25"`, }, } { t.Run(tc.in, func(t *testing.T) { hp := HostPort{} err := yaml.Unmarshal([]byte(tc.in), &hp) if tc.err { require.Error(t, err) return } require.NoError(t, err) require.Equal(t, tc.exp, hp) b, err := yaml.Marshal(&hp) require.NoError(t, err) require.Equal(t, tc.yamlOut, string(b)) b, err = json.Marshal(&hp) require.NoError(t, err) require.Equal(t, tc.jsonOut, string(b)) }) } } func TestNilRegexp(t *testing.T) { for _, tc := range []struct { file string errMsg string }{ { file: "testdata/conf.nil-match_re-route.yml", errMsg: "invalid_label", }, { file: "testdata/conf.nil-source_match_re-inhibition.yml", errMsg: "invalid_source_label", }, { file: "testdata/conf.nil-target_match_re-inhibition.yml", errMsg: "invalid_target_label", }, } { t.Run(tc.file, func(t *testing.T) { _, err := os.Stat(tc.file) require.NoError(t, err) _, err = LoadFile(tc.file) require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) }) } } func TestSecretTemplURL(t *testing.T) { tests := []struct { name string input string expectError bool errorMsg string }{ { name: "valid http URL", input: `"http://example.com/webhook"`, expectError: false, }, { name: "invalid URL missing scheme", input: `"example.com/webhook"`, expectError: true, errorMsg: "unsupported scheme", }, { name: "invalid URL unsupported scheme", input: `"ftp://example.com/webhook"`, expectError: true, errorMsg: "unsupported scheme", }, { name: "templated URL is not validated", input: `"http://example.com/{{ .GroupLabels.alertname }}"`, expectError: false, }, { name: "invalid URL with template is not validated", input: `"not-a-url-{{ .GroupLabels.alertname }}"`, expectError: false, }, { name: "invalid template syntax", input: `"http://example.com/{{ .Invalid"`, expectError: true, errorMsg: "invalid template syntax", }, { name: "empty string", input: `""`, expectError: false, }, { name: "secret token", input: `""`, expectError: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var u SecretTemplateURL err := yaml.Unmarshal([]byte(tc.input), &u) if tc.expectError { require.Error(t, err) if tc.errorMsg != "" { require.Contains(t, err.Error(), tc.errorMsg) } } else { require.NoError(t, err) } }) } } func TestSecretTemplURLMarshaling(t *testing.T) { t.Run("marshals to secret token by default", func(t *testing.T) { u := SecretTemplateURL("http://example.com/secret") yamlOut, err := yaml.Marshal(&u) require.NoError(t, err) require.YAMLEq(t, "\n", string(yamlOut)) jsonOut, err := json.Marshal(&u) require.NoError(t, err) require.JSONEq(t, `""`, string(jsonOut)) }) t.Run("marshals actual value when MarshalSecretValue is true", func(t *testing.T) { commoncfg.MarshalSecretValue = true defer func() { commoncfg.MarshalSecretValue = false }() u := SecretTemplateURL("http://example.com/secret") yamlOut, err := yaml.Marshal(&u) require.NoError(t, err) require.YAMLEq(t, "http://example.com/secret\n", string(yamlOut)) jsonOut, err := json.Marshal(&u) require.NoError(t, err) require.JSONEq(t, `"http://example.com/secret"`, string(jsonOut)) }) t.Run("empty URL marshals to empty", func(t *testing.T) { u := SecretTemplateURL("") yamlOut, err := yaml.Marshal(&u) require.NoError(t, err) require.YAMLEq(t, "null\n", string(yamlOut)) jsonOut, err := json.Marshal(&u) require.NoError(t, err) require.JSONEq(t, `""`, string(jsonOut)) }) } func TestGroupByEmptyOverride(t *testing.T) { in := ` route: receiver: 'default' group_by: ['alertname', 'cluster'] routes: - group_by: [] receivers: - name: 'default' ` cfg, err := Load(in) require.NoError(t, err) require.Len(t, cfg.Route.GroupBy, 2) require.NotNil(t, cfg.Route.Routes[0].GroupBy) require.Empty(t, cfg.Route.Routes[0].GroupBy) } func TestWechatNoAPIURL(t *testing.T) { _, err := LoadFile("testdata/conf.wechat-no-api-secret.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.wechat-no-api-url.yml", err) } if err.Error() != "no global Wechat Api Secret set either inline or in a file" { t.Errorf("Expected: %s\nGot: %s", "no global Wechat Api Secret set either inline or in a file", err.Error()) } } func TestWechatBothAPIURLAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.wechat-both-file-and-secret.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.wechat-both-file-and-secret.yml", err) } if err.Error() != "at most one of wechat_api_secret & wechat_api_secret_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of wechat_api_secret & wechat_api_secret_file must be configured", err.Error()) } } func TestWechatGlobalAPISecretFile(t *testing.T) { conf, err := LoadFile("testdata/conf.wechat-default-api-secret-file.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.wechat-default-api-secret-file.yml", err) } // no override firstConfig := conf.Receivers[0].WechatConfigs[0] if firstConfig.APISecretFile != "/global_file" || string(firstConfig.APISecret) != "" { t.Fatalf("Invalid Wechat API Secret file: %s\nExpected: %s", firstConfig.APISecretFile, "/global_file") } // override the file secondConfig := conf.Receivers[0].WechatConfigs[1] if secondConfig.APISecretFile != "/override_file" || string(secondConfig.APISecret) != "" { t.Fatalf("Invalid Wechat API Secret file: %s\nExpected: %s", secondConfig.APISecretFile, "/override_file") } // override the global file with an inline URL thirdConfig := conf.Receivers[0].WechatConfigs[2] if string(thirdConfig.APISecret) != "my_inline_secret" || thirdConfig.APISecretFile != "" { t.Fatalf("Invalid Wechat API Secret: %s\nExpected: %s", string(thirdConfig.APISecret), "my_inline_secret") } } func TestMattermostDefaultWebhookURL(t *testing.T) { conf, err := LoadFile("testdata/conf.mattermost-default-webhook-url.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.mattermost-default-webhook-url.yml", err) } defaultWebhookURL := conf.Global.MattermostWebhookURL overrideWebhookURL := "https://fakemattermost.example.com/hooks/xxxxxxxxxxxxxxxxxxxxxxxxxx" if defaultWebhookURL != conf.Receivers[0].MattermostConfigs[0].WebhookURL { t.Fatalf("Invalid mattermost webhook url: %s\nExpected: %s", conf.Receivers[0].MattermostConfigs[0].WebhookURL, defaultWebhookURL) } if overrideWebhookURL != conf.Receivers[1].MattermostConfigs[0].WebhookURL.String() { t.Errorf("Invalid mattermost webhook url: %s\nExpected: %s", conf.Receivers[1].MattermostConfigs[0].WebhookURL, overrideWebhookURL) } } func TestMattermostDefaultWebhookURLFile(t *testing.T) { conf, err := LoadFile("testdata/conf.mattermost-default-webhook-url-file.yml") if err != nil { t.Fatalf("Error parsing %s: %s", "testdata/conf.mattermost-default-webhook-url-file.yml", err) } defaultWebhookURLFile := conf.Global.MattermostWebhookURLFile overrideWebhookURLFile := "/override_file" if defaultWebhookURLFile != conf.Receivers[0].MattermostConfigs[0].WebhookURLFile { t.Fatalf("Invalid mattermost webhook url file: %s\nExpected: %s", conf.Receivers[0].MattermostConfigs[0].WebhookURLFile, defaultWebhookURLFile) } if overrideWebhookURLFile != conf.Receivers[1].MattermostConfigs[0].WebhookURLFile { t.Errorf("Invalid mattermost webhook url file: %s\nExpected: %s", conf.Receivers[1].MattermostConfigs[0].WebhookURLFile, overrideWebhookURLFile) } } func TestMattermostBothWebhookURLAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.mattermost-both-webhook-url-and-file.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.mattermost-both-webhook-url-and-file.yml", err) } if err.Error() != "at most one of mattermost_webhook_url & mattermost_webhook_url_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of mattermost_webhook_url & mattermost_webhook_url_file must be configured", err.Error()) } } func TestMattermostValidReceiverBothWebhookURLAndFile(t *testing.T) { _, err := LoadFile("testdata/conf.mattermost-valid-receiver-both-webhook-url-and-file.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.mattermost-valid-receiver-both-webhook-url-and-file.yml", err) } if err.Error() != "at most one of mattermost_webhook_url & mattermost_webhook_url_file must be configured" { t.Errorf("Expected: %s\nGot: %s", "at most one of mattermost_webhook_url & mattermost_webhook_url_file must be configured", err.Error()) } } func TestMattermostNoWebhookURL(t *testing.T) { _, err := LoadFile("testdata/conf.mattermost-no-webhook-url.yml") if err == nil { t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.mattermost-no-webhook-url.yml", err) } if err.Error() != "missing webhook_url or webhook_url_file on mattermost_config" { t.Errorf("Expected: %s\nGot: %s", "missing webhook_url or webhook_url_file on mattermost_config", err.Error()) } } ================================================ FILE: config/coordinator.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "crypto/md5" "encoding/binary" "log/slog" "sync" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) // Coordinator coordinates Alertmanager configurations beyond the lifetime of a // single configuration. type Coordinator struct { configFilePath string logger *slog.Logger // Protects config and subscribers mutex sync.Mutex config *Config subscribers []func(*Config) error configHashMetric prometheus.Gauge configSuccessMetric prometheus.Gauge configSuccessTimeMetric prometheus.Gauge } // NewCoordinator returns a new coordinator with the given configuration file // path. It does not yet load the configuration from file. This is done in // `Reload()`. func NewCoordinator(configFilePath string, r prometheus.Registerer, l *slog.Logger) *Coordinator { c := &Coordinator{ configFilePath: configFilePath, logger: l, } c.registerMetrics(r) return c } func (c *Coordinator) registerMetrics(r prometheus.Registerer) { configHash := promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_config_hash", Help: "Hash of the currently loaded alertmanager configuration. Note that this is not a cryptographically strong hash.", }) configSuccess := promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_config_last_reload_successful", Help: "Whether the last configuration reload attempt was successful.", }) configSuccessTime := promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_config_last_reload_success_timestamp_seconds", Help: "Timestamp of the last successful configuration reload.", }) c.configHashMetric = configHash c.configSuccessMetric = configSuccess c.configSuccessTimeMetric = configSuccessTime } // Subscribe subscribes the given Subscribers to configuration changes. func (c *Coordinator) Subscribe(ss ...func(*Config) error) { c.mutex.Lock() defer c.mutex.Unlock() c.subscribers = append(c.subscribers, ss...) } func (c *Coordinator) notifySubscribers() error { for _, s := range c.subscribers { if err := s(c.config); err != nil { return err } } return nil } // loadFromFile triggers a configuration load, discarding the old configuration. func (c *Coordinator) loadFromFile() error { conf, err := LoadFile(c.configFilePath) if err != nil { return err } c.config = conf return nil } // Reload triggers a configuration reload from file and notifies all // configuration change subscribers. func (c *Coordinator) Reload() error { c.mutex.Lock() defer c.mutex.Unlock() c.logger.Info( "Loading configuration file", "file", c.configFilePath, ) if err := c.loadFromFile(); err != nil { c.logger.Error( "Loading configuration file failed", "file", c.configFilePath, "err", err, ) c.configSuccessMetric.Set(0) return err } c.logger.Info( "Completed loading of configuration file", "file", c.configFilePath, ) if err := c.notifySubscribers(); err != nil { c.logger.Error( "one or more config change subscribers failed to apply new config", "file", c.configFilePath, "err", err, ) c.configSuccessMetric.Set(0) return err } c.configSuccessMetric.Set(1) c.configSuccessTimeMetric.SetToCurrentTime() hash := md5HashAsMetricValue([]byte(c.config.original)) c.configHashMetric.Set(hash) return nil } func md5HashAsMetricValue(data []byte) float64 { sum := md5.Sum(data) // We only want 48 bits as a float64 only has a 53 bit mantissa. smallSum := sum[0:6] bytes := make([]byte, 8) copy(bytes, smallSum) return float64(binary.LittleEndian.Uint64(bytes)) } ================================================ FILE: config/coordinator_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "errors" "testing" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/promslog" ) type fakeRegisterer struct { registeredCollectors []prometheus.Collector } func (r *fakeRegisterer) Register(prometheus.Collector) error { return nil } func (r *fakeRegisterer) MustRegister(c ...prometheus.Collector) { r.registeredCollectors = append(r.registeredCollectors, c...) } func (r *fakeRegisterer) Unregister(prometheus.Collector) bool { return false } func TestCoordinatorRegistersMetrics(t *testing.T) { fr := fakeRegisterer{} NewCoordinator("testdata/conf.good.yml", &fr, promslog.NewNopLogger()) if len(fr.registeredCollectors) == 0 { t.Error("expected NewCoordinator to register metrics on the given registerer") } } func TestCoordinatorNotifiesSubscribers(t *testing.T) { callBackCalled := false c := NewCoordinator("testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) c.Subscribe(func(*Config) error { callBackCalled = true return nil }) err := c.Reload() if err != nil { t.Fatal(err) } if !callBackCalled { t.Fatal("expected coordinator.Reload() to call subscribers") } } func TestCoordinatorFailReloadWhenSubscriberFails(t *testing.T) { errMessage := "something happened" c := NewCoordinator("testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) c.Subscribe(func(*Config) error { return errors.New(errMessage) }) err := c.Reload() if err == nil { t.Fatal("expected reload to throw an error") } if err.Error() != errMessage { t.Fatalf("expected error message %q but got %q", errMessage, err) } } ================================================ FILE: config/notifiers.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "errors" "fmt" "net/textproto" "regexp" "slices" "strings" "time" commoncfg "github.com/prometheus/common/config" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/common/model" "github.com/prometheus/sigv4" ) var ( // DefaultIncidentioConfig defines default values for Incident.io configurations. DefaultIncidentioConfig = IncidentioConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, } // DefaultWebhookConfig defines default values for Webhook configurations. DefaultWebhookConfig = WebhookConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, } // DefaultWebexConfig defines default values for Webex configurations. DefaultWebexConfig = WebexConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Message: `{{ template "webex.default.message" . }}`, } // DefaultDiscordConfig defines default values for Discord configurations. DefaultDiscordConfig = DiscordConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Title: `{{ template "discord.default.title" . }}`, Message: `{{ template "discord.default.message" . }}`, } // DefaultEmailConfig defines default values for Email configurations. DefaultEmailConfig = EmailConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: false, }, HTML: `{{ template "email.default.html" . }}`, Text: ``, } // DefaultEmailSubject defines the default Subject header of an Email. DefaultEmailSubject = `{{ template "email.default.subject" . }}` // DefaultPagerdutyDetails defines the default values for PagerDuty details. DefaultPagerdutyDetails = map[string]any{ "firing": `{{ .Alerts.Firing | toJson }}`, "resolved": `{{ .Alerts.Resolved | toJson }}`, "num_firing": `{{ .Alerts.Firing | len }}`, "num_resolved": `{{ .Alerts.Resolved | len }}`, } // DefaultPagerdutyConfig defines default values for PagerDuty configurations. DefaultPagerdutyConfig = PagerdutyConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Description: `{{ template "pagerduty.default.description" .}}`, Client: `{{ template "pagerduty.default.client" . }}`, ClientURL: `{{ template "pagerduty.default.clientURL" . }}`, } // DefaultSlackConfig defines default values for Slack configurations. DefaultSlackConfig = SlackConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: false, }, Color: `{{ template "slack.default.color" . }}`, Username: `{{ template "slack.default.username" . }}`, Title: `{{ template "slack.default.title" . }}`, TitleLink: `{{ template "slack.default.titlelink" . }}`, IconEmoji: `{{ template "slack.default.iconemoji" . }}`, IconURL: `{{ template "slack.default.iconurl" . }}`, Pretext: `{{ template "slack.default.pretext" . }}`, Text: `{{ template "slack.default.text" . }}`, Fallback: `{{ template "slack.default.fallback" . }}`, CallbackID: `{{ template "slack.default.callbackid" . }}`, Footer: `{{ template "slack.default.footer" . }}`, } // DefaultRocketchatConfig defines default values for Rocketchat configurations. DefaultRocketchatConfig = RocketchatConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: false, }, Color: `{{ if eq .Status "firing" }}red{{ else }}green{{ end }}`, Emoji: `{{ template "rocketchat.default.emoji" . }}`, IconURL: `{{ template "rocketchat.default.iconurl" . }}`, Text: `{{ template "rocketchat.default.text" . }}`, Title: `{{ template "rocketchat.default.title" . }}`, TitleLink: `{{ template "rocketchat.default.titlelink" . }}`, } // DefaultOpsGenieConfig defines default values for OpsGenie configurations. DefaultOpsGenieConfig = OpsGenieConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Message: `{{ template "opsgenie.default.message" . }}`, Description: `{{ template "opsgenie.default.description" . }}`, Source: `{{ template "opsgenie.default.source" . }}`, // TODO: Add a details field with all the alerts. } // DefaultWechatConfig defines default values for wechat configurations. DefaultWechatConfig = WechatConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: false, }, Message: `{{ template "wechat.default.message" . }}`, ToUser: `{{ template "wechat.default.to_user" . }}`, ToParty: `{{ template "wechat.default.to_party" . }}`, ToTag: `{{ template "wechat.default.to_tag" . }}`, AgentID: `{{ template "wechat.default.agent_id" . }}`, } // DefaultVictorOpsConfig defines default values for VictorOps configurations. DefaultVictorOpsConfig = VictorOpsConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, MessageType: `CRITICAL`, StateMessage: `{{ template "victorops.default.state_message" . }}`, EntityDisplayName: `{{ template "victorops.default.entity_display_name" . }}`, MonitoringTool: `{{ template "victorops.default.monitoring_tool" . }}`, } // DefaultPushoverConfig defines default values for Pushover configurations. DefaultPushoverConfig = PushoverConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Title: `{{ template "pushover.default.title" . }}`, Message: `{{ template "pushover.default.message" . }}`, URL: `{{ template "pushover.default.url" . }}`, Priority: `{{ if eq .Status "firing" }}2{{ else }}0{{ end }}`, // emergency (firing) or normal Retry: duration(1 * time.Minute), Expire: duration(1 * time.Hour), HTML: false, } // DefaultSNSConfig defines default values for SNS configurations. DefaultSNSConfig = SNSConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Subject: `{{ template "sns.default.subject" . }}`, Message: `{{ template "sns.default.message" . }}`, } DefaultTelegramConfig = TelegramConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, DisableNotifications: false, Message: `{{ template "telegram.default.message" . }}`, ParseMode: "HTML", } DefaultMSTeamsConfig = MSTeamsConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Title: `{{ template "msteams.default.title" . }}`, Summary: `{{ template "msteams.default.summary" . }}`, Text: `{{ template "msteams.default.text" . }}`, } DefaultMSTeamsV2Config = MSTeamsV2Config{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Title: `{{ template "msteamsv2.default.title" . }}`, Text: `{{ template "msteamsv2.default.text" . }}`, } DefaultJiraConfig = JiraConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, APIType: "auto", Summary: JiraFieldConfig{ Template: `{{ template "jira.default.summary" . }}`, }, Description: JiraFieldConfig{ Template: `{{ template "jira.default.description" . }}`, }, Priority: `{{ template "jira.default.priority" . }}`, } DefaultMattermostConfig = MattermostConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Username: `{{ template "mattermost.default.username" . }}`, Color: `{{ template "mattermost.default.color" . }}`, Text: `{{ template "mattermost.default.text" . }}`, Title: `{{ template "mattermost.default.title" . }}`, TitleLink: `{{ template "mattermost.default.titlelink" . }}`, Fallback: `{{ template "mattermost.default.fallback" . }}`, } ) // WebexConfig configures notifications via Webex. type WebexConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APIURL *amcommoncfg.URL `yaml:"api_url,omitempty" json:"api_url,omitempty"` Message string `yaml:"message,omitempty" json:"message,omitempty"` RoomID string `yaml:"room_id" json:"room_id"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *WebexConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultWebexConfig type plain WebexConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.RoomID == "" { return errors.New("missing room_id on webex_config") } if c.HTTPConfig == nil || c.HTTPConfig.Authorization == nil { return errors.New("missing webex_configs.http_config.authorization") } return nil } // DiscordConfig configures notifications via Discord. type DiscordConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` WebhookURL *amcommoncfg.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"` WebhookURLFile string `yaml:"webhook_url_file,omitempty" json:"webhook_url_file,omitempty"` Content string `yaml:"content,omitempty" json:"content,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` Message string `yaml:"message,omitempty" json:"message,omitempty"` Username string `yaml:"username,omitempty" json:"username,omitempty"` AvatarURL string `yaml:"avatar_url,omitempty" json:"avatar_url,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *DiscordConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultDiscordConfig type plain DiscordConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.WebhookURL == nil && c.WebhookURLFile == "" { return errors.New("one of webhook_url or webhook_url_file must be configured") } if c.WebhookURL != nil && len(c.WebhookURLFile) > 0 { return errors.New("at most one of webhook_url & webhook_url_file must be configured") } return nil } // EmailConfig configures notifications via mail. type EmailConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` // Email address to notify. To string `yaml:"to,omitempty" json:"to,omitempty"` From string `yaml:"from,omitempty" json:"from,omitempty"` Hello string `yaml:"hello,omitempty" json:"hello,omitempty"` Smarthost HostPort `yaml:"smarthost,omitempty" json:"smarthost,omitempty"` AuthUsername string `yaml:"auth_username,omitempty" json:"auth_username,omitempty"` AuthPassword commoncfg.Secret `yaml:"auth_password,omitempty" json:"auth_password,omitempty"` AuthPasswordFile string `yaml:"auth_password_file,omitempty" json:"auth_password_file,omitempty"` AuthSecret commoncfg.Secret `yaml:"auth_secret,omitempty" json:"auth_secret,omitempty"` AuthSecretFile string `yaml:"auth_secret_file,omitempty" json:"auth_secret_file,omitempty"` AuthIdentity string `yaml:"auth_identity,omitempty" json:"auth_identity,omitempty"` Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` HTML string `yaml:"html,omitempty" json:"html,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` RequireTLS *bool `yaml:"require_tls,omitempty" json:"require_tls,omitempty"` TLSConfig *commoncfg.TLSConfig `yaml:"tls_config,omitempty" json:"tls_config,omitempty"` // ForceImplicitTLS controls whether to use implicit TLS (direct TLS connection). // true: force use of implicit TLS (direct TLS connection) // false: force disable implicit TLS (use explicit TLS/STARTTLS if required) // nil (default): auto-detect based on port (465=implicit, other=explicit) for backward compatibility ForceImplicitTLS *bool `yaml:"force_implicit_tls,omitempty" json:"force_implicit_tls,omitempty"` Threading ThreadingConfig `yaml:"threading,omitempty" json:"threading,omitempty"` } // ThreadingConfig configures mail threading. type ThreadingConfig struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` ThreadByDate string `yaml:"thread_by_date,omitempty" json:"thread_by_date,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *EmailConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultEmailConfig type plain EmailConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.To == "" { return errors.New("missing to address in email config") } // Header names are case-insensitive, check for collisions. normalizedHeaders := map[string]string{} for h, v := range c.Headers { normalized := textproto.CanonicalMIMEHeaderKey(h) if _, ok := normalizedHeaders[normalized]; ok { return fmt.Errorf("duplicate header %q in email config", normalized) } normalizedHeaders[normalized] = v } c.Headers = normalizedHeaders if c.Threading.Enabled { if _, ok := normalizedHeaders["References"]; ok { return errors.New("conflicting configuration: threading.enabled conflicts with custom References header") } if _, ok := normalizedHeaders["In-Reply-To"]; ok { return errors.New("conflicting configuration: threading.enabled conflicts with custom In-Reply-To header") } if !slices.Contains([]string{"none", "daily"}, c.Threading.ThreadByDate) { return errors.New("threading.thread_by_date must be either 'none' or 'daily'") } } return nil } // PagerdutyConfig configures notifications via PagerDuty. type PagerdutyConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` ServiceKey commoncfg.Secret `yaml:"service_key,omitempty" json:"service_key,omitempty"` ServiceKeyFile string `yaml:"service_key_file,omitempty" json:"service_key_file,omitempty"` RoutingKey commoncfg.Secret `yaml:"routing_key,omitempty" json:"routing_key,omitempty"` RoutingKeyFile string `yaml:"routing_key_file,omitempty" json:"routing_key_file,omitempty"` URL *amcommoncfg.URL `yaml:"url,omitempty" json:"url,omitempty"` Client string `yaml:"client,omitempty" json:"client,omitempty"` ClientURL string `yaml:"client_url,omitempty" json:"client_url,omitempty"` Description string `yaml:"description,omitempty" json:"description,omitempty"` Details map[string]any `yaml:"details,omitempty" json:"details,omitempty"` Images []PagerdutyImage `yaml:"images,omitempty" json:"images,omitempty"` Links []PagerdutyLink `yaml:"links,omitempty" json:"links,omitempty"` Source string `yaml:"source,omitempty" json:"source,omitempty"` Severity string `yaml:"severity,omitempty" json:"severity,omitempty"` Class string `yaml:"class,omitempty" json:"class,omitempty"` Component string `yaml:"component,omitempty" json:"component,omitempty"` Group string `yaml:"group,omitempty" json:"group,omitempty"` // Timeout is the maximum time allowed to invoke the pagerduty. Setting this to 0 // does not impose a timeout. Timeout time.Duration `yaml:"timeout" json:"timeout"` } // PagerdutyLink is a link. type PagerdutyLink struct { Href string `yaml:"href,omitempty" json:"href,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` } // PagerdutyImage is an image. type PagerdutyImage struct { Src string `yaml:"src,omitempty" json:"src,omitempty"` Alt string `yaml:"alt,omitempty" json:"alt,omitempty"` Href string `yaml:"href,omitempty" json:"href,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *PagerdutyConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultPagerdutyConfig type plain PagerdutyConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.RoutingKey == "" && c.ServiceKey == "" && c.RoutingKeyFile == "" && c.ServiceKeyFile == "" { return errors.New("missing service or routing key in PagerDuty config") } if len(c.RoutingKey) > 0 && len(c.RoutingKeyFile) > 0 { return errors.New("at most one of routing_key & routing_key_file must be configured") } if len(c.ServiceKey) > 0 && len(c.ServiceKeyFile) > 0 { return errors.New("at most one of service_key & service_key_file must be configured") } if c.Details == nil { c.Details = make(map[string]any) } if c.Source == "" { c.Source = c.Client } for k, v := range DefaultPagerdutyDetails { if _, ok := c.Details[k]; !ok { c.Details[k] = v } } return nil } // SlackAction configures a single Slack action that is sent with each notification. // See https://api.slack.com/docs/message-attachments#action_fields and https://api.slack.com/docs/message-buttons // for more information. type SlackAction struct { Type string `yaml:"type,omitempty" json:"type,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` URL string `yaml:"url,omitempty" json:"url,omitempty"` Style string `yaml:"style,omitempty" json:"style,omitempty"` Name string `yaml:"name,omitempty" json:"name,omitempty"` Value string `yaml:"value,omitempty" json:"value,omitempty"` ConfirmField *SlackConfirmationField `yaml:"confirm,omitempty" json:"confirm,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for SlackAction. func (c *SlackAction) UnmarshalYAML(unmarshal func(any) error) error { type plain SlackAction if err := unmarshal((*plain)(c)); err != nil { return err } if c.Type == "" { return errors.New("missing type in Slack action configuration") } if c.Text == "" { return errors.New("missing text in Slack action configuration") } if c.URL != "" { // Clear all message action fields. c.Name = "" c.Value = "" c.ConfirmField = nil } else if c.Name != "" { c.URL = "" } else { return errors.New("missing name or url in Slack action configuration") } return nil } // SlackConfirmationField protect users from destructive actions or particularly distinguished decisions // by asking them to confirm their button click one more time. // See https://api.slack.com/docs/interactive-message-field-guide#confirmation_fields for more information. type SlackConfirmationField struct { Text string `yaml:"text,omitempty" json:"text,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` OkText string `yaml:"ok_text,omitempty" json:"ok_text,omitempty"` DismissText string `yaml:"dismiss_text,omitempty" json:"dismiss_text,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for SlackConfirmationField. func (c *SlackConfirmationField) UnmarshalYAML(unmarshal func(any) error) error { type plain SlackConfirmationField if err := unmarshal((*plain)(c)); err != nil { return err } if c.Text == "" { return errors.New("missing text in Slack confirmation configuration") } return nil } // SlackField configures a single Slack field that is sent with each notification. // Each field must contain a title, value, and optionally, a boolean value to indicate if the field // is short enough to be displayed next to other fields designated as short. // See https://api.slack.com/docs/message-attachments#fields for more information. type SlackField struct { Title string `yaml:"title,omitempty" json:"title,omitempty"` Value string `yaml:"value,omitempty" json:"value,omitempty"` Short *bool `yaml:"short,omitempty" json:"short,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for SlackField. func (c *SlackField) UnmarshalYAML(unmarshal func(any) error) error { type plain SlackField if err := unmarshal((*plain)(c)); err != nil { return err } if c.Title == "" { return errors.New("missing title in Slack field configuration") } if c.Value == "" { return errors.New("missing value in Slack field configuration") } return nil } // SlackConfig configures notifications via Slack. type SlackConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APIURL *amcommoncfg.SecretURL `yaml:"api_url,omitempty" json:"api_url,omitempty"` APIURLFile string `yaml:"api_url_file,omitempty" json:"api_url_file,omitempty"` AppToken commoncfg.Secret `yaml:"app_token,omitempty" json:"app_token,omitempty"` AppTokenFile string `yaml:"app_token_file,omitempty" json:"app_token_file,omitempty"` AppURL *amcommoncfg.URL `yaml:"app_url,omitempty" json:"app_url,omitempty"` // Slack channel override, (like #other-channel or @username). Channel string `yaml:"channel,omitempty" json:"channel,omitempty"` Username string `yaml:"username,omitempty" json:"username,omitempty"` Color string `yaml:"color,omitempty" json:"color,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"` Pretext string `yaml:"pretext,omitempty" json:"pretext,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` MessageText string `yaml:"message_text,omitempty" json:"message_text,omitempty"` Fields []*SlackField `yaml:"fields,omitempty" json:"fields,omitempty"` ShortFields bool `yaml:"short_fields" json:"short_fields,omitempty"` Footer string `yaml:"footer,omitempty" json:"footer,omitempty"` Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"` CallbackID string `yaml:"callback_id,omitempty" json:"callback_id,omitempty"` IconEmoji string `yaml:"icon_emoji,omitempty" json:"icon_emoji,omitempty"` IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"` ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"` ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"` LinkNames bool `yaml:"link_names" json:"link_names,omitempty"` MrkdwnIn []string `yaml:"mrkdwn_in,omitempty" json:"mrkdwn_in,omitempty"` Actions []*SlackAction `yaml:"actions,omitempty" json:"actions,omitempty"` // UpdateMessage enables updating existing Slack messages instead of creating new ones. // Requires bot token with chat:write scope. Webhook URLs do not support updates. UpdateMessage bool `yaml:"update_message" json:"update_message,omitempty"` // Timeout is the maximum time allowed to invoke the slack. Setting this to 0 // does not impose a timeout. Timeout time.Duration `yaml:"timeout" json:"timeout"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultSlackConfig type plain SlackConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.APIURL != nil && len(c.APIURLFile) > 0 { return errors.New("at most one of api_url & api_url_file must be configured") } if c.AppToken != "" && len(c.AppTokenFile) > 0 { return errors.New("at most one of app_token & app_token_file must be configured") } if (c.APIURL != nil || len(c.APIURLFile) > 0) && (c.AppToken != "" || len(c.AppTokenFile) > 0) { return errors.New("at most one of api_url/api_url_file & app_token/app_token_file must be configured") } if c.UpdateMessage && c.APIURL.String() != "https://slack.com/api/chat.postMessage" { return errors.New("update_message can only be used with bot tokens. api_url must be set to https://slack.com/api/chat.postMessage") } return nil } // IncidentioConfig configures notifications via incident.io. type IncidentioConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` // URL to send POST request to. URL *amcommoncfg.URL `yaml:"url" json:"url"` URLFile string `yaml:"url_file" json:"url_file"` // AlertSourceToken is the key used to authenticate with the alert source in incident.io. AlertSourceToken commoncfg.Secret `yaml:"alert_source_token,omitempty" json:"alert_source_token,omitempty"` AlertSourceTokenFile string `yaml:"alert_source_token_file,omitempty" json:"alert_source_token_file,omitempty"` // MaxAlerts is the maximum number of alerts to be sent per incident.io message. // Alerts exceeding this threshold will be truncated. Setting this to 0 // allows an unlimited number of alerts. Note that if the payload exceeds // incident.io's size limits, you will receive a 429 response and alerts // will not be ingested. MaxAlerts uint64 `yaml:"max_alerts" json:"max_alerts"` // Timeout is the maximum time allowed to invoke incident.io. Setting this to 0 // does not impose a timeout. Timeout time.Duration `yaml:"timeout" json:"timeout"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *IncidentioConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultIncidentioConfig type plain IncidentioConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.URL == nil && c.URLFile == "" { return errors.New("one of url or url_file must be configured") } if c.URL != nil && c.URLFile != "" { return errors.New("at most one of url & url_file must be configured") } if c.AlertSourceToken != "" && c.AlertSourceTokenFile != "" { return errors.New("at most one of alert_source_token & alert_source_token_file must be configured") } if c.HTTPConfig != nil && c.HTTPConfig.Authorization != nil && (c.AlertSourceToken != "" || c.AlertSourceTokenFile != "") { return errors.New("cannot specify alert_source_token or alert_source_token_file when using http_config.authorization") } if (c.HTTPConfig != nil && c.HTTPConfig.Authorization == nil) && c.AlertSourceToken == "" && c.AlertSourceTokenFile == "" { return errors.New("at least one of alert_source_token, alert_source_token_file or http_config.authorization must be configured") } return nil } // WebhookConfig configures notifications via a generic webhook. type WebhookConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` // URL to send POST request to. URL SecretTemplateURL `yaml:"url,omitempty" json:"url,omitempty"` URLFile string `yaml:"url_file" json:"url_file"` // MaxAlerts is the maximum number of alerts to be sent per webhook message. // Alerts exceeding this threshold will be truncated. Setting this to 0 // allows an unlimited number of alerts. MaxAlerts uint64 `yaml:"max_alerts" json:"max_alerts"` // Timeout is the maximum time allowed to invoke the webhook. Setting this to 0 // does not impose a timeout. Timeout time.Duration `yaml:"timeout" json:"timeout"` Payload map[string]any `yaml:"payload,omitempty" json:"payload,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *WebhookConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultWebhookConfig type plain WebhookConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.URL == "" && c.URLFile == "" { return errors.New("one of url or url_file must be configured") } if c.URL != "" && c.URLFile != "" { return errors.New("at most one of url & url_file must be configured") } return nil } // WechatConfig configures notifications via Wechat. type WechatConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APISecret commoncfg.Secret `yaml:"api_secret,omitempty" json:"api_secret,omitempty"` APISecretFile string `yaml:"api_secret_file,omitempty" json:"api_secret_file,omitempty"` CorpID string `yaml:"corp_id,omitempty" json:"corp_id,omitempty"` Message string `yaml:"message,omitempty" json:"message,omitempty"` APIURL *amcommoncfg.URL `yaml:"api_url,omitempty" json:"api_url,omitempty"` ToUser string `yaml:"to_user,omitempty" json:"to_user,omitempty"` ToParty string `yaml:"to_party,omitempty" json:"to_party,omitempty"` ToTag string `yaml:"to_tag,omitempty" json:"to_tag,omitempty"` AgentID string `yaml:"agent_id,omitempty" json:"agent_id,omitempty"` MessageType string `yaml:"message_type,omitempty" json:"message_type,omitempty"` } const wechatValidTypesRe = `^(text|markdown)$` var wechatTypeMatcher = regexp.MustCompile(wechatValidTypesRe) // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *WechatConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultWechatConfig type plain WechatConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.MessageType == "" { c.MessageType = "text" } if !wechatTypeMatcher.MatchString(c.MessageType) { return fmt.Errorf("weChat message type %q does not match valid options %s", c.MessageType, wechatValidTypesRe) } if c.APISecret != "" && len(c.APISecretFile) > 0 { return errors.New("at most one of api_secret & api_secret_file must be configured") } return nil } // OpsGenieConfig configures notifications via OpsGenie. type OpsGenieConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APIKey commoncfg.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"` APIKeyFile string `yaml:"api_key_file,omitempty" json:"api_key_file,omitempty"` APIURL *amcommoncfg.URL `yaml:"api_url,omitempty" json:"api_url,omitempty"` Message string `yaml:"message,omitempty" json:"message,omitempty"` Description string `yaml:"description,omitempty" json:"description,omitempty"` Source string `yaml:"source,omitempty" json:"source,omitempty"` Details map[string]string `yaml:"details,omitempty" json:"details,omitempty"` Entity string `yaml:"entity,omitempty" json:"entity,omitempty"` Responders []OpsGenieConfigResponder `yaml:"responders,omitempty" json:"responders,omitempty"` Actions string `yaml:"actions,omitempty" json:"actions,omitempty"` Tags string `yaml:"tags,omitempty" json:"tags,omitempty"` Note string `yaml:"note,omitempty" json:"note,omitempty"` Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` UpdateAlerts bool `yaml:"update_alerts,omitempty" json:"update_alerts,omitempty"` } const opsgenieValidTypesRe = `^(team|teams|user|escalation|schedule)$` var opsgenieTypeMatcher = regexp.MustCompile(opsgenieValidTypesRe) // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *OpsGenieConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultOpsGenieConfig type plain OpsGenieConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.APIKey != "" && len(c.APIKeyFile) > 0 { return errors.New("at most one of api_key & api_key_file must be configured") } for _, r := range c.Responders { if r.ID == "" && r.Username == "" && r.Name == "" { return fmt.Errorf("opsGenieConfig responder %v has to have at least one of id, username or name specified", r) } isTemplated, err := containsTemplating(r.Type) if err != nil { return fmt.Errorf("opsGenieConfig responder %v type contains invalid template syntax: %w", r, err) } if !isTemplated { r.Type = strings.ToLower(r.Type) if !opsgenieTypeMatcher.MatchString(r.Type) { return fmt.Errorf("opsGenieConfig responder %v type does not match valid options %s", r, opsgenieValidTypesRe) } } } return nil } type OpsGenieConfigResponder struct { // One of those 3 should be filled. ID string `yaml:"id,omitempty" json:"id,omitempty"` Name string `yaml:"name,omitempty" json:"name,omitempty"` Username string `yaml:"username,omitempty" json:"username,omitempty"` // team, user, escalation, schedule etc. Type string `yaml:"type,omitempty" json:"type,omitempty"` } // VictorOpsConfig configures notifications via VictorOps. type VictorOpsConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APIKey commoncfg.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"` APIKeyFile string `yaml:"api_key_file,omitempty" json:"api_key_file,omitempty"` APIURL *amcommoncfg.URL `yaml:"api_url" json:"api_url"` RoutingKey string `yaml:"routing_key" json:"routing_key"` MessageType string `yaml:"message_type" json:"message_type"` StateMessage string `yaml:"state_message" json:"state_message"` EntityDisplayName string `yaml:"entity_display_name" json:"entity_display_name"` MonitoringTool string `yaml:"monitoring_tool" json:"monitoring_tool"` CustomFields map[string]string `yaml:"custom_fields,omitempty" json:"custom_fields,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *VictorOpsConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultVictorOpsConfig type plain VictorOpsConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.RoutingKey == "" { return errors.New("missing Routing key in VictorOps config") } if c.APIKey != "" && len(c.APIKeyFile) > 0 { return errors.New("at most one of api_key & api_key_file must be configured") } reservedFields := []string{"routing_key", "message_type", "state_message", "entity_display_name", "monitoring_tool", "entity_id", "entity_state"} for _, v := range reservedFields { if _, ok := c.CustomFields[v]; ok { return fmt.Errorf("victorOps config contains custom field %s which cannot be used as it conflicts with the fixed/static fields", v) } } return nil } type duration time.Duration func (d *duration) UnmarshalText(text []byte) error { parsed, err := time.ParseDuration(string(text)) if err == nil { *d = duration(parsed) } return err } func (d duration) MarshalText() ([]byte, error) { return []byte(time.Duration(d).String()), nil } type PushoverConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` UserKey commoncfg.Secret `yaml:"user_key,omitempty" json:"user_key,omitempty"` UserKeyFile string `yaml:"user_key_file,omitempty" json:"user_key_file,omitempty"` Token commoncfg.Secret `yaml:"token,omitempty" json:"token,omitempty"` TokenFile string `yaml:"token_file,omitempty" json:"token_file,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` Message string `yaml:"message,omitempty" json:"message,omitempty"` URL string `yaml:"url,omitempty" json:"url,omitempty"` URLTitle string `yaml:"url_title,omitempty" json:"url_title,omitempty"` Device string `yaml:"device,omitempty" json:"device,omitempty"` Sound string `yaml:"sound,omitempty" json:"sound,omitempty"` Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` Retry duration `yaml:"retry,omitempty" json:"retry,omitempty"` Expire duration `yaml:"expire,omitempty" json:"expire,omitempty"` TTL duration `yaml:"ttl,omitempty" json:"ttl,omitempty"` HTML bool `yaml:"html,omitempty" json:"html,omitempty"` Monospace bool `yaml:"monospace,omitempty" json:"monospace,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *PushoverConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultPushoverConfig type plain PushoverConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.UserKey == "" && c.UserKeyFile == "" { return errors.New("one of user_key or user_key_file must be configured") } if c.UserKey != "" && c.UserKeyFile != "" { return errors.New("at most one of user_key & user_key_file must be configured") } if c.Token == "" && c.TokenFile == "" { return errors.New("one of token or token_file must be configured") } if c.Token != "" && c.TokenFile != "" { return errors.New("at most one of token & token_file must be configured") } if c.HTML && c.Monospace { return errors.New("at most one of monospace & html must be configured") } return nil } type SNSConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APIUrl string `yaml:"api_url,omitempty" json:"api_url,omitempty"` Sigv4 sigv4.SigV4Config `yaml:"sigv4" json:"sigv4"` TopicARN string `yaml:"topic_arn,omitempty" json:"topic_arn,omitempty"` PhoneNumber string `yaml:"phone_number,omitempty" json:"phone_number,omitempty"` TargetARN string `yaml:"target_arn,omitempty" json:"target_arn,omitempty"` Subject string `yaml:"subject,omitempty" json:"subject,omitempty"` Message string `yaml:"message,omitempty" json:"message,omitempty"` Attributes map[string]string `yaml:"attributes,omitempty" json:"attributes,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *SNSConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultSNSConfig type plain SNSConfig if err := unmarshal((*plain)(c)); err != nil { return err } if (c.TargetARN == "") != (c.TopicARN == "") != (c.PhoneNumber == "") { return errors.New("must provide either a Target ARN, Topic ARN, or Phone Number for SNS config") } return nil } // TelegramConfig configures notifications via Telegram. type TelegramConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APIUrl *amcommoncfg.URL `yaml:"api_url" json:"api_url,omitempty"` BotToken commoncfg.Secret `yaml:"bot_token,omitempty" json:"token,omitempty"` BotTokenFile string `yaml:"bot_token_file,omitempty" json:"token_file,omitempty"` ChatID int64 `yaml:"chat_id,omitempty" json:"chat,omitempty"` ChatIDFile string `yaml:"chat_id_file,omitempty" json:"chat_file,omitempty"` MessageThreadID int `yaml:"message_thread_id,omitempty" json:"message_thread_id,omitempty"` Message string `yaml:"message,omitempty" json:"message,omitempty"` DisableNotifications bool `yaml:"disable_notifications,omitempty" json:"disable_notifications,omitempty"` ParseMode string `yaml:"parse_mode,omitempty" json:"parse_mode,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *TelegramConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultTelegramConfig type plain TelegramConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.BotToken != "" && c.BotTokenFile != "" { return errors.New("at most one of bot_token & bot_token_file must be configured") } if c.ChatID == 0 && c.ChatIDFile == "" { return errors.New("missing chat_id or chat_id_file on telegram_config") } if c.ChatID != 0 && c.ChatIDFile != "" { return errors.New("at most one of chat_id & chat_id_file must be configured") } if c.ParseMode != "" && c.ParseMode != "Markdown" && c.ParseMode != "MarkdownV2" && c.ParseMode != "HTML" { return errors.New("unknown parse_mode on telegram_config, must be Markdown, MarkdownV2, HTML or empty string") } return nil } type MSTeamsConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` WebhookURL *amcommoncfg.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"` WebhookURLFile string `yaml:"webhook_url_file,omitempty" json:"webhook_url_file,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` Summary string `yaml:"summary,omitempty" json:"summary,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` } func (c *MSTeamsConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultMSTeamsConfig type plain MSTeamsConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.WebhookURL == nil && c.WebhookURLFile == "" { return errors.New("one of webhook_url or webhook_url_file must be configured") } if c.WebhookURL != nil && len(c.WebhookURLFile) > 0 { return errors.New("at most one of webhook_url & webhook_url_file must be configured") } return nil } type MSTeamsV2Config struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` WebhookURL *amcommoncfg.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"` WebhookURLFile string `yaml:"webhook_url_file,omitempty" json:"webhook_url_file,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` } func (c *MSTeamsV2Config) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultMSTeamsV2Config type plain MSTeamsV2Config if err := unmarshal((*plain)(c)); err != nil { return err } if c.WebhookURL == nil && c.WebhookURLFile == "" { return errors.New("one of webhook_url or webhook_url_file must be configured") } if c.WebhookURL != nil && len(c.WebhookURLFile) > 0 { return errors.New("at most one of webhook_url & webhook_url_file must be configured") } return nil } type JiraFieldConfig struct { // Template is the template string used to render the field. Template string `yaml:"template,omitempty" json:"template,omitempty"` // EnableUpdate indicates whether this field should be omitted when updating an existing issue. EnableUpdate *bool `yaml:"enable_update,omitempty" json:"enable_update,omitempty"` } type JiraConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APIURL *amcommoncfg.URL `yaml:"api_url,omitempty" json:"api_url,omitempty"` APIType string `yaml:"api_type,omitempty" json:"api_type,omitempty"` Project string `yaml:"project,omitempty" json:"project,omitempty"` Summary JiraFieldConfig `yaml:"summary,omitempty" json:"summary,omitempty"` Description JiraFieldConfig `yaml:"description,omitempty" json:"description,omitempty"` Labels []string `yaml:"labels,omitempty" json:"labels,omitempty"` Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` IssueType string `yaml:"issue_type,omitempty" json:"issue_type,omitempty"` ReopenTransition string `yaml:"reopen_transition,omitempty" json:"reopen_transition,omitempty"` ResolveTransition string `yaml:"resolve_transition,omitempty" json:"resolve_transition,omitempty"` WontFixResolution string `yaml:"wont_fix_resolution,omitempty" json:"wont_fix_resolution,omitempty"` ReopenDuration model.Duration `yaml:"reopen_duration,omitempty" json:"reopen_duration,omitempty"` Fields map[string]any `yaml:"fields,omitempty" json:"custom_fields,omitempty"` } func (f *JiraFieldConfig) EnableUpdateValue() bool { if f.EnableUpdate == nil { return true } return *f.EnableUpdate } // Supports both the legacy string and the new object form. func (f *JiraFieldConfig) UnmarshalYAML(unmarshal func(any) error) error { // Try simple string first (backward compatibility). var s string if err := unmarshal(&s); err == nil { f.Template = s // DisableUpdate stays false by default. return nil } // Fallback to full object form. type plain JiraFieldConfig return unmarshal((*plain)(f)) } func (c *JiraConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultJiraConfig type plain JiraConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.Project == "" { return errors.New("missing project in jira_config") } if c.IssueType == "" { return errors.New("missing issue_type in jira_config") } if c.APIType != "auto" && c.APIType != "cloud" && c.APIType != "datacenter" { return errors.New("unknown api_type on jira_config, must be auto, cloud or datacenter") } return nil } type RocketchatAttachmentField struct { Short *bool `json:"short"` Title string `json:"title,omitempty"` Value string `json:"value,omitempty"` } const ( ProcessingTypeSendMessage = "sendMessage" ProcessingTypeRespondWithMessage = "respondWithMessage" ) type RocketchatAttachmentAction struct { Type string `json:"type,omitempty"` Text string `json:"text,omitempty"` URL string `json:"url,omitempty"` ImageURL string `json:"image_url,omitempty"` IsWebView bool `json:"is_webview"` WebviewHeightRatio string `json:"webview_height_ratio,omitempty"` Msg string `json:"msg,omitempty"` MsgInChatWindow bool `json:"msg_in_chat_window"` MsgProcessingType string `json:"msg_processing_type,omitempty"` } // RocketchatConfig configures notifications via Rocketchat. type RocketchatConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` APIURL *amcommoncfg.URL `yaml:"api_url,omitempty" json:"api_url,omitempty"` TokenID *commoncfg.Secret `yaml:"token_id,omitempty" json:"token_id,omitempty"` TokenIDFile string `yaml:"token_id_file,omitempty" json:"token_id_file,omitempty"` Token *commoncfg.Secret `yaml:"token,omitempty" json:"token,omitempty"` TokenFile string `yaml:"token_file,omitempty" json:"token_file,omitempty"` // RocketChat channel override, (like #other-channel or @username). Channel string `yaml:"channel,omitempty" json:"channel,omitempty"` Color string `yaml:"color,omitempty" json:"color,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` Fields []*RocketchatAttachmentField `yaml:"fields,omitempty" json:"fields,omitempty"` ShortFields bool `yaml:"short_fields" json:"short_fields,omitempty"` Emoji string `yaml:"emoji,omitempty" json:"emoji,omitempty"` IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"` ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"` ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"` LinkNames bool `yaml:"link_names" json:"link_names,omitempty"` Actions []*RocketchatAttachmentAction `yaml:"actions,omitempty" json:"actions,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *RocketchatConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultRocketchatConfig type plain RocketchatConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.Token != nil && len(c.TokenFile) > 0 { return errors.New("at most one of token & token_file must be configured") } if c.TokenID != nil && len(c.TokenIDFile) > 0 { return errors.New("at most one of token_id & token_id_file must be configured") } return nil } // MattermostPriority defines the priority for a mattermost notification. type MattermostPriority struct { Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` RequestedAck bool `yaml:"requested_ack,omitempty" json:"requested_ack,omitempty"` PersistentNotifications bool `yaml:"persistent_notifications,omitempty" json:"persistent_notifications,omitempty"` } // MattermostProps defines additional properties for a mattermost notification. // Only 'card' property takes effect now. type MattermostProps struct { Card string `yaml:"card,omitempty" json:"card,omitempty"` } // MattermostField configures a single Mattermost field for Slack compatibility. // See https://developers.mattermost.com/integrate/reference/message-attachments/#fields for more information. type MattermostField struct { Title string `yaml:"title,omitempty" json:"title,omitempty"` Value string `yaml:"value,omitempty" json:"value,omitempty"` Short *bool `yaml:"short,omitempty" json:"short,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface for MattermostField. func (c *MattermostField) UnmarshalYAML(unmarshal func(any) error) error { type plain MattermostField if err := unmarshal((*plain)(c)); err != nil { return err } if c.Title == "" { return errors.New("missing title in Mattermost field configuration") } if c.Value == "" { return errors.New("missing value in Mattermost field configuration") } return nil } // MattermostAttachment defines an attachment for a Mattermost notification. // See https://developers.mattermost.com/integrate/reference/message-attachments/#fields for more information. type MattermostAttachment struct { Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"` Color string `yaml:"color,omitempty" json:"color,omitempty"` Pretext string `yaml:"pretext,omitempty" json:"pretext,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` AuthorName string `yaml:"author_name,omitempty" json:"author_name,omitempty"` AuthorLink string `yaml:"author_link,omitempty" json:"author_link,omitempty"` AuthorIcon string `yaml:"author_icon,omitempty" json:"author_icon,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"` Fields []*MattermostField `yaml:"fields,omitempty" json:"fields,omitempty"` ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"` Footer string `yaml:"footer,omitempty" json:"footer,omitempty"` FooterIcon string `yaml:"footer_icon,omitempty" json:"footer_icon,omitempty"` ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"` } // MattermostConfig configures notifications via Mattermost. // See https://developers.mattermost.com/integrate/webhooks/incoming/ for more information. type MattermostConfig struct { amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` WebhookURL *amcommoncfg.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"` WebhookURLFile string `yaml:"webhook_url_file,omitempty" json:"webhook_url_file,omitempty"` Channel string `yaml:"channel,omitempty" json:"channel,omitempty"` Username string `yaml:"username,omitempty" json:"username,omitempty"` Text string `yaml:"text,omitempty" json:"text,omitempty"` Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"` Color string `yaml:"color,omitempty" json:"color,omitempty"` Pretext string `yaml:"pretext,omitempty" json:"pretext,omitempty"` AuthorName string `yaml:"author_name,omitempty" json:"author_name,omitempty"` AuthorLink string `yaml:"author_link,omitempty" json:"author_link,omitempty"` AuthorIcon string `yaml:"author_icon,omitempty" json:"author_icon,omitempty"` Title string `yaml:"title,omitempty" json:"title,omitempty"` TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"` Fields []*MattermostField `yaml:"fields,omitempty" json:"fields,omitempty"` ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"` Footer string `yaml:"footer,omitempty" json:"footer,omitempty"` FooterIcon string `yaml:"footer_icon,omitempty" json:"footer_icon,omitempty"` ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"` IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"` IconEmoji string `yaml:"icon_emoji,omitempty" json:"icon_emoji,omitempty"` Attachments []*MattermostAttachment `yaml:"attachments,omitempty" json:"attachments,omitempty"` Type string `yaml:"type,omitempty" json:"type,omitempty"` Props *MattermostProps `yaml:"props,omitempty" json:"props,omitempty"` Priority *MattermostPriority `yaml:"priority,omitempty" json:"priority,omitempty"` } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (c *MattermostConfig) UnmarshalYAML(unmarshal func(any) error) error { *c = DefaultMattermostConfig type plain MattermostConfig if err := unmarshal((*plain)(c)); err != nil { return err } if c.WebhookURL != nil && len(c.WebhookURLFile) > 0 { return errors.New("at most one of webhook_url & webhook_url_file must be configured") } return nil } ================================================ FILE: config/notifiers_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package config import ( "errors" "net/mail" "reflect" "strings" "testing" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) func TestEmailToIsPresent(t *testing.T) { in := ` to: '' ` var cfg EmailConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "missing to address in email config" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestEmailHeadersCollision(t *testing.T) { in := ` to: 'to@email.com' headers: Subject: 'Alert' sUbject: 'New Alert' ` var cfg EmailConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "duplicate header \"Subject\" in email config" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestEmailToAllowsMultipleAdresses(t *testing.T) { in := ` to: 'a@example.com, ,b@example.com,c@example.com' ` var cfg EmailConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) if err != nil { t.Fatal(err) } expected := []*mail.Address{ {Address: "a@example.com"}, {Address: "b@example.com"}, {Address: "c@example.com"}, } res, err := mail.ParseAddressList(cfg.To) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(res, expected) { t.Fatalf("expected %v, got %v", expected, res) } } func TestEmailDisallowMalformed(t *testing.T) { in := ` to: 'a@' ` var cfg EmailConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) if err != nil { t.Fatal(err) } _, err = mail.ParseAddressList(cfg.To) if err == nil { t.Fatalf("no error returned, expected:\n%v", "mail: no angle-addr") } } func TestPagerdutyTestRoutingKey(t *testing.T) { t.Run("error if no routing key or key file", func(t *testing.T) { in := ` routing_key: '' ` var cfg PagerdutyConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "missing service or routing key in PagerDuty config" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } }) t.Run("error if both routing key and key file", func(t *testing.T) { in := ` routing_key: 'xyz' routing_key_file: 'xyz' ` var cfg PagerdutyConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "at most one of routing_key & routing_key_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } }) } func TestPagerdutyServiceKey(t *testing.T) { t.Run("error if no service key or key file", func(t *testing.T) { in := ` service_key: '' ` var cfg PagerdutyConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "missing service or routing key in PagerDuty config" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } }) t.Run("error if both service key and key file", func(t *testing.T) { in := ` service_key: 'xyz' service_key_file: 'xyz' ` var cfg PagerdutyConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "at most one of service_key & service_key_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } }) } func TestPagerdutyDetails(t *testing.T) { tests := []struct { in string checkFn func(map[string]any) }{ { in: ` routing_key: 'xyz' `, checkFn: func(d map[string]any) { if len(d) != 4 { t.Errorf("expected 4 items, got: %d", len(d)) } }, }, { in: ` routing_key: 'xyz' details: key1: val1 `, checkFn: func(d map[string]any) { if len(d) != 5 { t.Errorf("expected 5 items, got: %d", len(d)) } }, }, { in: ` routing_key: 'xyz' details: key1: val1 key2: val2 firing: firing `, checkFn: func(d map[string]any) { if len(d) != 6 { t.Errorf("expected 6 items, got: %d", len(d)) } }, }, } for _, tc := range tests { var cfg PagerdutyConfig err := yaml.UnmarshalStrict([]byte(tc.in), &cfg) if err != nil { t.Errorf("expected no error, got:%v", err) } if tc.checkFn != nil { tc.checkFn(cfg.Details) } } } func TestPagerDutySource(t *testing.T) { for _, tc := range []struct { title string in string expectedSource string }{ { title: "check source field is backward compatible", in: ` routing_key: 'xyz' client: 'alert-manager-client' `, expectedSource: "alert-manager-client", }, { title: "check source field is set", in: ` routing_key: 'xyz' client: 'alert-manager-client' source: 'alert-manager-source' `, expectedSource: "alert-manager-source", }, } { t.Run(tc.title, func(t *testing.T) { var cfg PagerdutyConfig err := yaml.UnmarshalStrict([]byte(tc.in), &cfg) require.NoError(t, err) require.Equal(t, tc.expectedSource, cfg.Source) }) } } func TestWebhookURLIsPresent(t *testing.T) { in := `{}` var cfg WebhookConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "one of url or url_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestWebhookURLOrURLFile(t *testing.T) { in := ` url: 'http://example.com' url_file: 'http://example.com' ` var cfg WebhookConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "at most one of url & url_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestWebhookHttpConfigIsValid(t *testing.T) { in := ` url: 'http://example.com' http_config: bearer_token: foo bearer_token_file: /tmp/bar ` var cfg WebhookConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "at most one of bearer_token & bearer_token_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestWebhookHttpConfigIsOptional(t *testing.T) { in := ` url: 'http://example.com' ` var cfg WebhookConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) if err != nil { t.Fatalf("no error expected, returned:\n%v", err.Error()) } } func TestWebhookPasswordIsObfuscated(t *testing.T) { in := ` url: 'http://example.com' http_config: basic_auth: username: foo password: supersecret ` var cfg WebhookConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) if err != nil { t.Fatalf("no error expected, returned:\n%v", err.Error()) } ycfg, err := yaml.Marshal(cfg) if err != nil { t.Fatalf("no error expected, returned:\n%v", err.Error()) } if strings.Contains(string(ycfg), "supersecret") { t.Errorf("Found password in the YAML cfg: %s\n", ycfg) } } func TestVictorOpsConfiguration(t *testing.T) { t.Run("valid configuration", func(t *testing.T) { in := ` routing_key: test api_key_file: /global_file ` var cfg VictorOpsConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) if err != nil { t.Fatalf("no error was expected:\n%v", err) } }) t.Run("routing key is missing", func(t *testing.T) { in := ` routing_key: '' ` var cfg VictorOpsConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "missing Routing key in VictorOps config" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } }) t.Run("api_key and api_key_file both defined", func(t *testing.T) { in := ` routing_key: test api_key: xyz api_key_file: /global_file ` var cfg VictorOpsConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "at most one of api_key & api_key_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } }) } func TestVictorOpsCustomFieldsValidation(t *testing.T) { in := ` routing_key: 'test' custom_fields: entity_state: 'state_message' ` var cfg VictorOpsConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "victorOps config contains custom field entity_state which cannot be used as it conflicts with the fixed/static fields" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } in = ` routing_key: 'test' custom_fields: my_special_field: 'special_label' ` err = yaml.UnmarshalStrict([]byte(in), &cfg) expected = "special_label" if err != nil { t.Fatalf("Unexpected error returned, got:\n%v", err.Error()) } val, ok := cfg.CustomFields["my_special_field"] if !ok { t.Fatalf("Expected Custom Field to have value %v set, field is empty", expected) } if val != expected { t.Errorf("\nexpected custom field my_special_field value:\n%v\ngot:\n%v", expected, val) } } func TestPushoverUserKeyIsPresent(t *testing.T) { in := ` user_key: '' ` var cfg PushoverConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "one of user_key or user_key_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestPushoverUserKeyOrUserKeyFile(t *testing.T) { in := ` user_key: 'user key' user_key_file: /pushover/user_key ` var cfg PushoverConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "at most one of user_key & user_key_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestPushoverTokenIsPresent(t *testing.T) { in := ` user_key: '' token: '' ` var cfg PushoverConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "one of token or token_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestPushoverTokenOrTokenFile(t *testing.T) { in := ` token: 'pushover token' token_file: /pushover/token user_key: 'user key' ` var cfg PushoverConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "at most one of token & token_file must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestPushoverHTMLOrMonospace(t *testing.T) { in := ` token: 'pushover token' user_key: 'user key' html: true monospace: true ` var cfg PushoverConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) expected := "at most one of monospace & html must be configured" if err == nil { t.Fatalf("no error returned, expected:\n%v", expected) } if err.Error() != expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, err.Error()) } } func TestLoadSlackConfiguration(t *testing.T) { tests := []struct { in string expected SlackConfig }{ { in: ` color: green username: mark channel: engineering title_link: http://example.com/ image_url: https://example.com/logo.png `, expected: SlackConfig{ Color: "green", Username: "mark", Channel: "engineering", TitleLink: "http://example.com/", ImageURL: "https://example.com/logo.png", }, }, { in: ` color: green username: mark channel: alerts title_link: http://example.com/alert1 mrkdwn_in: - pretext - text `, expected: SlackConfig{ Color: "green", Username: "mark", Channel: "alerts", MrkdwnIn: []string{"pretext", "text"}, TitleLink: "http://example.com/alert1", }, }, } for _, rt := range tests { var cfg SlackConfig err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) if err != nil { t.Fatalf("\nerror returned when none expected, error:\n%v", err) } if rt.expected.Color != cfg.Color { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Color, cfg.Color) } if rt.expected.Username != cfg.Username { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Username, cfg.Username) } if rt.expected.Channel != cfg.Channel { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.Channel, cfg.Channel) } if rt.expected.ThumbURL != cfg.ThumbURL { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.ThumbURL, cfg.ThumbURL) } if rt.expected.TitleLink != cfg.TitleLink { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.TitleLink, cfg.TitleLink) } if rt.expected.ImageURL != cfg.ImageURL { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.ImageURL, cfg.ImageURL) } if len(rt.expected.MrkdwnIn) != len(cfg.MrkdwnIn) { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected.MrkdwnIn, cfg.MrkdwnIn) } for i := range cfg.MrkdwnIn { if rt.expected.MrkdwnIn[i] != cfg.MrkdwnIn[i] { t.Errorf("\nexpected:\n%v\ngot:\n%v\nat index %v", rt.expected.MrkdwnIn[i], cfg.MrkdwnIn[i], i) } } } } func TestSlackAuthMethodConfigValidation(t *testing.T) { tests := []struct { in string expectedErr string }{ { in: ` api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' api_url_file: /slack_url `, expectedErr: "at most one of api_url & api_url_file must be configured", }, { in: ` app_token: 'xoxb-1234-abcdefgh' app_token_file: /slack_app_token `, expectedErr: "at most one of app_token & app_token_file must be configured", }, { in: ` app_token: 'xoxb-1234-abcdefgh' api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' `, expectedErr: "at most one of api_url/api_url_file & app_token/app_token_file must be configured", }, } for _, rt := range tests { var cfg SlackConfig err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) // Check if an error occurred when it was NOT expected to. if rt.expectedErr == "" && err != nil { t.Fatalf("\nerror returned when none expected, error:\n%v", err) } // Check that an error occurred if one was expected to. if rt.expectedErr != "" && err == nil { t.Fatalf("\nno error returned, expected:\n%v", rt.expectedErr) } // Check that the error that occurred was what was expected. if err != nil && err.Error() != rt.expectedErr { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expectedErr, err.Error()) } } } func TestSlackFieldConfigValidation(t *testing.T) { tests := []struct { in string expected string }{ { in: ` fields: - title: first value: hello - title: second `, expected: "missing value in Slack field configuration", }, { in: ` fields: - title: first value: hello short: true - value: world short: true `, expected: "missing title in Slack field configuration", }, { in: ` fields: - title: first value: hello short: true - title: second value: world `, expected: "", }, } for _, rt := range tests { var cfg SlackConfig err := yaml.UnmarshalStrict([]byte(rt.in), &cfg) // Check if an error occurred when it was NOT expected to. if rt.expected == "" && err != nil { t.Fatalf("\nerror returned when none expected, error:\n%v", err) } // Check that an error occurred if one was expected to. if rt.expected != "" && err == nil { t.Fatalf("\nno error returned, expected:\n%v", rt.expected) } // Check that the error that occurred was what was expected. if err != nil && err.Error() != rt.expected { t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expected, err.Error()) } } } func TestSlackFieldConfigUnmarshaling(t *testing.T) { in := ` fields: - title: first value: hello short: true - title: second value: world - title: third value: slack field test short: false ` expected := []*SlackField{ { Title: "first", Value: "hello", Short: newBoolPointer(true), }, { Title: "second", Value: "world", Short: nil, }, { Title: "third", Value: "slack field test", Short: newBoolPointer(false), }, } var cfg SlackConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) if err != nil { t.Fatalf("\nerror returned when none expected, error:\n%v", err) } for index, field := range cfg.Fields { exp := expected[index] if field.Title != exp.Title { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Title, field.Title) } if field.Value != exp.Value { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Value, field.Value) } if exp.Short == nil && field.Short != nil { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Short, *field.Short) } if exp.Short != nil && field.Short == nil { t.Errorf("\nexpected:\n%v\ngot:\n%v", *exp.Short, field.Short) } if exp.Short != nil && *exp.Short != *field.Short { t.Errorf("\nexpected:\n%v\ngot:\n%v", *exp.Short, *field.Short) } } } func TestSlackActionsValidation(t *testing.T) { in := ` actions: - type: button text: hello url: https://localhost style: danger - type: button text: hello name: something style: default confirm: title: please confirm text: are you sure? ok_text: yes dismiss_text: no ` expected := []*SlackAction{ { Type: "button", Text: "hello", URL: "https://localhost", Style: "danger", }, { Type: "button", Text: "hello", Name: "something", Style: "default", ConfirmField: &SlackConfirmationField{ Title: "please confirm", Text: "are you sure?", OkText: "yes", DismissText: "no", }, }, } var cfg SlackConfig err := yaml.UnmarshalStrict([]byte(in), &cfg) if err != nil { t.Fatalf("\nerror returned when none expected, error:\n%v", err) } for index, action := range cfg.Actions { exp := expected[index] if action.Type != exp.Type { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Type, action.Type) } if action.Text != exp.Text { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Text, action.Text) } if action.URL != exp.URL { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.URL, action.URL) } if action.Style != exp.Style { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Style, action.Style) } if action.Name != exp.Name { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Name, action.Name) } if action.Value != exp.Value { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.Value, action.Value) } if action.ConfirmField != nil && exp.ConfirmField == nil || action.ConfirmField == nil && exp.ConfirmField != nil { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField, action.ConfirmField) } else if action.ConfirmField != nil && exp.ConfirmField != nil { if action.ConfirmField.Title != exp.ConfirmField.Title { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.Title, action.ConfirmField.Title) } if action.ConfirmField.Text != exp.ConfirmField.Text { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.Text, action.ConfirmField.Text) } if action.ConfirmField.OkText != exp.ConfirmField.OkText { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.OkText, action.ConfirmField.OkText) } if action.ConfirmField.DismissText != exp.ConfirmField.DismissText { t.Errorf("\nexpected:\n%v\ngot:\n%v", exp.ConfirmField.DismissText, action.ConfirmField.DismissText) } } } } func TestOpsgenieTypeMatcher(t *testing.T) { good := []string{"team", "user", "escalation", "schedule"} for _, g := range good { if !opsgenieTypeMatcher.MatchString(g) { t.Fatalf("failed to match with %s", g) } } bad := []string{"0user", "team1", "2escalation3", "sche4dule", "User", "TEAM"} for _, b := range bad { if opsgenieTypeMatcher.MatchString(b) { t.Errorf("mistakenly match with %s", b) } } } func TestOpsGenieConfiguration(t *testing.T) { for _, tc := range []struct { name string in string err bool }{ { name: "valid configuration", in: `api_key: xyz responders: - id: foo type: scheDule - name: bar type: teams - username: fred type: USER api_url: http://example.com `, }, { name: "api_key and api_key_file both defined", in: `api_key: xyz api_key_file: xyz api_url: http://example.com `, err: true, }, { name: "invalid responder type", in: `api_key: xyz responders: - id: foo type: wrong api_url: http://example.com `, err: true, }, { name: "missing responder field", in: `api_key: xyz responders: - type: schedule api_url: http://example.com `, err: true, }, { name: "valid responder type template", in: `api_key: xyz responders: - id: foo type: "{{/* valid comment */}}team" api_url: http://example.com `, }, { name: "invalid responder type template", in: `api_key: xyz responders: - id: foo type: "{{/* invalid comment }}team" api_url: http://example.com `, err: true, }, } { t.Run(tc.name, func(t *testing.T) { var cfg OpsGenieConfig err := yaml.UnmarshalStrict([]byte(tc.in), &cfg) if tc.err { if err == nil { t.Fatalf("expected error but got none") } return } if err != nil { t.Errorf("expected no error, got %v", err) } }) } } func TestSNS(t *testing.T) { for _, tc := range []struct { in string err bool }{ { // Valid configuration without sigv4. in: `target_arn: target`, err: false, }, { // Valid configuration without sigv4. in: `topic_arn: topic`, err: false, }, { // Valid configuration with sigv4. in: `phone_number: phone sigv4: access_key: abc secret_key: abc `, err: false, }, { // at most one of 'target_arn', 'topic_arn' or 'phone_number' must be provided without sigv4. in: `topic_arn: topic target_arn: target `, err: true, }, { // at most one of 'target_arn', 'topic_arn' or 'phone_number' must be provided without sigv4. in: `topic_arn: topic phone_number: phone `, err: true, }, { // one of 'target_arn', 'topic_arn' or 'phone_number' must be provided without sigv4. in: "{}", err: true, }, { // one of 'target_arn', 'topic_arn' or 'phone_number' must be provided with sigv4. in: `sigv4: access_key: abc secret_key: abc `, err: true, }, { // 'secret_key' must be provided with 'access_key'. in: `topic_arn: topic sigv4: access_key: abc `, err: true, }, { // 'access_key' must be provided with 'secret_key'. in: `topic_arn: topic sigv4: secret_key: abc `, err: true, }, } { t.Run("", func(t *testing.T) { var cfg SNSConfig err := yaml.UnmarshalStrict([]byte(tc.in), &cfg) if err != nil { if !tc.err { t.Errorf("expecting no error, got %q", err) } return } if tc.err { t.Logf("%#v", cfg) t.Error("expecting error, got none") } }) } } func TestWeChatTypeMatcher(t *testing.T) { good := []string{"text", "markdown"} for _, g := range good { if !wechatTypeMatcher.MatchString(g) { t.Fatalf("failed to match with %s", g) } } bad := []string{"TEXT", "MarkDOwn"} for _, b := range bad { if wechatTypeMatcher.MatchString(b) { t.Errorf("mistakenly match with %s", b) } } } func TestWebexConfiguration(t *testing.T) { tc := []struct { name string in string expected error }{ { name: "with no room_id - it fails", in: ` message: xyz123 `, expected: errors.New("missing room_id on webex_config"), }, { name: "with room_id and http_config.authorization set - it succeeds", in: ` room_id: 2 http_config: authorization: credentials: "xxxyyyzz" `, }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { var cfg WebexConfig err := yaml.UnmarshalStrict([]byte(tt.in), &cfg) require.Equal(t, tt.expected, err) }) } } func TestTelegramConfiguration(t *testing.T) { tc := []struct { name string in string expected error }{ { name: "with both bot_token & bot_token_file - it fails", in: ` bot_token: xyz bot_token_file: /file `, expected: errors.New("at most one of bot_token & bot_token_file must be configured"), }, { name: "with bot_token and chat_id set - it succeeds", in: ` bot_token: xyz chat_id: 123 `, }, { name: "with bot_token, chat_id and message_thread_id set - it succeeds", in: ` bot_token: xyz chat_id: 123 message_thread_id: 456 `, }, { name: "with bot_token_file and chat_id set - it succeeds", in: ` bot_token_file: /file chat_id: 123 `, }, { name: "with bot_token_file and chat_id_file set - it succeeds", in: ` bot_token_file: /file chat_id_file: /chat_id_file `, }, { name: "with no chat_id set - it fails", in: ` bot_token: xyz `, expected: errors.New("missing chat_id or chat_id_file on telegram_config"), }, { name: "with both chat_id and chat_id_file - it fails", in: ` bot_token: xyz chat_id: 123 chat_id_file: /file `, expected: errors.New("at most one of chat_id & chat_id_file must be configured"), }, { name: "with unknown parse_mode - it fails", in: ` bot_token: xyz chat_id: 123 parse_mode: invalid `, expected: errors.New("unknown parse_mode on telegram_config, must be Markdown, MarkdownV2, HTML or empty string"), }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { var cfg TelegramConfig err := yaml.UnmarshalStrict([]byte(tt.in), &cfg) require.Equal(t, tt.expected, err) }) } } func newBoolPointer(b bool) *bool { return &b } func TestMattermostField_UnmarshalYAML(t *testing.T) { mf := []struct { name string in string expected error }{ { name: "with title, value and short - it succeeds", in: ` title: some title value: some value short: true `, }, { name: "with title and value - it succeeds", in: ` title: some title value: some value `, }, { name: "with no value - it fails", in: ` title: some title `, expected: errors.New("missing value in Mattermost field configuration"), }, { name: "with no title - it fails", in: ` value: some value `, expected: errors.New("missing title in Mattermost field configuration"), }, } for _, tt := range mf { t.Run(tt.name, func(t *testing.T) { var cfg MattermostField err := yaml.UnmarshalStrict([]byte(tt.in), &cfg) require.Equal(t, tt.expected, err) }) } } func TestMattermostConfig_UnmarshalYAML(t *testing.T) { mc := []struct { name string in string expected error }{ { name: "with url and text - it succeeds", in: ` webhook_url: http://some.url channel: some_channel username: some_username text: some text `, }, { name: "with url_file, attachments and props - it succeeds", in: ` webhook_url_file: /some/url.file channel: some_channel username: some_username attachments: - text: some text props: card: some text `, }, { name: "with url and url_file - it fails", in: ` webhook_url: http://some.url webhook_url_file: /some/url.file channel: some_channel username: some_username attachments: - text: some text `, expected: errors.New("at most one of webhook_url & webhook_url_file must be configured"), }, { name: "with text and attachments - it succeeds", in: ` webhook_url: http://some.url channel: some_channel username: some_username text: some text attachments: - text: some text `, }, } for _, tt := range mc { t.Run(tt.name, func(t *testing.T) { var cfg MattermostConfig err := yaml.UnmarshalStrict([]byte(tt.in), &cfg) require.Equal(t, tt.expected, err) }) } } func TestEmailConfig_UnmarshalYAML(t *testing.T) { testConfig := []struct { name string in string expected error }{ { name: "with basic config - it succeeds", in: ` to: foobar@example.com headers: {X-Custom-Header: CustomValue} `, }, { name: "with empty to address - it fails", in: ` to: ''`, expected: errors.New("missing to address in email config"), }, { name: "with correct threading - it succeeds", in: ` to: foobar@example.com threading: enabled: true thread_by_date: daily `, }, { name: "with invalid threading - it fails", in: ` to: foobar@example.com threading: enabled: true thread_by_date: weekly `, expected: errors.New("threading.thread_by_date must be either 'none' or 'daily'"), }, { name: "with duplicate headers - it failes", in: ` to: foobar@example.com headers: {X-Custom-Header: CustomValue, X-CUSTOM-HEADER: AnotherValue} `, expected: errors.New("duplicate header \"X-Custom-Header\" in email config"), }, } for _, tt := range testConfig { t.Run(tt.name, func(t *testing.T) { var cfg EmailConfig err := yaml.UnmarshalStrict([]byte(tt.in), &cfg) require.Equal(t, tt.expected, err) }) } } ================================================ FILE: config/receiver/receiver.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package receiver import ( "errors" "log/slog" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/promslog" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/discord" "github.com/prometheus/alertmanager/notify/email" "github.com/prometheus/alertmanager/notify/incidentio" "github.com/prometheus/alertmanager/notify/jira" "github.com/prometheus/alertmanager/notify/mattermost" "github.com/prometheus/alertmanager/notify/msteams" "github.com/prometheus/alertmanager/notify/msteamsv2" "github.com/prometheus/alertmanager/notify/opsgenie" "github.com/prometheus/alertmanager/notify/pagerduty" "github.com/prometheus/alertmanager/notify/pushover" "github.com/prometheus/alertmanager/notify/rocketchat" "github.com/prometheus/alertmanager/notify/slack" "github.com/prometheus/alertmanager/notify/sns" "github.com/prometheus/alertmanager/notify/telegram" "github.com/prometheus/alertmanager/notify/victorops" "github.com/prometheus/alertmanager/notify/webex" "github.com/prometheus/alertmanager/notify/webhook" "github.com/prometheus/alertmanager/notify/wechat" "github.com/prometheus/alertmanager/template" ) // BuildReceiverIntegrations builds a list of integration notifiers off of a // receiver config. func BuildReceiverIntegrations(nc config.Receiver, tmpl *template.Template, logger *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) ([]notify.Integration, error) { if logger == nil { logger = promslog.NewNopLogger() } var ( errs error integrations []notify.Integration add = func(name string, i int, rs notify.ResolvedSender, f func(l *slog.Logger) (notify.Notifier, error)) { n, err := f(logger.With("integration", name)) if err != nil { errs = errors.Join(errs, err) return } integrations = append(integrations, notify.NewIntegration(n, rs, name, i, nc.Name)) } ) for i, c := range nc.WebhookConfigs { add("webhook", i, c, func(l *slog.Logger) (notify.Notifier, error) { return webhook.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.EmailConfigs { add("email", i, c, func(l *slog.Logger) (notify.Notifier, error) { return email.New(c, tmpl, l), nil }) } for i, c := range nc.PagerdutyConfigs { add("pagerduty", i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.OpsGenieConfigs { add("opsgenie", i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.WechatConfigs { add("wechat", i, c, func(l *slog.Logger) (notify.Notifier, error) { return wechat.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.SlackConfigs { add("slack", i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.VictorOpsConfigs { add("victorops", i, c, func(l *slog.Logger) (notify.Notifier, error) { return victorops.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.PushoverConfigs { add("pushover", i, c, func(l *slog.Logger) (notify.Notifier, error) { return pushover.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.SNSConfigs { add("sns", i, c, func(l *slog.Logger) (notify.Notifier, error) { return sns.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.TelegramConfigs { add("telegram", i, c, func(l *slog.Logger) (notify.Notifier, error) { return telegram.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.DiscordConfigs { add("discord", i, c, func(l *slog.Logger) (notify.Notifier, error) { return discord.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.WebexConfigs { add("webex", i, c, func(l *slog.Logger) (notify.Notifier, error) { return webex.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.MSTeamsConfigs { add("msteams", i, c, func(l *slog.Logger) (notify.Notifier, error) { return msteams.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.MSTeamsV2Configs { add("msteamsv2", i, c, func(l *slog.Logger) (notify.Notifier, error) { return msteamsv2.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.JiraConfigs { add("jira", i, c, func(l *slog.Logger) (notify.Notifier, error) { return jira.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.IncidentioConfigs { add("incidentio", i, c, func(l *slog.Logger) (notify.Notifier, error) { return incidentio.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.RocketchatConfigs { add("rocketchat", i, c, func(l *slog.Logger) (notify.Notifier, error) { return rocketchat.New(c, tmpl, l, httpOpts...) }) } for i, c := range nc.MattermostConfigs { add("mattermost", i, c, func(l *slog.Logger) (notify.Notifier, error) { return mattermost.New(c, tmpl, l, httpOpts...) }) } return integrations, errs } ================================================ FILE: config/receiver/receiver_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package receiver import ( "testing" commoncfg "github.com/prometheus/common/config" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" ) type sendResolved bool func (s sendResolved) SendResolved() bool { return bool(s) } func TestBuildReceiverIntegrations(t *testing.T) { for _, tc := range []struct { receiver config.Receiver err bool exp []notify.Integration }{ { receiver: config.Receiver{ Name: "foo", WebhookConfigs: []*config.WebhookConfig{ { HTTPConfig: &commoncfg.HTTPClientConfig{}, }, { HTTPConfig: &commoncfg.HTTPClientConfig{}, NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, }, }, }, exp: []notify.Integration{ notify.NewIntegration(nil, sendResolved(false), "webhook", 0, "foo"), notify.NewIntegration(nil, sendResolved(true), "webhook", 1, "foo"), }, }, { receiver: config.Receiver{ Name: "foo", WebhookConfigs: []*config.WebhookConfig{ { HTTPConfig: &commoncfg.HTTPClientConfig{ TLSConfig: commoncfg.TLSConfig{ CAFile: "not_existing", }, }, }, }, }, err: true, }, } { t.Run("", func(t *testing.T) { integrations, err := BuildReceiverIntegrations(tc.receiver, nil, nil) if tc.err { require.Error(t, err) return } require.NoError(t, err) require.Len(t, integrations, len(tc.exp)) for i := range tc.exp { require.Equal(t, tc.exp[i].SendResolved(), integrations[i].SendResolved()) require.Equal(t, tc.exp[i].Name(), integrations[i].Name()) require.Equal(t, tc.exp[i].Index(), integrations[i].Index()) } }) } } ================================================ FILE: config/testdata/conf.empty-fields.yml ================================================ global: smtp_smarthost: 'localhost:25' smtp_from: 'alertmanager@example.org' smtp_auth_username: '' smtp_auth_password: '' smtp_hello: '' slack_api_url: 'https://slack.com/webhook' templates: - '/etc/alertmanager/template/*.tmpl' route: group_by: ['alertname', 'cluster', 'service'] receiver: team-X-mails routes: - match_re: service: ^(foo1|foo2|baz)$ receiver: team-X-mails receivers: - name: 'team-X-mails' email_configs: - to: 'team-X+alerts@example.org' ================================================ FILE: config/testdata/conf.good.yml ================================================ global: # The smarthost and SMTP sender used for mail notifications. smtp_smarthost: 'localhost:25' smtp_from: 'alertmanager@example.org' smtp_auth_username: 'alertmanager' smtp_auth_password: "multiline\nmysecret" smtp_hello: "host.example.org" slack_api_url: "http://mysecret.example.com/" http_config: proxy_url: 'http://127.0.0.1:1025' # The directory from which notification templates are read. templates: - '/etc/alertmanager/template/*.tmpl' # The root route on which each incoming alert enters. route: # The labels by which incoming alerts are grouped together. For example, # multiple alerts coming in for cluster=A and alertname=LatencyHigh would # be batched into a single group. group_by: ['alertname', 'cluster', 'service'] # When a new group of alerts is created by an incoming alert, wait at # least 'group_wait' to send the initial notification. # This way ensures that you get multiple alerts for the same group that start # firing shortly after another are batched together on the first # notification. group_wait: 30s # When the first notification was sent, wait 'group_interval' to send a batch # of new alerts that started firing for that group. group_interval: 5m # If an alert has successfully been sent, wait 'repeat_interval' to # resend them. repeat_interval: 3h # A default receiver receiver: team-X-mails # All the above attributes are inherited by all child routes and can # overwritten on each. # The child route trees. routes: # This routes performs a regular expression match on alert labels to # catch alerts that are related to a list of services. - match_re: service: ^(foo1|foo2|baz)$ receiver: team-X-mails # The service has a sub-route for critical alerts, any alerts # that do not match, i.e. severity != critical, fall-back to the # parent node and are sent to 'team-X-mails' routes: - match: severity: critical receiver: team-X-pager - match: service: files receiver: team-Y-mails routes: - match: severity: critical receiver: team-Y-pager # This route handles all alerts coming from a database service. If there's # no team to handle it, it defaults to the DB team. - match: service: database receiver: team-DB-pager # Also group alerts by affected database. group_by: [alertname, cluster, database] routes: - match: owner2: team-X receiver: team-X-pager continue: true - match: owner: team-Y receiver: team-Y-pager # continue: true # Inhibition rules allow to mute a set of alerts given that another alert is # firing. # We use this to mute any warning-level notifications if the same alert is # already critical. inhibit_rules: - source_match: severity: 'critical' target_match: severity: 'warning' # Apply inhibition if the alertname is the same. equal: ['alertname', 'cluster', 'service'] receivers: - name: 'team-X-mails' email_configs: - to: 'team-X+alerts@example.org' - name: 'team-X-pager' email_configs: - to: 'team-X+alerts-critical@example.org' pagerduty_configs: - routing_key: "mysecret" - name: 'team-Y-mails' email_configs: - to: 'team-Y+alerts@example.org' - name: 'team-Y-pager' pagerduty_configs: - routing_key: "mysecret" - name: 'team-DB-pager' pagerduty_configs: - routing_key: "mysecret" - name: victorOps-receiver victorops_configs: - api_key: mysecret routing_key: Sample_route - name: opsGenie-receiver opsgenie_configs: - api_key: mysecret - name: pushover-receiver pushover_configs: - token: mysecret user_key: key - name: slack-receiver slack_configs: - channel: '#my-channel' image_url: 'http://some.img.com/img.png' ================================================ FILE: config/testdata/conf.group-by-all.yml ================================================ route: group_by: ['...'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-X receivers: - name: 'team-X' ================================================ FILE: config/testdata/conf.http-config.good.yml ================================================ global: slack_api_url: 'https://slack.com/webhook' http_config: follow_redirects: false route: receiver: team-X-slack receivers: - name: 'team-X-slack' slack_configs: - http_config: proxy_url: foo ================================================ FILE: config/testdata/conf.mattermost-both-webhook-url-and-file.yml ================================================ global: mattermost_webhook_url: https://mattermost.example.com/hooks/xxxxxxxxxxxxxxxxxxxxxxxxxx mattermost_webhook_url_file: /global_file route: receiver: team-X-mattermost receivers: - name: team-X-mattermost mattermost_configs: - channel: team-X - name: team-Y-mattermost mattermost_configs: - channel: team-Y webhook_url: https://fakemattermost.example.com/hooks/xxxxxxxxxxxxxxxxxxxxxxxxxx ================================================ FILE: config/testdata/conf.mattermost-default-webhook-url-file.yml ================================================ global: mattermost_webhook_url_file: /global_file route: receiver: team-X-mattermost receivers: - name: team-X-mattermost mattermost_configs: - channel: team-X - name: team-Y-mattermost mattermost_configs: - channel: team-Y webhook_url_file: /override_file ================================================ FILE: config/testdata/conf.mattermost-default-webhook-url.yml ================================================ global: mattermost_webhook_url: https://mattermost.example.com/hooks/xxxxxxxxxxxxxxxxxxxxxxxxxx route: receiver: team-X-mattermost receivers: - name: team-X-mattermost mattermost_configs: - channel: team-X - name: team-Y-mattermost mattermost_configs: - channel: team-Y webhook_url: https://fakemattermost.example.com/hooks/xxxxxxxxxxxxxxxxxxxxxxxxxx ================================================ FILE: config/testdata/conf.mattermost-no-webhook-url.yml ================================================ route: receiver: team-X-mattermost receivers: - name: team-X-mattermost mattermost_configs: - channel: team-X ================================================ FILE: config/testdata/conf.mattermost-valid-receiver-both-webhook-url-and-file.yml ================================================ global: mattermost_webhook_url: https://mattermost.example.com/hooks/xxxxxxxxxxxxxxxxxxxxxxxxxx mattermost_webhook_url_file: /global_file route: receiver: team-X-mattermost receivers: - name: team-X-mattermost mattermost_configs: - channel: team-X webhook_url_file: /override_file ================================================ FILE: config/testdata/conf.nil-match_re-route.yml ================================================ route: receiver: empty routes: - match_re: invalid_label: receiver: empty receivers: - name: empty ================================================ FILE: config/testdata/conf.nil-source_match_re-inhibition.yml ================================================ route: receiver: empty receivers: - name: empty inhibit_rules: - source_match_re: invalid_source_label: target_match_re: severity: critical ================================================ FILE: config/testdata/conf.nil-target_match_re-inhibition.yml ================================================ route: receiver: empty receivers: - name: empty inhibit_rules: - source_match: severity: critical target_match_re: invalid_target_label: ================================================ FILE: config/testdata/conf.opsgenie-both-file-and-apikey.yml ================================================ global: opsgenie_api_key: asd132 opsgenie_api_key_file: '/global_file' route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: escalation-Y-opsgenie routes: - match: service: foo receiver: team-X-opsgenie receivers: - name: 'team-X-opsgenie' opsgenie_configs: - responders: - name: 'team-X' type: 'team' - name: 'escalation-Y-opsgenie' opsgenie_configs: - responders: - name: 'escalation-Y' type: 'escalation' api_key: qwe456 ================================================ FILE: config/testdata/conf.opsgenie-default-apikey-file.yml ================================================ global: opsgenie_api_key_file: '/global_file' route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: escalation-Y-opsgenie routes: - match: service: foo receiver: team-X-opsgenie receivers: - name: 'team-X-opsgenie' opsgenie_configs: - responders: - name: 'team-X' type: 'team' - name: 'escalation-Y-opsgenie' opsgenie_configs: - responders: - name: 'escalation-Y' type: 'escalation' api_key_file: /override_file ================================================ FILE: config/testdata/conf.opsgenie-default-apikey-old-team.yml ================================================ global: opsgenie_api_key: asd132 route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: escalation-Y-opsgenie routes: - match: service: foo receiver: team-X-opsgenie receivers: - name: 'team-X-opsgenie' opsgenie_configs: - teams: 'team-X' ================================================ FILE: config/testdata/conf.opsgenie-default-apikey.yml ================================================ global: opsgenie_api_key: asd132 route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: escalation-Y-opsgenie routes: - match: service: foo receiver: team-X-opsgenie receivers: - name: 'team-X-opsgenie' opsgenie_configs: - responders: - name: 'team-X' type: 'team' - name: 'escalation-Y-opsgenie' opsgenie_configs: - responders: - name: 'escalation-Y' type: 'escalation' api_key: qwe456 ================================================ FILE: config/testdata/conf.opsgenie-no-apikey.yml ================================================ route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-X-opsgenie routes: - match: service: foo receiver: team-X-opsgenie receivers: - name: 'team-X-opsgenie' opsgenie_configs: - responders: - name: 'team-X' type: 'team' ================================================ FILE: config/testdata/conf.rocketchat-both-token-and-tokenfile.yml ================================================ global: rocketchat_token_file: /global_file rocketchat_token: token123 route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-Y-rocketchat routes: - match: service: foo receiver: team-X-rocketchat receivers: - name: 'team-X-rocketchat' rocketchat_configs: - channel: '#team-X' - name: 'team-Y-rocketchat' rocketchat_configs: - channel: '#team-Y' ================================================ FILE: config/testdata/conf.rocketchat-both-tokenid-and-tokenidfile.yml ================================================ global: rocketchat_token_id_file: /global_file rocketchat_token_id: id123 route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-Y-rocketchat routes: - match: service: foo receiver: team-X-rocketchat receivers: - name: 'team-X-rocketchat' rocketchat_configs: - channel: '#team-X' - name: 'team-Y-rocketchat' rocketchat_configs: - channel: '#team-Y' ================================================ FILE: config/testdata/conf.rocketchat-default-token-file.yml ================================================ global: rocketchat_token_file: /global_file rocketchat_token_id_file: /etc/alertmanager/rocketchat_token_id route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-Y-rocketchat routes: - match: service: foo receiver: team-X-rocketchat receivers: - name: 'team-X-rocketchat' rocketchat_configs: - channel: '#team-X' - name: 'team-Y-rocketchat' rocketchat_configs: - channel: '#team-Y' token_file: /override_file token_id_file: /override_file ================================================ FILE: config/testdata/conf.rocketchat-default-token.yml ================================================ global: rocketchat_token: token123 rocketchat_token_id: id123 route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-Y-rocketchat routes: - match: service: foo receiver: team-X-rocketchat receivers: - name: 'team-X-rocketchat' rocketchat_configs: - channel: '#team-X' - name: 'team-Y-rocketchat' rocketchat_configs: - channel: '#team-Y' token: token456 token_id: id456 ================================================ FILE: config/testdata/conf.rocketchat-no-token.yml ================================================ global: rocketchat_token_id: id123 route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-Y-rocketchat routes: - match: service: foo receiver: team-X-rocketchat receivers: - name: 'team-X-rocketchat' rocketchat_configs: - channel: '#team-X' - name: 'team-Y-rocketchat' rocketchat_configs: - channel: '#team-Y' ================================================ FILE: config/testdata/conf.slack-both-file-and-token.yml ================================================ global: slack_app_token: 'xoxb-1234-abcdefgh' slack_app_token_file: '/global_file' route: receiver: 'slack-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'slack-notifications' slack_configs: - channel: '#alerts1' text: 'test' ================================================ FILE: config/testdata/conf.slack-both-file-and-url.yml ================================================ global: slack_api_url: "http://mysecret.example.com/" slack_api_url_file: '/global_file' route: receiver: 'slack-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'slack-notifications' slack_configs: - channel: '#alerts1' text: 'test' ================================================ FILE: config/testdata/conf.slack-both-url-and-token.yml ================================================ global: slack_app_token: 'xoxb-1234-abcdefgh' slack_api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' route: receiver: 'slack-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'slack-notifications' slack_configs: - channel: '#alerts1' text: 'test' ================================================ FILE: config/testdata/conf.slack-default-api-url-file.yml ================================================ global: slack_api_url_file: '/global_file' route: receiver: 'slack-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'slack-notifications' slack_configs: # Use global - channel: '#alerts1' text: 'test' # Override global with other file - channel: '#alerts2' text: 'test' api_url_file: '/override_file' # Override global with inline URL - channel: '#alerts3' text: 'test' api_url: 'http://mysecret.example.com/' ================================================ FILE: config/testdata/conf.slack-default-app-token.yml ================================================ global: slack_app_token: 'xoxb-1234-abcdefgh' # old workaround to use bot tokens slack_api_url: 'https://slack.com/api/chat.postMessage' route: receiver: 'slack-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'slack-notifications' slack_configs: # use global - channel: '#alerts1' text: 'test' # use override - channel: '#alerts2' text: 'test' app_token: 'xoxb-1234-xxxxxx' # use custom app url - channel: '#alerts3' text: 'test' app_url: http://api.fakeslack.example/ # use workaround to configure bot token - channel: '#alerts4' text: 'test' http_config: authorization: credentials: 'xoxb-my-bot-token' - api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' send_resolved: true ================================================ FILE: config/testdata/conf.slack-no-api-url-or-token.yml ================================================ route: receiver: 'slack-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'slack-notifications' slack_configs: - channel: '#alerts' text: 'test' ================================================ FILE: config/testdata/conf.slack-update-message-and-webhook.yml ================================================ route: receiver: 'slack-notifications' group_by: [alertname] receivers: - name: 'slack-notifications' slack_configs: # use global - channel: '#alerts1' text: 'test' send_resolved: true # trying to use webhook urls with update_message api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' update_message: true ================================================ FILE: config/testdata/conf.smtp-both-password-and-file.yml ================================================ global: smtp_smarthost: 'localhost:25' smtp_from: 'alertmanager@example.org' smtp_auth_username: 'alertmanager' smtp_auth_password: "multiline\nmysecret" smtp_auth_password_file: "/tmp/global" smtp_hello: "host.example.org" route: receiver: 'email-notifications' receivers: - name: 'email-notifications' email_configs: - to: 'one@example.org' ================================================ FILE: config/testdata/conf.smtp-no-username-or-password.yml ================================================ global: smtp_smarthost: 'localhost:25' smtp_from: 'alertmanager@example.org' smtp_hello: "host.example.org" route: receiver: 'email-notifications' receivers: - name: 'email-notifications' email_configs: - to: 'one@example.org' ================================================ FILE: config/testdata/conf.smtp-password-global-and-local.yml ================================================ global: smtp_smarthost: 'localhost:25' smtp_from: 'alertmanager@example.org' smtp_auth_username: 'globaluser' smtp_auth_password_file: '/tmp/globaluserpassword' smtp_hello: "host.example.org" route: receiver: 'email-notifications' receivers: - name: 'email-notifications' email_configs: # Use global - to: 'one@example.org' # Override global with other file - to: 'two@example.org' auth_username: 'localuser1' auth_password_file: '/tmp/localuser1password' # Override global with inline password - to: 'three@example.org' auth_username: 'localuser2' auth_password: 'mysecret' # Test auth secret - to: 'four@exmaple.org' auth_username: 'localuser3' auth_secret: 'myprecious' # Test auth secret from file - to: 'five@exmaple.org' auth_username: 'localuser4' auth_secret_file: '/tmp/localuser4secret' ================================================ FILE: config/testdata/conf.sns-invalid.yml ================================================ route: receiver: 'sns-api-notifications' group_by: [alertname] receivers: - name: 'sns-api-notifications' sns_configs: - api_url: https://sns.us-east-2.amazonaws.com sigv4: region: us-east-2 access_key: access_key secret_key: secret_ket attributes: severity: Sev2 ================================================ FILE: config/testdata/conf.sns-topic-arn.yml ================================================ route: receiver: 'sns-api-notifications' group_by: [alertname] receivers: - name: 'sns-api-notifications' sns_configs: - api_url: https://sns.us-east-2.amazonaws.com topic_arn: arn:aws:sns:us-east-2:123456789012:My-Topic sigv4: region: us-east-2 access_key: access_key secret_key: secret_ket attributes: severity: Sev2 ================================================ FILE: config/testdata/conf.telegram-both-bot-token-and-file.yml ================================================ global: telegram_bot_token: asd132 telegram_bot_token_file: /global_file route: receiver: team-X-telegram receivers: - name: team-X-telegram telegram_configs: - chat_id: 123 - name: team-Y-telegram telegram_configs: - chat_id: 456 bot_token: qwe456 ================================================ FILE: config/testdata/conf.telegram-default-bot-token-file.yml ================================================ global: telegram_bot_token_file: /global_file route: receiver: team-X-telegram receivers: - name: team-X-telegram telegram_configs: - chat_id: 123 - name: team-Y-telegram telegram_configs: - chat_id: 456 bot_token_file: /override_file ================================================ FILE: config/testdata/conf.telegram-default-bot-token.yml ================================================ global: telegram_bot_token: asd132 route: receiver: team-X-telegram receivers: - name: team-X-telegram telegram_configs: - chat_id: 123 - name: team-Y-telegram telegram_configs: - chat_id: 456 bot_token: qwe456 ================================================ FILE: config/testdata/conf.telegram-no-bot-token.yml ================================================ route: receiver: team-X-telegram receivers: - name: team-X-telegram telegram_configs: - chat_id: 123 ================================================ FILE: config/testdata/conf.telegram-valid-receiver-both-bot-token-and-file.yml ================================================ global: telegram_bot_token: asd132 telegram_bot_token_file: /global_file route: receiver: team-X-telegram receivers: - name: team-X-telegram telegram_configs: - chat_id: 123 bot_token_file: /override_file ================================================ FILE: config/testdata/conf.victorops-both-file-and-apikey.yml ================================================ global: victorops_api_key: asd132 victorops_api_key_file: '/global_file' route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-Y-victorops routes: - match: service: foo receiver: team-X-victorops receivers: - name: 'team-X-victorops' victorops_configs: - routing_key: 'team-X' - name: 'team-Y-victorops' victorops_configs: - routing_key: 'team-Y' api_key: qwe456 ================================================ FILE: config/testdata/conf.victorops-default-apikey-file.yml ================================================ global: victorops_api_key_file: '/global_file' route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-Y-victorops routes: - match: service: foo receiver: team-X-victorops receivers: - name: 'team-X-victorops' victorops_configs: - routing_key: 'team-X' - name: 'team-Y-victorops' victorops_configs: - routing_key: 'team-Y' api_key_file: /override_file ================================================ FILE: config/testdata/conf.victorops-default-apikey.yml ================================================ global: victorops_api_key: asd132 route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-Y-victorops routes: - match: service: foo receiver: team-X-victorops receivers: - name: 'team-X-victorops' victorops_configs: - routing_key: 'team-X' - name: 'team-Y-victorops' victorops_configs: - routing_key: 'team-Y' api_key: qwe456 ================================================ FILE: config/testdata/conf.victorops-no-apikey.yml ================================================ route: group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 3h receiver: team-X-victorops routes: - match: service: foo receiver: team-X-victorops receivers: - name: 'team-X-victorops' victorops_configs: - routing_key: 'team-X' ================================================ FILE: config/testdata/conf.wechat-both-file-and-secret.yml ================================================ global: wechat_api_secret: "http://mysecret.example.com/" wechat_api_secret_file: '/global_file' route: receiver: 'wechat-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'wechat-notifications' wechat_configs: - {} ================================================ FILE: config/testdata/conf.wechat-default-api-secret-file.yml ================================================ global: wechat_api_secret_file: '/global_file' wechat_api_corp_id: 'my_corp_id' route: receiver: 'wechat-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'wechat-notifications' wechat_configs: # Use global - {} # Override global with other file - api_secret_file: '/override_file' # Override global with inline API secret - api_secret: 'my_inline_secret' ================================================ FILE: config/testdata/conf.wechat-no-api-secret.yml ================================================ route: receiver: 'wechat-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'wechat-notifications' wechat_configs: - {} ================================================ FILE: dispatch/dispatch.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dispatch import ( "context" "errors" "fmt" "log/slog" "runtime" "sort" "sync" "sync/atomic" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/common/model" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/provider" "github.com/prometheus/alertmanager/store" "github.com/prometheus/alertmanager/types" ) const ( DispatcherStateUnknown = iota DispatcherStateWaitingToStart DispatcherStateRunning DispatcherStateStopped ) var tracer = otel.Tracer("github.com/prometheus/alertmanager/dispatch") // DispatcherMetrics represents metrics associated to a dispatcher. type DispatcherMetrics struct { aggrGroups prometheus.Gauge processingDuration prometheus.Summary aggrGroupLimitReached prometheus.Counter } // NewDispatcherMetrics returns a new registered DispatchMetrics. func NewDispatcherMetrics(registerLimitMetrics bool, r prometheus.Registerer) *DispatcherMetrics { if r == nil { return nil } m := DispatcherMetrics{ aggrGroups: promauto.With(r).NewGauge( prometheus.GaugeOpts{ Name: "alertmanager_dispatcher_aggregation_groups", Help: "Number of active aggregation groups", }, ), processingDuration: promauto.With(r).NewSummary( prometheus.SummaryOpts{ Name: "alertmanager_dispatcher_alert_processing_duration_seconds", Help: "Summary of latencies for the processing of alerts.", }, ), aggrGroupLimitReached: promauto.With(r).NewCounter( prometheus.CounterOpts{ Name: "alertmanager_dispatcher_aggregation_group_limit_reached_total", Help: "Number of times when dispatcher failed to create new aggregation group due to limit.", }, ), } return &m } // Dispatcher sorts incoming alerts into aggregation groups and // assigns the correct notifiers to each. type Dispatcher struct { route *Route alerts provider.Alerts stage notify.Stage marker types.GroupMarker metrics *DispatcherMetrics limits Limits propagator propagation.TextMapPropagator timeout func(time.Duration) time.Duration loaded chan struct{} finished sync.WaitGroup ctx context.Context cancel func() routeGroupsSlice []routeAggrGroups aggrGroupsNum atomic.Int32 maintenanceInterval time.Duration concurrency int // Number of goroutines for alert ingestion logger *slog.Logger startTimer *time.Timer state atomic.Int32 } // Limits describes limits used by Dispatcher. type Limits interface { // MaxNumberOfAggregationGroups returns max number of aggregation groups that dispatcher can have. // 0 or negative value = unlimited. // If dispatcher hits this limit, it will not create additional groups, but will log an error instead. MaxNumberOfAggregationGroups() int } type routeAggrGroups struct { route *Route groups sync.Map // map[string]*aggrGroup groupsLen atomic.Int64 } // NewDispatcher returns a new Dispatcher. func NewDispatcher( alerts provider.Alerts, route *Route, stage notify.Stage, marker types.GroupMarker, timeout func(time.Duration) time.Duration, maintenanceInterval time.Duration, limits Limits, logger *slog.Logger, metrics *DispatcherMetrics, ) *Dispatcher { if limits == nil { limits = nilLimits{} } // Calculate concurrency for ingestion. concurrency := min(max(runtime.GOMAXPROCS(0)/2, 2), 8) disp := &Dispatcher{ alerts: alerts, stage: stage, route: route, marker: marker, timeout: timeout, maintenanceInterval: maintenanceInterval, concurrency: concurrency, logger: logger.With("component", "dispatcher"), metrics: metrics, limits: limits, propagator: otel.GetTextMapPropagator(), } disp.state.Store(DispatcherStateUnknown) disp.loaded = make(chan struct{}) disp.ctx, disp.cancel = context.WithCancel(context.Background()) return disp } // Run starts dispatching alerts incoming via the updates channel. func (d *Dispatcher) Run(dispatchStartTime time.Time) { if !d.state.CompareAndSwap(DispatcherStateUnknown, DispatcherStateWaitingToStart) { return } d.finished.Add(1) defer d.finished.Done() d.logger.Debug("preparing to start", "startTime", dispatchStartTime) d.startTimer = time.NewTimer(time.Until(dispatchStartTime)) d.logger.Debug("setting state", "state", "waiting_to_start") d.routeGroupsSlice = make([]routeAggrGroups, d.route.Idx+1) d.route.Walk(func(r *Route) { d.routeGroupsSlice[r.Idx] = routeAggrGroups{ route: r, } }) d.aggrGroupsNum.Store(0) d.metrics.aggrGroups.Set(0) initalAlerts, it := d.alerts.SlurpAndSubscribe("dispatcher") for _, alert := range initalAlerts { d.routeAlert(d.ctx, alert) } close(d.loaded) d.run(it) } func (d *Dispatcher) run(it provider.AlertIterator) { defer it.Close() // Start maintenance goroutine d.finished.Go(func() { ticker := time.NewTicker(d.maintenanceInterval) defer ticker.Stop() for { select { case <-ticker.C: d.doMaintenance() case <-d.ctx.Done(): return } } }) // Start timer goroutine d.finished.Go(func() { <-d.startTimer.C if d.state.CompareAndSwap(DispatcherStateWaitingToStart, DispatcherStateRunning) { d.logger.Debug("started", "state", "running") d.logger.Debug("Starting all existing aggregation groups") for rg := range d.routeGroupsSlice { d.routeGroupsSlice[rg].groups.Range(func(_, ag any) bool { d.runAG(ag.(*aggrGroup)) return true }) } } }) // Start multiple alert ingestion goroutines alertCh := it.Next() for i := 0; i < d.concurrency; i++ { d.finished.Add(1) go func(workerID int) { defer d.finished.Done() d.logger.Debug("starting alert ingestion worker", "workerID", workerID) for { select { case alert, ok := <-alertCh: if !ok { // Iterator exhausted for some reason. if err := it.Err(); err != nil { d.logger.Error("Error on alert update", "err", err, "workerID", workerID) } return } // Log errors but keep trying. if err := it.Err(); err != nil { d.logger.Error("Error on alert update", "err", err, "workerID", workerID) continue } ctx := d.ctx if alert.Header != nil { ctx = d.propagator.Extract(ctx, propagation.MapCarrier(alert.Header)) } d.routeAlert(ctx, alert.Data) case <-d.ctx.Done(): return } } }(i) } <-d.ctx.Done() } func (d *Dispatcher) routeAlert(ctx context.Context, alert *types.Alert) { d.logger.Debug("Received alert", "alert", alert) ctx, span := tracer.Start(ctx, "dispatch.Dispatcher.routeAlert", trace.WithAttributes( attribute.String("alerting.alert.name", alert.Name()), attribute.String("alerting.alert.fingerprint", alert.Fingerprint().String()), ), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() now := time.Now() for _, r := range d.route.Match(alert.Labels) { span.AddEvent("dispatching alert to route", trace.WithAttributes( attribute.String("alerting.route.receiver.name", r.RouteOpts.Receiver), ), ) d.groupAlert(ctx, alert, r) } d.metrics.processingDuration.Observe(time.Since(now).Seconds()) } func (d *Dispatcher) doMaintenance() { for i := range d.routeGroupsSlice { d.routeGroupsSlice[i].groups.Range(func(_, el any) bool { ag := el.(*aggrGroup) if ag.destroyed() { ag.stop() d.marker.DeleteByGroupKey(ag.routeID, ag.GroupKey()) deleted := d.routeGroupsSlice[i].groups.CompareAndDelete(ag.fingerprint(), ag) if deleted { d.routeGroupsSlice[i].groupsLen.Add(-1) d.aggrGroupsNum.Add(-1) d.metrics.aggrGroups.Set(float64(d.aggrGroupsNum.Load())) } } return true }) } } func (d *Dispatcher) WaitForLoading() { <-d.loaded } func (d *Dispatcher) LoadingDone() <-chan struct{} { return d.loaded } // AlertGroup represents how alerts exist within an aggrGroup. type AlertGroup struct { Alerts types.AlertSlice Labels model.LabelSet Receiver string GroupKey string RouteID string } type AlertGroups []*AlertGroup func (ag AlertGroups) Swap(i, j int) { ag[i], ag[j] = ag[j], ag[i] } func (ag AlertGroups) Less(i, j int) bool { if ag[i].Labels.Equal(ag[j].Labels) { return ag[i].Receiver < ag[j].Receiver } return ag[i].Labels.Before(ag[j].Labels) } func (ag AlertGroups) Len() int { return len(ag) } // Groups returns a slice of AlertGroups from the dispatcher's internal state. func (d *Dispatcher) Groups(ctx context.Context, routeFilter func(*Route) bool, alertFilter func(*types.Alert, time.Time) bool) (AlertGroups, map[model.Fingerprint][]string, error) { select { case <-ctx.Done(): return nil, nil, ctx.Err() case <-d.LoadingDone(): } groups := AlertGroups{} // Keep a list of receivers for an alert to prevent checking each alert // again against all routes. The alert has already matched against this // route on ingestion. receivers := map[model.Fingerprint][]string{} now := time.Now() for i := range d.routeGroupsSlice { if !routeFilter(d.routeGroupsSlice[i].route) { continue } receiver := d.routeGroupsSlice[i].route.RouteOpts.Receiver // Make a snapshot of the aggregation groups in each route to avoid holding // sync.Map locks while we process alerts or acquiring leaf locks in the alert // store. // Estimate capacity based on total groups and number of routes. // We overallocate a bit to avoid copying in most cases. snapshot := make([]*aggrGroup, 0, d.routeGroupsSlice[i].groupsLen.Load()+32) d.routeGroupsSlice[i].groups.Range(func(_, el any) bool { snapshot = append(snapshot, el.(*aggrGroup)) return true }) // Process the snapshot without holding sync.Map locks for _, ag := range snapshot { alertGroup := &AlertGroup{ Labels: ag.labels, Receiver: receiver, GroupKey: ag.GroupKey(), RouteID: ag.routeID, } alerts := ag.alerts.List() filteredAlerts := make([]*types.Alert, 0, len(alerts)) for _, a := range alerts { if !alertFilter(a, now) { continue } fp := a.Fingerprint() if r, ok := receivers[fp]; ok { // Receivers slice already exists. Add // the current receiver to the slice. receivers[fp] = append(r, receiver) } else { // First time we've seen this alert fingerprint. // Initialize a new receivers slice. receivers[fp] = []string{receiver} } filteredAlerts = append(filteredAlerts, a) } if len(filteredAlerts) == 0 { continue } alertGroup.Alerts = filteredAlerts groups = append(groups, alertGroup) } } sort.Sort(groups) for i := range groups { sort.Sort(groups[i].Alerts) } for i := range receivers { sort.Strings(receivers[i]) } return groups, receivers, nil } // Stop the dispatcher. func (d *Dispatcher) Stop() { if d == nil { return } d.state.Store(DispatcherStateStopped) d.cancel() d.finished.Wait() } // notifyFunc is a function that performs notification for the alert // with the given fingerprint. It aborts on context cancelation. // Returns false if notifying failed. type notifyFunc func(context.Context, ...*types.Alert) bool // groupAlert determines in which aggregation group the alert falls // and inserts it. func (d *Dispatcher) groupAlert(ctx context.Context, alert *types.Alert, route *Route) { _, span := tracer.Start(ctx, "dispatch.Dispatcher.groupAlert", trace.WithAttributes( attribute.String("alerting.alert.name", alert.Name()), attribute.String("alerting.alert.fingerprint", alert.Fingerprint().String()), attribute.String("alerting.route.receiver.name", route.RouteOpts.Receiver), ), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() now := time.Now() groupLabels := getGroupLabels(alert, route) fp := groupLabels.Fingerprint() el, loaded := d.routeGroupsSlice[route.Idx].groups.Load(fp) if loaded { ag := el.(*aggrGroup) // Try to insert into the aggrgroup. // If it's destroyed insert will return false. if ag.insert(ctx, alert) { return } } // If we couldn't insert, we need to create a new aggregation group. // Since multiple goroutines might be trying to create the same group concurrently // we will use the sync map swap to ensure only one of them creates it. // If the group does not exist, create it. But check the limit first. limit := d.limits.MaxNumberOfAggregationGroups() current := int(d.aggrGroupsNum.Load()) if limit > 0 && current >= limit { d.metrics.aggrGroupLimitReached.Inc() err := errors.New("too many aggregation groups, cannot create new group for alert") message := "Failed to create aggregation group" d.logger.Error(message, "err", err.Error(), "groups", current, "limit", limit, "alert", alert.Name()) span.SetStatus(codes.Error, message) span.RecordError(err, trace.WithAttributes( attribute.Int("alerting.aggregation_group.count", current), attribute.Int("alerting.aggregation_group.limit", limit), ), ) return } ag := newAggrGroup(d.ctx, groupLabels, route, d.timeout, d.marker.(types.AlertMarker), d.logger) // Insert the 1st alert in the group before starting the group's run() // function, to make sure that when the run() will be executed the 1st // alert is already there. ag.insert(ctx, alert) retries := 0 for { if loaded { // Try to store the new group in the map. If another goroutine has already created the same group, use the existing one. swapped := d.routeGroupsSlice[route.Idx].groups.CompareAndSwap(fp, el, ag) if swapped { // We swapped the new group in, we can break and start it. break } } else { el, loaded = d.routeGroupsSlice[route.Idx].groups.LoadOrStore(fp, ag) if !loaded { d.routeGroupsSlice[route.Idx].groupsLen.Add(1) d.aggrGroupsNum.Add(1) d.metrics.aggrGroups.Set(float64(d.aggrGroupsNum.Load())) // We stored the new group, we can break and start it. break } if el == nil { continue } // we found an existing group, try to insert the alert into it. If it's destroyed, we will retry the whole process with the updated el. agExisting := el.(*aggrGroup) if agExisting.insert(ctx, alert) { return // if we inserted we return to avoid incrementing the aggrgroup count and starting the group. } } // If we failed to swap, it means another goroutine has created/modified the group retries++ if retries > 100 { // This shouldn't happen - indicates a bug or extreme contention d.logger.Error("excessive retries creating aggregation group", "fingerprint", fp, "route", route.Key(), "alert", alert.Name(), "retries", retries, ) // Give up and accept potential alert loss rather than infinite loop return } } span.AddEvent("new AggregationGroup created", trace.WithAttributes( attribute.String("alerting.aggregation_group.key", ag.GroupKey()), attribute.Int("alerting.aggregation_group.count", int(d.aggrGroupsNum.Load())), ), ) if alert.StartsAt.Add(ag.opts.GroupWait).Before(now) { message := "Alert is old enough for immediate flush, resetting timer to zero" ag.logger.Debug(message, "alert", alert.Name(), "fingerprint", alert.Fingerprint(), "startsAt", alert.StartsAt) span.AddEvent(message, trace.WithAttributes( attribute.String("alerting.alert.StartsAt", alert.StartsAt.Format(time.RFC3339)), ), ) ag.resetTimer(0) } // Check dispatcher and alert state to determine if we should run the AG now. switch d.state.Load() { case DispatcherStateWaitingToStart: span.AddEvent("Not starting Aggregation Group, dispatcher is not running") d.logger.Debug("Dispatcher still waiting to start") case DispatcherStateRunning: span.AddEvent("Starting Aggregation Group") d.runAG(ag) default: d.logger.Warn("unknown state detected", "state", "unknown") } } func (d *Dispatcher) runAG(ag *aggrGroup) { if !ag.running.CompareAndSwap(false, true) { return // already running } go ag.run(func(ctx context.Context, alerts ...*types.Alert) bool { _, _, err := d.stage.Exec(ctx, d.logger, alerts...) if err != nil { logger := d.logger.With("aggrGroup", ag.GroupKey(), "num_alerts", len(alerts), "err", err) if errors.Is(ctx.Err(), context.Canceled) { // It is expected for the context to be canceled on // configuration reload or shutdown. In this case, the // message should only be logged at the debug level. logger.Debug("Notify for alerts failed") } else { logger.Error("Notify for alerts failed") } } return err == nil }) } func getGroupLabels(alert *types.Alert, route *Route) model.LabelSet { capacity := len(route.RouteOpts.GroupBy) if route.RouteOpts.GroupByAll { capacity = len(alert.Labels) } groupLabels := make(model.LabelSet, capacity) for ln, lv := range alert.Labels { if _, ok := route.RouteOpts.GroupBy[ln]; ok || route.RouteOpts.GroupByAll { groupLabels[ln] = lv } } return groupLabels } // aggrGroup aggregates alert fingerprints into groups to which a // common set of routing options applies. // It emits notifications in the specified intervals. type aggrGroup struct { labels model.LabelSet opts *RouteOpts logger *slog.Logger routeID string routeKey string alerts *store.Alerts marker types.AlertMarker ctx context.Context cancel func() done chan struct{} next *time.Timer timeout func(time.Duration) time.Duration running atomic.Bool } // newAggrGroup returns a new aggregation group. func newAggrGroup( ctx context.Context, labels model.LabelSet, r *Route, to func(time.Duration) time.Duration, marker types.AlertMarker, logger *slog.Logger, ) *aggrGroup { if to == nil { to = func(d time.Duration) time.Duration { return d } } ag := &aggrGroup{ labels: labels, routeID: r.ID(), routeKey: r.Key(), opts: &r.RouteOpts, timeout: to, alerts: store.NewAlerts(), marker: marker, done: make(chan struct{}), } ag.ctx, ag.cancel = context.WithCancel(ctx) ag.logger = logger.With("aggrGroup", ag) // Set an initial one-time wait before flushing // the first batch of notifications. ag.next = time.NewTimer(ag.opts.GroupWait) return ag } func (ag *aggrGroup) fingerprint() model.Fingerprint { return ag.labels.Fingerprint() } func (ag *aggrGroup) GroupKey() string { return fmt.Sprintf("%s:%s", ag.routeKey, ag.labels) } func (ag *aggrGroup) String() string { return ag.GroupKey() } func (ag *aggrGroup) run(nf notifyFunc) { defer close(ag.done) defer ag.next.Stop() for { select { case now := <-ag.next.C: // Give the notifications time until the next flush to // finish before terminating them. ctx, cancel := context.WithTimeout(ag.ctx, ag.timeout(ag.opts.GroupInterval)) // The now time we retrieve from the ticker is the only reliable // point of time reference for the subsequent notification pipeline. // Calculating the current time directly is prone to flaky behavior, // which usually only becomes apparent in tests. ctx = notify.WithNow(ctx, now) // Populate context with information needed along the pipeline. ctx = notify.WithGroupKey(ctx, ag.GroupKey()) ctx = notify.WithGroupLabels(ctx, ag.labels) ctx = notify.WithReceiverName(ctx, ag.opts.Receiver) ctx = notify.WithRepeatInterval(ctx, ag.opts.RepeatInterval) ctx = notify.WithMuteTimeIntervals(ctx, ag.opts.MuteTimeIntervals) ctx = notify.WithActiveTimeIntervals(ctx, ag.opts.ActiveTimeIntervals) ctx = notify.WithRouteID(ctx, ag.routeID) // Wait the configured interval before calling flush again. ag.resetTimer(ag.opts.GroupInterval) ag.flush(func(alerts ...*types.Alert) bool { ctx, span := tracer.Start(ctx, "dispatch.AggregationGroup.flush", trace.WithAttributes( attribute.String("alerting.aggregation_group.key", ag.GroupKey()), attribute.Int("alerting.alerts.count", len(alerts)), ), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() success := nf(ctx, alerts...) if !success { span.SetStatus(codes.Error, "notification failed") } return success }) cancel() case <-ag.ctx.Done(): return } } } func (ag *aggrGroup) stop() { // Calling cancel will terminate all in-process notifications // and the run() loop. ag.cancel() <-ag.done } // resetTimer resets the timer for the AG. func (ag *aggrGroup) resetTimer(t time.Duration) { ag.next.Reset(t) } // insert inserts the alert into the aggregation group. // Returns false if the aggregation group has been destroyed. func (ag *aggrGroup) insert(ctx context.Context, alert *types.Alert) bool { _, span := tracer.Start(ctx, "dispatch.AggregationGroup.insert", trace.WithAttributes( attribute.String("alerting.alert.name", alert.Name()), attribute.String("alerting.alert.fingerprint", alert.Fingerprint().String()), attribute.String("alerting.aggregation_group.key", ag.GroupKey()), ), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() if err := ag.alerts.Set(alert); err != nil { if errors.Is(err, store.ErrDestroyed) { return false } message := "error on set alert" span.SetStatus(codes.Error, message) span.RecordError(err) ag.logger.Error(message, "err", err) } return true } func (ag *aggrGroup) empty() bool { return ag.alerts.Empty() } func (ag *aggrGroup) destroyed() bool { return ag.alerts.Destroyed() } // flush sends notifications for all new alerts. func (ag *aggrGroup) flush(notify func(...*types.Alert) bool) { if ag.empty() { return } var ( alerts = ag.alerts.List() alertsSlice = make(types.AlertSlice, 0, len(alerts)) resolvedSlice = make(types.AlertSlice, 0, len(alerts)) now = time.Now() ) for _, alert := range alerts { a := *alert // Ensure that alerts don't resolve as time move forwards. if a.ResolvedAt(now) { resolvedSlice = append(resolvedSlice, &a) } else { a.EndsAt = time.Time{} } alertsSlice = append(alertsSlice, &a) } sort.Stable(alertsSlice) ag.logger.Debug("flushing", "alerts", fmt.Sprintf("%v", alertsSlice)) if notify(alertsSlice...) { // Delete all resolved alerts as we just sent a notification for them, // and we don't want to send another one. However, we need to make sure // that each resolved alert has not fired again during the flush as then // we would delete an active alert thinking it was resolved. // Since we are passing DestroyIfEmpty=true the group will be marked as // destroyed if there are no more alerts after the deletion. if err := ag.alerts.DeleteIfNotModified(resolvedSlice, true); err != nil { ag.logger.Error("error on delete alerts", "err", err) } else { // Delete markers for resolved alerts that are not in the store. for _, alert := range resolvedSlice { _, err := ag.alerts.Get(alert.Fingerprint()) if errors.Is(err, store.ErrNotFound) { ag.marker.Delete(alert.Fingerprint()) } } } } } type nilLimits struct{} func (n nilLimits) MaxNumberOfAggregationGroups() int { return 0 } ================================================ FILE: dispatch/dispatch_bench_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dispatch import ( "context" "fmt" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/provider/mem" "github.com/prometheus/alertmanager/types" ) // buildDeepRouteTree creates a multi-level hierarchical route tree: // - numTeams routes at top level // - Each team has numClusters cluster sub-routes // - Each cluster has numPriorities priority sub-routes // Total: numTeams * numClusters * numPriorities leaf routes with 3 levels of depth. func buildDeepRouteTree(numTeams, numClusters, numPriorities int) *Route { groupWait := model.Duration(30 * time.Second) groupInterval := model.Duration(5 * time.Minute) repeatInterval := model.Duration(4 * time.Hour) root := &config.Route{ Receiver: "default", GroupBy: []model.LabelName{"alertname"}, GroupWait: &groupWait, GroupInterval: &groupInterval, RepeatInterval: &repeatInterval, } // Create team routes, each with cluster sub-routes, each with priority sub-routes root.Routes = make([]*config.Route, 0, numTeams) for i := range numTeams { teamRoute := &config.Route{ Receiver: fmt.Sprintf("team-%d-default", i), Match: map[string]string{"team": fmt.Sprintf("team-%d", i)}, GroupBy: []model.LabelName{"alertname"}, GroupWait: &groupWait, GroupInterval: &groupInterval, RepeatInterval: &repeatInterval, } // Add cluster sub-routes teamRoute.Routes = make([]*config.Route, 0, numClusters) for j := range numClusters { clusterRoute := &config.Route{ Receiver: fmt.Sprintf("team-%d-cluster-%d-default", i, j), Match: map[string]string{"cluster": fmt.Sprintf("cluster-%d", j)}, GroupBy: []model.LabelName{"alertname"}, GroupWait: &groupWait, GroupInterval: &groupInterval, RepeatInterval: &repeatInterval, } // Add priority sub-routes clusterRoute.Routes = make([]*config.Route, 0, numPriorities) for k := range numPriorities { sevRoute := &config.Route{ Receiver: fmt.Sprintf("team-%d-cluster-%d-p%d", i, j, k), Match: map[string]string{"priority": fmt.Sprintf("p%d", k)}, GroupBy: []model.LabelName{"alertname"}, GroupWait: &groupWait, GroupInterval: &groupInterval, RepeatInterval: &repeatInterval, } clusterRoute.Routes = append(clusterRoute.Routes, sevRoute) } teamRoute.Routes = append(teamRoute.Routes, clusterRoute) } root.Routes = append(root.Routes, teamRoute) } return NewRoute(root, nil) } // newBenchAlert creates a simple alert with given labels for benchmarking. func newBenchAlert(labels model.LabelSet) *types.Alert { now := time.Now() return &types.Alert{ Alert: model.Alert{ Labels: labels, Annotations: model.LabelSet{"description": "benchmark alert"}, StartsAt: now, EndsAt: now.Add(time.Hour), GeneratorURL: "http://localhost", }, UpdatedAt: now, } } // makeBenchAlertBatch creates a batch of alerts distributed across route tree dimensions: // - offset is added to the index to create unique alerts across multiple batches, // exercising the whole route tree. // - numTeams, numClusters, numPriorities define the route tree structure. func makeBenchAlertBatch(size, offset, numTeams, numClusters, numPriorities int) []*types.Alert { batch := make([]*types.Alert, size) for i := range size { idx := offset + i labels := model.LabelSet{ "alertname": model.LabelValue(fmt.Sprintf("alert-%d", idx)), "instance": model.LabelValue(fmt.Sprintf("instance-%d", idx)), } // Distribute alerts across teams, clusters, priorities using simple modulo // This ensures each batch hits all dimensions evenly if numTeams > 0 { labels["team"] = model.LabelValue(fmt.Sprintf("team-%d", idx%numTeams)) labels["cluster"] = model.LabelValue(fmt.Sprintf("cluster-%d", idx%numClusters)) labels["priority"] = model.LabelValue(fmt.Sprintf("p%d", idx%numPriorities)) } batch[i] = newBenchAlert(labels) } return batch } // setupDispatcher creates a dispatcher with the given route for benchmarking. func setupDispatcher(b *testing.B, route *Route) (*Dispatcher, *mem.Alerts, *recordStage) { logger := promslog.NewNopLogger() reg := prometheus.NewRegistry() marker := types.NewMarker(reg) alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, reg, nil) require.NoError(b, err) b.Cleanup(func() { alerts.Close() }) recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*types.Alert)} timeout := func(d time.Duration) time.Duration { return time.Duration(0) } metrics := NewDispatcherMetrics(false, reg) dispatcher := NewDispatcher(alerts, route, recorder, marker, timeout, 30*time.Second, nil, logger, metrics) return dispatcher, alerts, recorder } // populateGroups pre-populates the dispatcher with aggregation groups. // It puts a total of numGroups groups, each with alertsPerGroup alerts, spread across // numTeams, numClusters, numPriorities route dimensions. func populateGroups(b *testing.B, d *Dispatcher, alerts *mem.Alerts, numGroups, alertsPerGroup, numTeams, numClusters, numPriorities, expectedMinGroups int) { ctx := context.Background() for i := range numGroups { groupAlerts := make([]*types.Alert, 0, alertsPerGroup) for j := range alertsPerGroup { labels := model.LabelSet{ "alertname": model.LabelValue(fmt.Sprintf("alert-%d", i)), "instance": model.LabelValue(fmt.Sprintf("instance-%d", j)), } // Distribute alerts across teams, clusters, priorities (for deep route tree) if numTeams > 0 { labels["team"] = model.LabelValue(fmt.Sprintf("team-%d", i%numTeams)) labels["cluster"] = model.LabelValue(fmt.Sprintf("cluster-%d", (i/numTeams)%numClusters)) labels["priority"] = model.LabelValue(fmt.Sprintf("p%d", (i/(numTeams*numClusters))%numPriorities)) } groupAlerts = append(groupAlerts, newBenchAlert(labels)) } require.NoError(b, alerts.Put(ctx, groupAlerts...)) } // Wait for dispatcher to create all expected groups require.Eventually(b, func() bool { groups, _, _ := d.Groups(ctx, func(*Route) bool { return true }, func(*types.Alert, time.Time) bool { return true }, ) return len(groups) >= expectedMinGroups }, 30*time.Second, 10*time.Millisecond, "expected %d groups to be created", expectedMinGroups) } // BenchmarkGroups simulates a realistic production scenario: // - 500 leaf routes in a deep hierarchy (25 teams × 4 clusters × 5 priorities) // - 5000 stable aggregation groups (average ~10 per leaf route) // - Measures Groups() API latency (simulates GET /api/v2/alerts/groups) // // This benchmark demonstrates the concurrent dispatcher's benefit: // - main branch: Global lock blocks all Groups() calls during any alert processing // - concurrent dispatcher: Per-route locks allow Groups() to run mostly lock-free. func BenchmarkGroups(b *testing.B) { b.Run("500 routes, 5000 groups", func(b *testing.B) { benchmarkGroups(b, 5000, 3, 25, 4, 5) }) b.Run("400 routes, 10000 groups", func(b *testing.B) { benchmarkGroups(b, 10000, 3, 20, 4, 5) }) } func benchmarkGroups(b *testing.B, numGroups, alertsPerGroup, numTeams, numClusters, numPriorities int) { route := buildDeepRouteTree(numTeams, numClusters, numPriorities) b.ReportAllocs() dispatcher, alerts, _ := setupDispatcher(b, route) go dispatcher.Run(time.Now()) defer dispatcher.Stop() // Pre-populate with stable groups (uses existing helper) populateGroups(b, dispatcher, alerts, numGroups, alertsPerGroup, numTeams, numClusters, numPriorities, numGroups) ctx := context.Background() routeFilter := func(*Route) bool { return true } alertFilter := func(*types.Alert, time.Time) bool { return true } b.ResetTimer() // Measure Groups() API latency for b.Loop() { groups, _, _ := dispatcher.Groups(ctx, routeFilter, alertFilter) if len(groups) != numGroups { b.Fatalf("unexpected group count: %d (expected %d)", len(groups), numGroups) } } b.StopTimer() } // BenchmarkIngestionUnderGroupsLoad measures alert ingestion latency // while concurrent Groups() API calls are happening. // // This demonstrates the key benefit of the concurrent dispatcher: // - Main branch: Groups() holds global RLock, blocks ingestion (needs WLock) // - Concurrent dispatcher: Groups() iterates sync.Maps, minimal blocking // // We measure ingestion latency (time for alerts.Put to complete) as we // increase the number of concurrent Groups() callers from 0 to 100. func BenchmarkIngestionUnderGroupsLoad(b *testing.B) { b.Run("500 routes, 0/s Groups() callers", func(b *testing.B) { benchmarkIngestionUnderGroupsLoad(b, 0, 0*time.Millisecond) }) b.Run("500 routes, 1 10/s Groups() callers", func(b *testing.B) { benchmarkIngestionUnderGroupsLoad(b, 1, 100*time.Millisecond) }) b.Run("500 routes, 10 10/s Groups() callers", func(b *testing.B) { benchmarkIngestionUnderGroupsLoad(b, 10, 100*time.Millisecond) }) b.Run("500 routes, 25 10/s Groups() callers", func(b *testing.B) { benchmarkIngestionUnderGroupsLoad(b, 25, 100*time.Millisecond) }) b.Run("500 routes, 25 20/s Groups() callers", func(b *testing.B) { benchmarkIngestionUnderGroupsLoad(b, 25, 50*time.Millisecond) }) } func benchmarkIngestionUnderGroupsLoad(b *testing.B, numGroupsCallers int, groupsTick time.Duration) { route := buildDeepRouteTree(25, 4, 5) b.ReportAllocs() dispatcher, alerts, _ := setupDispatcher(b, route) go dispatcher.Run(time.Now()) defer dispatcher.Stop() // Pre-populate 5000 stable groups across routes populateGroups(b, dispatcher, alerts, 5000, 3, 25, 4, 5, 5000) ctx := context.Background() stopCh := make(chan struct{}) defer close(stopCh) // Start concurrent Groups() callers (simulating dashboard queries) for range numGroupsCallers { go func() { ticker := time.NewTicker(groupsTick) defer ticker.Stop() for { select { case <-ticker.C: dispatcher.Groups(ctx, func(*Route) bool { return true }, func(*types.Alert, time.Time) bool { return true }) case <-stopCh: return } } }() } // Let Groups() callers stabilize time.Sleep(500 * time.Millisecond) b.ResetTimer() counter := 0 for b.Loop() { batch := makeBenchAlertBatch(100, counter*100, 25, 4, 5) // Put alerts into provider err := alerts.Put(ctx, batch...) if err != nil { b.Fatal(err) } counter++ } } ================================================ FILE: dispatch/dispatch_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dispatch import ( "context" "fmt" "log/slog" "reflect" "sort" "sync" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/provider/mem" "github.com/prometheus/alertmanager/types" ) const testMaintenanceInterval = 30 * time.Second func TestAggrGroup(t *testing.T) { lset := model.LabelSet{ "a": "v1", "b": "v2", } opts := &RouteOpts{ Receiver: "n1", GroupBy: map[model.LabelName]struct{}{ "a": {}, "b": {}, }, GroupWait: 1 * time.Second, GroupInterval: 300 * time.Millisecond, RepeatInterval: 1 * time.Hour, } route := &Route{ RouteOpts: *opts, } var ( a1 = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "a": "v1", "b": "v2", "c": "v3", }, StartsAt: time.Now().Add(time.Minute), EndsAt: time.Now().Add(time.Hour), }, UpdatedAt: time.Now(), } a2 = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "a": "v1", "b": "v2", "c": "v4", }, StartsAt: time.Now().Add(-time.Hour), EndsAt: time.Now().Add(2 * time.Hour), }, UpdatedAt: time.Now(), } a3 = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "a": "v1", "b": "v2", "c": "v5", }, StartsAt: time.Now().Add(time.Minute), EndsAt: time.Now().Add(5 * time.Minute), }, UpdatedAt: time.Now(), } ) var ( last = time.Now() current = time.Now() lastCurMtx = &sync.Mutex{} alertsCh = make(chan types.AlertSlice) ) ntfy := func(ctx context.Context, alerts ...*types.Alert) bool { // Validate that the context is properly populated. if _, ok := notify.Now(ctx); !ok { t.Errorf("now missing") } if _, ok := notify.GroupKey(ctx); !ok { t.Errorf("group key missing") } if lbls, ok := notify.GroupLabels(ctx); !ok || !reflect.DeepEqual(lbls, lset) { t.Errorf("wrong group labels: %q", lbls) } if rcv, ok := notify.ReceiverName(ctx); !ok || rcv != opts.Receiver { t.Errorf("wrong receiver: %q", rcv) } if ri, ok := notify.RepeatInterval(ctx); !ok || ri != opts.RepeatInterval { t.Errorf("wrong repeat interval: %q", ri) } lastCurMtx.Lock() last = current // Subtract a millisecond to allow for races. current = time.Now().Add(-time.Millisecond) lastCurMtx.Unlock() alertsCh <- types.AlertSlice(alerts) return true } removeEndsAt := func(as types.AlertSlice) types.AlertSlice { for i, a := range as { ac := *a ac.EndsAt = time.Time{} as[i] = &ac } return as } // Test regular situation where we wait for group_wait to send out alerts. ag := newAggrGroup(context.Background(), lset, route, nil, types.NewMarker(prometheus.NewRegistry()), promslog.NewNopLogger()) go ag.run(ntfy) ctx := context.Background() ag.insert(ctx, a1) select { case <-time.After(2 * opts.GroupWait): t.Fatalf("expected initial batch after group_wait") case batch := <-alertsCh: lastCurMtx.Lock() s := time.Since(last) lastCurMtx.Unlock() if s < opts.GroupWait { t.Fatalf("received batch too early after %v", s) } exp := removeEndsAt(types.AlertSlice{a1}) sort.Sort(batch) if !reflect.DeepEqual(batch, exp) { t.Fatalf("expected alerts %v but got %v", exp, batch) } } for range 3 { // New alert should come in after group interval. ag.insert(ctx, a3) select { case <-time.After(2 * opts.GroupInterval): t.Fatalf("expected new batch after group interval but received none") case batch := <-alertsCh: lastCurMtx.Lock() s := time.Since(last) lastCurMtx.Unlock() if s < opts.GroupInterval { t.Fatalf("received batch too early after %v", s) } exp := removeEndsAt(types.AlertSlice{a1, a3}) sort.Sort(batch) if !reflect.DeepEqual(batch, exp) { t.Fatalf("expected alerts %v but got %v", exp, batch) } } } ag.stop() // Finally, set all alerts to be resolved. After successful notify the aggregation group // should empty itself. ag = newAggrGroup(context.Background(), lset, route, nil, types.NewMarker(prometheus.NewRegistry()), promslog.NewNopLogger()) go ag.run(ntfy) ag.insert(ctx, a1) ag.insert(ctx, a2) batch := <-alertsCh exp := removeEndsAt(types.AlertSlice{a1, a2}) sort.Sort(batch) if !reflect.DeepEqual(batch, exp) { t.Fatalf("expected alerts %v but got %v", exp, batch) } for range 3 { // New alert should come in after group interval. ag.insert(ctx, a3) select { case <-time.After(2 * opts.GroupInterval): t.Fatalf("expected new batch after group interval but received none") case batch := <-alertsCh: lastCurMtx.Lock() s := time.Since(last) lastCurMtx.Unlock() if s < opts.GroupInterval { t.Fatalf("received batch too early after %v", s) } exp := removeEndsAt(types.AlertSlice{a1, a2, a3}) sort.Sort(batch) if !reflect.DeepEqual(batch, exp) { t.Fatalf("expected alerts %v but got %v", exp, batch) } } } // Resolve an alert, and it should be removed after the next batch was sent. a1r := *a1 a1r.EndsAt = time.Now() ag.insert(ctx, &a1r) exp = append(types.AlertSlice{&a1r}, removeEndsAt(types.AlertSlice{a2, a3})...) select { case <-time.After(2 * opts.GroupInterval): t.Fatalf("expected new batch after group interval but received none") case batch := <-alertsCh: lastCurMtx.Lock() s := time.Since(last) lastCurMtx.Unlock() if s < opts.GroupInterval { t.Fatalf("received batch too early after %v", s) } sort.Sort(batch) if !reflect.DeepEqual(batch, exp) { t.Fatalf("expected alerts %v but got %v", exp, batch) } } // Resolve all remaining alerts, they should be removed after the next batch was sent. // Do not add a1r as it should have been deleted following the previous batch. a2r, a3r := *a2, *a3 resolved := types.AlertSlice{&a2r, &a3r} for _, a := range resolved { a.EndsAt = time.Now() ag.insert(ctx, a) } select { case <-time.After(2 * opts.GroupInterval): t.Fatalf("expected new batch after group interval but received none") case batch := <-alertsCh: lastCurMtx.Lock() s := time.Since(last) lastCurMtx.Unlock() if s < opts.GroupInterval { t.Fatalf("received batch too early after %v", s) } sort.Sort(batch) if !reflect.DeepEqual(batch, resolved) { t.Fatalf("expected alerts %v but got %v", resolved, batch) } if !ag.empty() { t.Fatalf("Expected aggregation group to be empty after resolving alerts: %v", ag) } } ag.stop() } func TestGroupLabels(t *testing.T) { a := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "a": "v1", "b": "v2", "c": "v3", }, }, } route := &Route{ RouteOpts: RouteOpts{ GroupBy: map[model.LabelName]struct{}{ "a": {}, "b": {}, }, GroupByAll: false, }, } expLs := model.LabelSet{ "a": "v1", "b": "v2", } ls := getGroupLabels(a, route) if !reflect.DeepEqual(ls, expLs) { t.Fatalf("expected labels are %v, but got %v", expLs, ls) } } func TestGroupByAllLabels(t *testing.T) { a := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "a": "v1", "b": "v2", "c": "v3", }, }, } route := &Route{ RouteOpts: RouteOpts{ GroupBy: map[model.LabelName]struct{}{}, GroupByAll: true, }, } expLs := model.LabelSet{ "a": "v1", "b": "v2", "c": "v3", } ls := getGroupLabels(a, route) if !reflect.DeepEqual(ls, expLs) { t.Fatalf("expected labels are %v, but got %v", expLs, ls) } } func TestGroups(t *testing.T) { confData := `receivers: - name: 'kafka' - name: 'prod' - name: 'testing' route: group_by: ['alertname'] group_wait: 10ms group_interval: 10ms receiver: 'prod' routes: - match: env: 'testing' receiver: 'testing' group_by: ['alertname', 'service'] - match: env: 'prod' receiver: 'prod' group_by: ['alertname', 'service', 'cluster'] continue: true - match: kafka: 'yes' receiver: 'kafka' group_by: ['alertname', 'service', 'cluster']` conf, err := config.Load(confData) if err != nil { t.Fatal(err) } logger := promslog.NewNopLogger() route := NewRoute(conf.Route, nil) reg := prometheus.NewRegistry() marker := types.NewMarker(reg) alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, reg, nil) if err != nil { t.Fatal(err) } defer alerts.Close() timeout := func(d time.Duration) time.Duration { return time.Duration(0) } recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*types.Alert)} dispatcher := NewDispatcher(alerts, route, recorder, marker, timeout, testMaintenanceInterval, nil, logger, NewDispatcherMetrics(false, reg)) go dispatcher.Run(time.Now()) defer dispatcher.Stop() // Create alerts. the dispatcher will automatically create the groups. inputAlerts := []*types.Alert{ // Matches the parent route. newAlert(model.LabelSet{"alertname": "OtherAlert", "cluster": "cc", "service": "dd"}), // Matches the first sub-route. newAlert(model.LabelSet{"env": "testing", "alertname": "TestingAlert", "service": "api", "instance": "inst1"}), // Matches the second sub-route. newAlert(model.LabelSet{"env": "prod", "alertname": "HighErrorRate", "cluster": "aa", "service": "api", "instance": "inst1"}), newAlert(model.LabelSet{"env": "prod", "alertname": "HighErrorRate", "cluster": "aa", "service": "api", "instance": "inst2"}), // Matches the second sub-route. newAlert(model.LabelSet{"env": "prod", "alertname": "HighErrorRate", "cluster": "bb", "service": "api", "instance": "inst1"}), // Matches the second and third sub-route. newAlert(model.LabelSet{"env": "prod", "alertname": "HighLatency", "cluster": "bb", "service": "db", "kafka": "yes", "instance": "inst3"}), } alerts.Put(context.Background(), inputAlerts...) // Let alerts get processed. for i := 0; len(recorder.Alerts()) != 7 && i < 10; i++ { time.Sleep(200 * time.Millisecond) } require.Len(t, recorder.Alerts(), 7) alertGroups, receivers, _ := dispatcher.Groups(context.Background(), func(*Route) bool { return true }, func(*types.Alert, time.Time) bool { return true }, ) require.Equal(t, AlertGroups{ &AlertGroup{ Alerts: []*types.Alert{inputAlerts[0]}, Labels: model.LabelSet{ "alertname": "OtherAlert", }, Receiver: "prod", GroupKey: "{}:{alertname=\"OtherAlert\"}", RouteID: "{}", }, &AlertGroup{ Alerts: []*types.Alert{inputAlerts[1]}, Labels: model.LabelSet{ "alertname": "TestingAlert", "service": "api", }, Receiver: "testing", GroupKey: "{}/{env=\"testing\"}:{alertname=\"TestingAlert\", service=\"api\"}", RouteID: "{}/{env=\"testing\"}/0", }, &AlertGroup{ Alerts: []*types.Alert{inputAlerts[2], inputAlerts[3]}, Labels: model.LabelSet{ "alertname": "HighErrorRate", "service": "api", "cluster": "aa", }, Receiver: "prod", GroupKey: "{}/{env=\"prod\"}:{alertname=\"HighErrorRate\", cluster=\"aa\", service=\"api\"}", RouteID: "{}/{env=\"prod\"}/1", }, &AlertGroup{ Alerts: []*types.Alert{inputAlerts[4]}, Labels: model.LabelSet{ "alertname": "HighErrorRate", "service": "api", "cluster": "bb", }, Receiver: "prod", GroupKey: "{}/{env=\"prod\"}:{alertname=\"HighErrorRate\", cluster=\"bb\", service=\"api\"}", RouteID: "{}/{env=\"prod\"}/1", }, &AlertGroup{ Alerts: []*types.Alert{inputAlerts[5]}, Labels: model.LabelSet{ "alertname": "HighLatency", "service": "db", "cluster": "bb", }, Receiver: "kafka", GroupKey: "{}/{kafka=\"yes\"}:{alertname=\"HighLatency\", cluster=\"bb\", service=\"db\"}", RouteID: "{}/{kafka=\"yes\"}/2", }, &AlertGroup{ Alerts: []*types.Alert{inputAlerts[5]}, Labels: model.LabelSet{ "alertname": "HighLatency", "service": "db", "cluster": "bb", }, Receiver: "prod", GroupKey: "{}/{env=\"prod\"}:{alertname=\"HighLatency\", cluster=\"bb\", service=\"db\"}", RouteID: "{}/{env=\"prod\"}/1", }, }, alertGroups) require.Equal(t, map[model.Fingerprint][]string{ inputAlerts[0].Fingerprint(): {"prod"}, inputAlerts[1].Fingerprint(): {"testing"}, inputAlerts[2].Fingerprint(): {"prod"}, inputAlerts[3].Fingerprint(): {"prod"}, inputAlerts[4].Fingerprint(): {"prod"}, inputAlerts[5].Fingerprint(): {"kafka", "prod"}, }, receivers) } func TestGroupsWithLimits(t *testing.T) { confData := `receivers: - name: 'kafka' - name: 'prod' - name: 'testing' route: group_by: ['alertname'] group_wait: 10ms group_interval: 10ms receiver: 'prod' routes: - match: env: 'testing' receiver: 'testing' group_by: ['alertname', 'service'] - match: env: 'prod' receiver: 'prod' group_by: ['alertname', 'service', 'cluster'] continue: true - match: kafka: 'yes' receiver: 'kafka' group_by: ['alertname', 'service', 'cluster']` conf, err := config.Load(confData) if err != nil { t.Fatal(err) } logger := promslog.NewNopLogger() route := NewRoute(conf.Route, nil) reg := prometheus.NewRegistry() marker := types.NewMarker(reg) alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, reg, nil) if err != nil { t.Fatal(err) } defer alerts.Close() timeout := func(d time.Duration) time.Duration { return time.Duration(0) } recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*types.Alert)} lim := limits{groups: 6} m := NewDispatcherMetrics(true, reg) dispatcher := NewDispatcher(alerts, route, recorder, marker, timeout, testMaintenanceInterval, lim, logger, m) go dispatcher.Run(time.Now()) defer dispatcher.Stop() // Create alerts. the dispatcher will automatically create the groups. inputAlerts := []*types.Alert{ // Matches the parent route. newAlert(model.LabelSet{"alertname": "OtherAlert", "cluster": "cc", "service": "dd"}), // Matches the first sub-route. newAlert(model.LabelSet{"env": "testing", "alertname": "TestingAlert", "service": "api", "instance": "inst1"}), // Matches the second sub-route. newAlert(model.LabelSet{"env": "prod", "alertname": "HighErrorRate", "cluster": "aa", "service": "api", "instance": "inst1"}), newAlert(model.LabelSet{"env": "prod", "alertname": "HighErrorRate", "cluster": "aa", "service": "api", "instance": "inst2"}), // Matches the second sub-route. newAlert(model.LabelSet{"env": "prod", "alertname": "HighErrorRate", "cluster": "bb", "service": "api", "instance": "inst1"}), // Matches the second and third sub-route. newAlert(model.LabelSet{"env": "prod", "alertname": "HighLatency", "cluster": "bb", "service": "db", "kafka": "yes", "instance": "inst3"}), } err = alerts.Put(context.Background(), inputAlerts...) if err != nil { t.Fatal(err) } // Let alerts get processed. for i := 0; len(recorder.Alerts()) != 7 && i < 10; i++ { time.Sleep(200 * time.Millisecond) } require.Len(t, recorder.Alerts(), 7) routeFilter := func(*Route) bool { return true } alertFilter := func(*types.Alert, time.Time) bool { return true } alertGroups, _, _ := dispatcher.Groups(context.Background(), routeFilter, alertFilter) require.Len(t, alertGroups, 6) require.Equal(t, 0.0, testutil.ToFloat64(m.aggrGroupLimitReached)) // Try to store new alert. This time, we will hit limit for number of groups. err = alerts.Put(context.Background(), newAlert(model.LabelSet{"env": "prod", "alertname": "NewAlert", "cluster": "new-cluster", "service": "db"})) if err != nil { t.Fatal(err) } // Let alert get processed. for i := 0; testutil.ToFloat64(m.aggrGroupLimitReached) == 0 && i < 10; i++ { time.Sleep(200 * time.Millisecond) } require.Equal(t, 1.0, testutil.ToFloat64(m.aggrGroupLimitReached)) // Verify there are still only 6 groups. alertGroups, _, _ = dispatcher.Groups(context.Background(), routeFilter, alertFilter) require.Len(t, alertGroups, 6) } type recordStage struct { mtx sync.RWMutex alerts map[string]map[model.Fingerprint]*types.Alert } func (r *recordStage) Alerts() []*types.Alert { r.mtx.RLock() defer r.mtx.RUnlock() alerts := make([]*types.Alert, 0) for k := range r.alerts { for _, a := range r.alerts[k] { alerts = append(alerts, a) } } return alerts } func (r *recordStage) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { r.mtx.Lock() defer r.mtx.Unlock() gk, ok := notify.GroupKey(ctx) if !ok { panic("GroupKey not present!") } if _, ok := r.alerts[gk]; !ok { r.alerts[gk] = make(map[model.Fingerprint]*types.Alert) } for _, a := range alerts { r.alerts[gk][a.Fingerprint()] = a } return ctx, nil, nil } var ( // Set the start time in the past to trigger a flush immediately. t0 = time.Now().Add(-time.Minute) // Set the end time in the future to avoid deleting the alert. t1 = t0.Add(2 * time.Minute) ) func newAlert(labels model.LabelSet) *types.Alert { return &types.Alert{ Alert: model.Alert{ Labels: labels, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: t0, EndsAt: t1, GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: t0, Timeout: false, } } func TestDispatcherRace(t *testing.T) { logger := promslog.NewNopLogger() reg := prometheus.NewRegistry() marker := types.NewMarker(reg) alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, reg, nil) if err != nil { t.Fatal(err) } defer alerts.Close() timeout := func(d time.Duration) time.Duration { return time.Duration(0) } route := &Route{} dispatcher := NewDispatcher(alerts, route, nil, marker, timeout, testMaintenanceInterval, nil, logger, NewDispatcherMetrics(false, reg)) go dispatcher.Run(time.Now()) dispatcher.Stop() } func TestDispatcherRaceOnFirstAlertNotDeliveredWhenGroupWaitIsZero(t *testing.T) { const numAlerts = 5000 logger := promslog.NewNopLogger() reg := prometheus.NewRegistry() marker := types.NewMarker(reg) alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, reg, nil) if err != nil { t.Fatal(err) } defer alerts.Close() route := &Route{ RouteOpts: RouteOpts{ Receiver: "default", GroupBy: map[model.LabelName]struct{}{"alertname": {}}, GroupWait: 0, GroupInterval: 1 * time.Hour, // Should never hit in this test. RepeatInterval: 1 * time.Hour, // Should never hit in this test. }, } timeout := func(d time.Duration) time.Duration { return d } recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*types.Alert)} dispatcher := NewDispatcher(alerts, route, recorder, marker, timeout, testMaintenanceInterval, nil, logger, NewDispatcherMetrics(false, reg)) go dispatcher.Run(time.Now()) defer dispatcher.Stop() // Push all alerts. for i := range numAlerts { alert := newAlert(model.LabelSet{"alertname": model.LabelValue(fmt.Sprintf("Alert_%d", i))}) require.NoError(t, alerts.Put(context.Background(), alert)) } // Wait until the alerts have been notified or the waiting timeout expires. for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); { if len(recorder.Alerts()) >= numAlerts { break } // Throttle. time.Sleep(10 * time.Millisecond) } // We expect all alerts to be notified immediately, since they all belong to different groups. require.Len(t, recorder.Alerts(), numAlerts) } type limits struct { groups int } func (l limits) MaxNumberOfAggregationGroups() int { return l.groups } func TestDispatcher_DoMaintenance(t *testing.T) { r := prometheus.NewRegistry() marker := types.NewMarker(r) alerts, err := mem.NewAlerts(context.Background(), marker, time.Minute, 0, nil, promslog.NewNopLogger(), r, nil) if err != nil { t.Fatal(err) } route := &Route{ RouteOpts: RouteOpts{ GroupBy: map[model.LabelName]struct{}{"alertname": {}}, GroupWait: 0, GroupInterval: 5 * time.Minute, // Should never hit in this test. }, Idx: 0, } timeout := func(d time.Duration) time.Duration { return d } recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*types.Alert)} ctx := context.Background() dispatcher := NewDispatcher(alerts, route, recorder, marker, timeout, testMaintenanceInterval, nil, promslog.NewNopLogger(), NewDispatcherMetrics(false, r)) // Manually create the routeAggrGroups structure since we are not calling Run(). dispatcher.routeGroupsSlice = make([]routeAggrGroups, route.Idx+1) dispatcher.routeGroupsSlice[route.Idx] = routeAggrGroups{ route: route, } // Insert an aggregation group with one resolved alert. labels := model.LabelSet{"alertname": "1"} aggrGroup1 := newAggrGroup(ctx, labels, route, timeout, types.NewMarker(prometheus.NewRegistry()), promslog.NewNopLogger()) dispatcher.routeGroupsSlice[route.Idx].groups.Store(aggrGroup1.fingerprint(), aggrGroup1) // Add a resolved alert resolvedAlert := &types.Alert{ Alert: model.Alert{ Labels: labels, StartsAt: time.Now().Add(-2 * time.Hour), EndsAt: time.Now().Add(-1 * time.Hour), // Already resolved }, UpdatedAt: time.Now().Add(-1 * time.Hour), } aggrGroup1.alerts.Set(resolvedAlert) // Flush will detect the resolved alert and delete it via DeleteIfNotModified // This is the actual production code path notified := false aggrGroup1.flush(func(alerts ...*types.Alert) bool { require.Len(t, alerts, 1) require.Equal(t, labels, alerts[0].Labels) notified = true return true // Simulate successful notification }) require.True(t, notified, "flush should have called notify function") // Must run otherwise doMaintenance blocks on aggrGroup1.stop(). go aggrGroup1.run(func(context.Context, ...*types.Alert) bool { return true }) // Insert a marker for the aggregation group's group key. marker.SetMuted(route.ID(), aggrGroup1.GroupKey(), []string{"weekends"}) mutedBy, isMuted := marker.Muted(route.ID(), aggrGroup1.GroupKey()) require.True(t, isMuted) require.Equal(t, []string{"weekends"}, mutedBy) // Run the maintenance and the marker should be removed. dispatcher.doMaintenance() mutedBy, isMuted = marker.Muted(route.ID(), aggrGroup1.GroupKey()) require.False(t, isMuted) require.Empty(t, mutedBy) } func TestDispatcher_DeleteResolvedAlertsFromMarker(t *testing.T) { t.Run("successful flush deletes markers for resolved alerts", func(t *testing.T) { ctx := context.Background() marker := types.NewMarker(prometheus.NewRegistry()) labels := model.LabelSet{"alertname": "TestAlert"} route := &Route{ RouteOpts: RouteOpts{ Receiver: "test", GroupBy: map[model.LabelName]struct{}{"alertname": {}}, GroupWait: 0, GroupInterval: time.Minute, RepeatInterval: time.Hour, }, } timeout := func(d time.Duration) time.Duration { return d } logger := promslog.NewNopLogger() // Create an aggregation group ag := newAggrGroup(ctx, labels, route, timeout, marker, logger) // Create test alerts: one active and one resolved now := time.Now() activeAlert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "instance": "1", }, StartsAt: now.Add(-time.Hour), EndsAt: now.Add(time.Hour), // Active alert }, UpdatedAt: now, } resolvedAlert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "instance": "2", }, StartsAt: now.Add(-time.Hour), EndsAt: now.Add(-time.Minute), // Resolved alert }, UpdatedAt: now, } // Insert alerts into the aggregation group ag.insert(ctx, activeAlert) ag.insert(ctx, resolvedAlert) // Set markers for both alerts marker.SetActiveOrSilenced(activeAlert.Fingerprint(), nil) marker.SetActiveOrSilenced(resolvedAlert.Fingerprint(), nil) // Verify markers exist before flush require.True(t, marker.Active(activeAlert.Fingerprint())) require.True(t, marker.Active(resolvedAlert.Fingerprint())) // Create a notify function that succeeds notifyFunc := func(alerts ...*types.Alert) bool { return true } // Flush the alerts ag.flush(notifyFunc) // Verify that the resolved alert's marker was deleted require.True(t, marker.Active(activeAlert.Fingerprint()), "active alert marker should still exist") require.False(t, marker.Active(resolvedAlert.Fingerprint()), "resolved alert marker should be deleted") }) t.Run("failed flush does not delete markers", func(t *testing.T) { ctx := context.Background() marker := types.NewMarker(prometheus.NewRegistry()) labels := model.LabelSet{"alertname": "TestAlert"} route := &Route{ RouteOpts: RouteOpts{ Receiver: "test", GroupBy: map[model.LabelName]struct{}{"alertname": {}}, GroupWait: 0, GroupInterval: time.Minute, RepeatInterval: time.Hour, }, } timeout := func(d time.Duration) time.Duration { return d } logger := promslog.NewNopLogger() // Create an aggregation group ag := newAggrGroup(ctx, labels, route, timeout, marker, logger) // Create a resolved alert now := time.Now() resolvedAlert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "instance": "1", }, StartsAt: now.Add(-time.Hour), EndsAt: now.Add(-time.Minute), // Resolved alert }, UpdatedAt: now, } // Insert alert into the aggregation group ag.insert(ctx, resolvedAlert) // Set marker for the alert marker.SetActiveOrSilenced(resolvedAlert.Fingerprint(), nil) // Verify marker exists before flush require.True(t, marker.Active(resolvedAlert.Fingerprint())) // Create a notify function that fails notifyFunc := func(alerts ...*types.Alert) bool { return false } // Flush the alerts (notify will fail) ag.flush(notifyFunc) // Verify that the marker was NOT deleted due to failed notification require.True(t, marker.Active(resolvedAlert.Fingerprint()), "marker should not be deleted when notify fails") }) t.Run("markers not deleted when alert is modified during flush", func(t *testing.T) { ctx := context.Background() marker := types.NewMarker(prometheus.NewRegistry()) labels := model.LabelSet{"alertname": "TestAlert"} route := &Route{ RouteOpts: RouteOpts{ Receiver: "test", GroupBy: map[model.LabelName]struct{}{"alertname": {}}, GroupWait: 0, GroupInterval: time.Minute, RepeatInterval: time.Hour, }, } timeout := func(d time.Duration) time.Duration { return d } logger := promslog.NewNopLogger() // Create an aggregation group ag := newAggrGroup(ctx, labels, route, timeout, marker, logger) // Create a resolved alert now := time.Now() resolvedAlert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "instance": "1", }, StartsAt: now.Add(-time.Hour), EndsAt: now.Add(-time.Minute), // Resolved alert }, UpdatedAt: now, } // Insert alert into the aggregation group ag.insert(ctx, resolvedAlert) // Set marker for the alert marker.SetActiveOrSilenced(resolvedAlert.Fingerprint(), nil) // Verify marker exists before flush require.True(t, marker.Active(resolvedAlert.Fingerprint())) // Create a notify function that modifies the alert before returning notifyFunc := func(alerts ...*types.Alert) bool { // Simulate the alert being modified (e.g., firing again) during flush modifiedAlert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "instance": "1", }, StartsAt: now.Add(-time.Hour), EndsAt: now.Add(time.Hour), // Active again }, UpdatedAt: now.Add(time.Second), // More recent update } // Update the alert in the store ag.alerts.Set(modifiedAlert) return true } // Flush the alerts ag.flush(notifyFunc) // Verify that the marker was NOT deleted because the alert was modified // during the flush (DeleteIfNotModified should have failed) require.True(t, marker.Active(resolvedAlert.Fingerprint()), "marker should not be deleted when alert is modified during flush") }) } func TestDispatchOnStartup(t *testing.T) { logger := promslog.NewNopLogger() reg := prometheus.NewRegistry() marker := types.NewMarker(reg) alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, reg, nil) if err != nil { t.Fatal(err) } defer alerts.Close() // Set up a route with GroupBy to separate alerts into different aggregation groups. route := &Route{ RouteOpts: RouteOpts{ Receiver: "default", GroupBy: map[model.LabelName]struct{}{"instance": {}}, GroupWait: 1 * time.Second, GroupInterval: 3 * time.Minute, RepeatInterval: 1 * time.Hour, }, } recorder := &recordStage{alerts: make(map[string]map[model.Fingerprint]*types.Alert)} timeout := func(d time.Duration) time.Duration { return d } // Set start time to 3 seconds in the future now := time.Now() startDelay := 2 * time.Second startTime := time.Now().Add(startDelay) dispatcher := NewDispatcher(alerts, route, recorder, marker, timeout, testMaintenanceInterval, nil, logger, NewDispatcherMetrics(false, reg)) go dispatcher.Run(startTime) defer dispatcher.Stop() // Create 2 similar alerts with start times in the past alert1 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"alertname": "TestAlert1", "instance": "1"}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: now.Add(-1 * time.Hour), EndsAt: now.Add(time.Hour), GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: now, Timeout: false, } alert2 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"alertname": "TestAlert2", "instance": "2"}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: now.Add(-1 * time.Hour), EndsAt: now.Add(time.Hour), GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: now, Timeout: false, } // Send alert1 require.NoError(t, alerts.Put(context.Background(), alert1)) var recordedAlerts []*types.Alert // Expect a recorded alert after startTime + GroupWait which is in future require.Eventually(t, func() bool { recordedAlerts = recorder.Alerts() return len(recordedAlerts) == 1 }, startDelay+route.RouteOpts.GroupWait, 500*time.Millisecond) require.Equal(t, alert1.Fingerprint(), recordedAlerts[0].Fingerprint(), "expected alert1 to be dispatched after GroupWait") // Send alert2 require.NoError(t, alerts.Put(context.Background(), alert2)) // Expect a recorded alert after GroupInterval require.Eventually(t, func() bool { recordedAlerts = recorder.Alerts() return len(recordedAlerts) == 2 }, route.RouteOpts.GroupInterval, 100*time.Millisecond) // Sort alerts by fingerprint for deterministic ordering sort.Slice(recordedAlerts, func(i, j int) bool { return recordedAlerts[i].Fingerprint() < recordedAlerts[j].Fingerprint() }) require.Equal(t, alert2.Fingerprint(), recordedAlerts[1].Fingerprint(), "expected alert2 to be dispatched after GroupInterval") // Verify both alerts are present fingerprints := make(map[model.Fingerprint]bool) for _, a := range recordedAlerts { fingerprints[a.Fingerprint()] = true } require.True(t, fingerprints[alert1.Fingerprint()], "expected alert1 to be present") require.True(t, fingerprints[alert2.Fingerprint()], "expected alert2 to be present") } func TestGetGroupLabels(t *testing.T) { alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "job": "prometheus", "instance": "localhost:9090", "severity": "critical", }, }, } t.Run("specific labels", func(t *testing.T) { route := &Route{ RouteOpts: RouteOpts{ GroupBy: map[model.LabelName]struct{}{ "alertname": {}, "job": {}, }, }, } labels := getGroupLabels(alert, route) require.Len(t, labels, 2) require.Equal(t, model.LabelValue("TestAlert"), labels["alertname"]) require.Equal(t, model.LabelValue("prometheus"), labels["job"]) }) t.Run("group by all", func(t *testing.T) { route := &Route{ RouteOpts: RouteOpts{ GroupByAll: true, }, } labels := getGroupLabels(alert, route) require.Len(t, labels, 4) require.Equal(t, alert.Labels, labels) }) } func BenchmarkGetGroupLabels(b *testing.B) { now := time.Now() // Alert with many labels (typical production alert) alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "severity": "critical", "job": "prometheus", "instance": "localhost:9090", "namespace": "monitoring", "cluster": "prod-us-east-1", "datacenter": "dc1", "env": "production", "team": "platform", "service": "alertmanager", }, StartsAt: now.Add(-time.Hour), EndsAt: now.Add(time.Hour), }, } b.Run("specific_labels", func(b *testing.B) { route := &Route{ RouteOpts: RouteOpts{ GroupBy: map[model.LabelName]struct{}{ "alertname": {}, "job": {}, "severity": {}, }, }, } b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _ = getGroupLabels(alert, route) } }) b.Run("group_by_all", func(b *testing.B) { route := &Route{ RouteOpts: RouteOpts{ GroupByAll: true, }, } b.ResetTimer() b.ReportAllocs() for i := 0; i < b.N; i++ { _ = getGroupLabels(alert, route) } }) } ================================================ FILE: dispatch/route.go ================================================ // Copyright 2015 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dispatch import ( "encoding/json" "fmt" "sort" "strconv" "strings" "time" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" ) // DefaultRouteOpts are the defaulting routing options which apply // to the root route of a routing tree. var DefaultRouteOpts = RouteOpts{ GroupWait: 30 * time.Second, GroupInterval: 5 * time.Minute, RepeatInterval: 4 * time.Hour, GroupBy: map[model.LabelName]struct{}{}, GroupByAll: false, MuteTimeIntervals: []string{}, } // A Route is a node that contains definitions of how to handle alerts. type Route struct { parent *Route // The configuration parameters for matches of this route. RouteOpts RouteOpts // Matchers an alert has to fulfill to match // this route. Matchers labels.Matchers // If true, an alert matches further routes on the same level. Continue bool // Children routes of this route. Routes []*Route // Idx contains the index of this route in the config Idx int } // NewRoute returns a new route. func NewRoute(cr *config.Route, parent *Route) *Route { counter := 0 return newRoute(cr, parent, &counter) } func newRoute(cr *config.Route, parent *Route, counter *int) *Route { // Create default and overwrite with configured settings. opts := DefaultRouteOpts if parent != nil { opts = parent.RouteOpts } if cr.Receiver != "" { opts.Receiver = cr.Receiver } if cr.GroupBy != nil { opts.GroupBy = map[model.LabelName]struct{}{} for _, ln := range cr.GroupBy { opts.GroupBy[ln] = struct{}{} } opts.GroupByAll = false } else { if cr.GroupByAll { opts.GroupByAll = cr.GroupByAll } } if cr.GroupWait != nil { opts.GroupWait = time.Duration(*cr.GroupWait) } if cr.GroupInterval != nil { opts.GroupInterval = time.Duration(*cr.GroupInterval) } if cr.RepeatInterval != nil { opts.RepeatInterval = time.Duration(*cr.RepeatInterval) } // Build matchers. var matchers labels.Matchers // cr.Match will be deprecated. This for loop appends matchers. for ln, lv := range cr.Match { matcher, err := labels.NewMatcher(labels.MatchEqual, ln, lv) if err != nil { // This error must not happen because the config already validates the yaml. panic(err) } matchers = append(matchers, matcher) } // cr.MatchRE will be deprecated. This for loop appends regex matchers. for ln, lv := range cr.MatchRE { matcher, err := labels.NewMatcher(labels.MatchRegexp, ln, lv.String()) if err != nil { // This error must not happen because the config already validates the yaml. panic(err) } matchers = append(matchers, matcher) } // We append the new-style matchers. This can be simplified once the deprecated matcher syntax is removed. matchers = append(matchers, cr.Matchers...) sort.Sort(matchers) opts.MuteTimeIntervals = cr.MuteTimeIntervals opts.ActiveTimeIntervals = cr.ActiveTimeIntervals route := &Route{ parent: parent, RouteOpts: opts, Matchers: matchers, Continue: cr.Continue, } // Create child routes first (they get lower indices) route.Routes = newRoutes(cr.Routes, route, counter) // Assign index to this route after all children have been indexed route.Idx = *counter *counter++ return route } // newRoutes returns a slice of routes. func newRoutes(croutes []*config.Route, parent *Route, counter *int) []*Route { res := []*Route{} for _, cr := range croutes { res = append(res, newRoute(cr, parent, counter)) } return res } // Match does a depth-first left-to-right search through the route tree // and returns the matching routing nodes. func (r *Route) Match(lset model.LabelSet) []*Route { if !r.Matchers.Matches(lset) { return nil } var all []*Route for _, cr := range r.Routes { matches := cr.Match(lset) all = append(all, matches...) if matches != nil && !cr.Continue { break } } // If no child nodes were matches, the current node itself is a match. if len(all) == 0 { all = append(all, r) } return all } // Key returns a key for the route. It does not uniquely identify the route in general. func (r *Route) Key() string { b := strings.Builder{} if r.parent != nil { b.WriteString(r.parent.Key()) b.WriteRune('/') } b.WriteString(r.Matchers.String()) return b.String() } // ID returns a unique identifier for the route. func (r *Route) ID() string { b := strings.Builder{} if r.parent != nil { b.WriteString(r.parent.ID()) b.WriteRune('/') } b.WriteString(r.Matchers.String()) if r.parent != nil { for i := range r.parent.Routes { if r == r.parent.Routes[i] { b.WriteRune('/') b.WriteString(strconv.Itoa(i)) break } } } return b.String() } // Walk traverses the route tree in depth-first order. func (r *Route) Walk(visit func(*Route)) { visit(r) for i := range r.Routes { r.Routes[i].Walk(visit) } } // RouteOpts holds various routing options necessary for processing alerts // that match a given route. type RouteOpts struct { // The identifier of the associated notification configuration. Receiver string // What labels to group alerts by for notifications. GroupBy map[model.LabelName]struct{} // Use all alert labels to group. GroupByAll bool // How long to wait to group matching alerts before sending // a notification. GroupWait time.Duration GroupInterval time.Duration RepeatInterval time.Duration // A list of time intervals for which the route is muted. MuteTimeIntervals []string // A list of time intervals for which the route is active. ActiveTimeIntervals []string } func (ro *RouteOpts) String() string { var labels []model.LabelName for ln := range ro.GroupBy { labels = append(labels, ln) } return fmt.Sprintf("", ro.Receiver, labels, ro.GroupByAll, ro.GroupWait, ro.GroupInterval) } // MarshalJSON returns a JSON representation of the routing options. func (ro *RouteOpts) MarshalJSON() ([]byte, error) { v := struct { Receiver string `json:"receiver"` GroupBy model.LabelNames `json:"groupBy"` GroupByAll bool `json:"groupByAll"` GroupWait time.Duration `json:"groupWait"` GroupInterval time.Duration `json:"groupInterval"` RepeatInterval time.Duration `json:"repeatInterval"` }{ Receiver: ro.Receiver, GroupByAll: ro.GroupByAll, GroupWait: ro.GroupWait, GroupInterval: ro.GroupInterval, RepeatInterval: ro.RepeatInterval, } for ln := range ro.GroupBy { v.GroupBy = append(v.GroupBy, ln) } return json.Marshal(&v) } ================================================ FILE: dispatch/route_test.go ================================================ // Copyright 2015 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package dispatch import ( "reflect" "testing" "time" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" "github.com/prometheus/alertmanager/config" ) func TestRouteMatch(t *testing.T) { in := ` receiver: 'notify-def' routes: - match: owner: 'team-A' receiver: 'notify-A' routes: - match: env: 'testing' receiver: 'notify-testing' group_by: [...] - match: env: "production" receiver: 'notify-productionA' group_wait: 1m continue: true - match_re: env: "produ.*" job: ".*" receiver: 'notify-productionB' group_wait: 30s group_interval: 5m repeat_interval: 1h group_by: ['job'] - match_re: owner: 'team-(B|C)' group_by: ['foo', 'bar'] group_wait: 2m receiver: 'notify-BC' - match: group_by: 'role' group_by: ['role'] routes: - match: env: 'testing' receiver: 'notify-testing' routes: - match: wait: 'long' group_wait: 2m ` var ctree config.Route if err := yaml.UnmarshalStrict([]byte(in), &ctree); err != nil { t.Fatal(err) } var ( def = DefaultRouteOpts tree = NewRoute(&ctree, nil) ) lset := func(labels ...string) map[model.LabelName]struct{} { s := map[model.LabelName]struct{}{} for _, ls := range labels { s[model.LabelName(ls)] = struct{}{} } return s } tests := []struct { input model.LabelSet result []*RouteOpts keys []string }{ { input: model.LabelSet{ "owner": "team-A", }, result: []*RouteOpts{ { Receiver: "notify-A", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{owner=\"team-A\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "unset", }, result: []*RouteOpts{ { Receiver: "notify-A", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{owner=\"team-A\"}"}, }, { input: model.LabelSet{ "owner": "team-C", }, result: []*RouteOpts{ { Receiver: "notify-BC", GroupBy: lset("foo", "bar"), GroupByAll: false, GroupWait: 2 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{owner=~\"^(?:team-(B|C))$\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "testing", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset(), GroupByAll: true, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{owner=\"team-A\"}/{env=\"testing\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "production", }, result: []*RouteOpts{ { Receiver: "notify-productionA", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: 1 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, { Receiver: "notify-productionB", GroupBy: lset("job"), GroupByAll: false, GroupWait: 30 * time.Second, GroupInterval: 5 * time.Minute, RepeatInterval: 1 * time.Hour, }, }, keys: []string{ "{}/{owner=\"team-A\"}/{env=\"production\"}", "{}/{owner=\"team-A\"}/{env=~\"^(?:produ.*)$\",job=~\"^(?:.*)$\"}", }, }, { input: model.LabelSet{ "group_by": "role", }, result: []*RouteOpts{ { Receiver: "notify-def", GroupBy: lset("role"), GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}"}, }, { input: model.LabelSet{ "env": "testing", "group_by": "role", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset("role"), GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}/{env=\"testing\"}"}, }, { input: model.LabelSet{ "env": "testing", "group_by": "role", "wait": "long", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset("role"), GroupByAll: false, GroupWait: 2 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}/{env=\"testing\"}/{wait=\"long\"}"}, }, } for _, test := range tests { var matches []*RouteOpts var keys []string for _, r := range tree.Match(test.input) { matches = append(matches, &r.RouteOpts) keys = append(keys, r.Key()) } if !reflect.DeepEqual(matches, test.result) { t.Errorf("\nexpected:\n%v\ngot:\n%v", test.result, matches) } if !reflect.DeepEqual(keys, test.keys) { t.Errorf("\nexpected:\n%v\ngot:\n%v", test.keys, keys) } } } func TestRouteWalk(t *testing.T) { in := ` receiver: 'notify-def' routes: - match: owner: 'team-A' receiver: 'notify-A' routes: - match: env: 'testing' receiver: 'notify-testing' group_by: [...] - match: env: "production" receiver: 'notify-productionA' group_wait: 1m continue: true - match_re: env: "produ.*" job: ".*" receiver: 'notify-productionB' group_wait: 30s group_interval: 5m repeat_interval: 1h group_by: ['job'] - match_re: owner: 'team-(B|C)' group_by: ['foo', 'bar'] group_wait: 2m receiver: 'notify-BC' - match: group_by: 'role' group_by: ['role'] routes: - match: env: 'testing' receiver: 'notify-testing' routes: - match: wait: 'long' group_wait: 2m ` var ctree config.Route if err := yaml.UnmarshalStrict([]byte(in), &ctree); err != nil { t.Fatal(err) } tree := NewRoute(&ctree, nil) expected := []string{ "notify-def", "notify-A", "notify-testing", "notify-productionA", "notify-productionB", "notify-BC", "notify-def", "notify-testing", "notify-testing", } var got []string tree.Walk(func(r *Route) { got = append(got, r.RouteOpts.Receiver) }) if !reflect.DeepEqual(got, expected) { t.Errorf("\nexpected:\n%v\ngot:\n%v", expected, got) } } func TestInheritParentGroupByAll(t *testing.T) { in := ` routes: - match: env: 'parent' group_by: ['...'] routes: - match: env: 'child1' - match: env: 'child2' group_by: ['foo'] ` var ctree config.Route if err := yaml.UnmarshalStrict([]byte(in), &ctree); err != nil { t.Fatal(err) } tree := NewRoute(&ctree, nil) parent := tree.Routes[0] child1 := parent.Routes[0] child2 := parent.Routes[1] require.True(t, parent.RouteOpts.GroupByAll) require.True(t, child1.RouteOpts.GroupByAll) require.False(t, child2.RouteOpts.GroupByAll) } func TestRouteMatchers(t *testing.T) { in := ` receiver: 'notify-def' routes: - matchers: ['{owner="team-A"}', '{level!="critical"}'] receiver: 'notify-A' routes: - matchers: ['{env="testing"}', '{baz!~".*quux"}'] receiver: 'notify-testing' group_by: [...] - matchers: ['{env="production"}'] receiver: 'notify-productionA' group_wait: 1m continue: true - matchers: [ env=~"produ.*", job=~".*"] receiver: 'notify-productionB' group_wait: 30s group_interval: 5m repeat_interval: 1h group_by: ['job'] - matchers: [owner=~"team-(B|C)"] group_by: ['foo', 'bar'] group_wait: 2m receiver: 'notify-BC' - matchers: [group_by="role"] group_by: ['role'] routes: - matchers: ['{env="testing"}'] receiver: 'notify-testing' routes: - matchers: [wait="long"] group_wait: 2m ` var ctree config.Route if err := yaml.UnmarshalStrict([]byte(in), &ctree); err != nil { t.Fatal(err) } var ( def = DefaultRouteOpts tree = NewRoute(&ctree, nil) ) lset := func(labels ...string) map[model.LabelName]struct{} { s := map[model.LabelName]struct{}{} for _, ls := range labels { s[model.LabelName(ls)] = struct{}{} } return s } tests := []struct { input model.LabelSet result []*RouteOpts keys []string }{ { input: model.LabelSet{ "owner": "team-A", }, result: []*RouteOpts{ { Receiver: "notify-A", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{level!=\"critical\",owner=\"team-A\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "unset", }, result: []*RouteOpts{ { Receiver: "notify-A", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{level!=\"critical\",owner=\"team-A\"}"}, }, { input: model.LabelSet{ "owner": "team-C", }, result: []*RouteOpts{ { Receiver: "notify-BC", GroupBy: lset("foo", "bar"), GroupByAll: false, GroupWait: 2 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{owner=~\"team-(B|C)\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "testing", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset(), GroupByAll: true, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{level!=\"critical\",owner=\"team-A\"}/{baz!~\".*quux\",env=\"testing\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "production", }, result: []*RouteOpts{ { Receiver: "notify-productionA", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: 1 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, { Receiver: "notify-productionB", GroupBy: lset("job"), GroupByAll: false, GroupWait: 30 * time.Second, GroupInterval: 5 * time.Minute, RepeatInterval: 1 * time.Hour, }, }, keys: []string{ "{}/{level!=\"critical\",owner=\"team-A\"}/{env=\"production\"}", "{}/{level!=\"critical\",owner=\"team-A\"}/{env=~\"produ.*\",job=~\".*\"}", }, }, { input: model.LabelSet{ "group_by": "role", }, result: []*RouteOpts{ { Receiver: "notify-def", GroupBy: lset("role"), GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}"}, }, { input: model.LabelSet{ "env": "testing", "group_by": "role", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset("role"), GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}/{env=\"testing\"}"}, }, { input: model.LabelSet{ "env": "testing", "group_by": "role", "wait": "long", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset("role"), GroupByAll: false, GroupWait: 2 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}/{env=\"testing\"}/{wait=\"long\"}"}, }, } for _, test := range tests { var matches []*RouteOpts var keys []string for _, r := range tree.Match(test.input) { matches = append(matches, &r.RouteOpts) keys = append(keys, r.Key()) } if !reflect.DeepEqual(matches, test.result) { t.Errorf("\nexpected:\n%v\ngot:\n%v", test.result, matches) } if !reflect.DeepEqual(keys, test.keys) { t.Errorf("\nexpected:\n%v\ngot:\n%v", test.keys, keys) } } } func TestRouteMatchersAndMatch(t *testing.T) { in := ` receiver: 'notify-def' routes: - matchers: ['{owner="team-A"}', '{level!="critical"}'] receiver: 'notify-A' routes: - matchers: ['{env="testing"}', '{baz!~".*quux"}'] receiver: 'notify-testing' group_by: [...] - match: env: "production" receiver: 'notify-productionA' group_wait: 1m continue: true - matchers: [ env=~"produ.*", job=~".*"] receiver: 'notify-productionB' group_wait: 30s group_interval: 5m repeat_interval: 1h group_by: ['job'] - match_re: owner: 'team-(B|C)' group_by: ['foo', 'bar'] group_wait: 2m receiver: 'notify-BC' - matchers: [group_by="role"] group_by: ['role'] routes: - matchers: ['{env="testing"}'] receiver: 'notify-testing' routes: - matchers: [wait="long"] group_wait: 2m ` var ctree config.Route if err := yaml.UnmarshalStrict([]byte(in), &ctree); err != nil { t.Fatal(err) } var ( def = DefaultRouteOpts tree = NewRoute(&ctree, nil) ) lset := func(labels ...string) map[model.LabelName]struct{} { s := map[model.LabelName]struct{}{} for _, ls := range labels { s[model.LabelName(ls)] = struct{}{} } return s } tests := []struct { input model.LabelSet result []*RouteOpts keys []string }{ { input: model.LabelSet{ "owner": "team-A", }, result: []*RouteOpts{ { Receiver: "notify-A", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{level!=\"critical\",owner=\"team-A\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "unset", }, result: []*RouteOpts{ { Receiver: "notify-A", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{level!=\"critical\",owner=\"team-A\"}"}, }, { input: model.LabelSet{ "owner": "team-C", }, result: []*RouteOpts{ { Receiver: "notify-BC", GroupBy: lset("foo", "bar"), GroupByAll: false, GroupWait: 2 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{owner=~\"^(?:team-(B|C))$\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "testing", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset(), GroupByAll: true, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{level!=\"critical\",owner=\"team-A\"}/{baz!~\".*quux\",env=\"testing\"}"}, }, { input: model.LabelSet{ "owner": "team-A", "env": "production", }, result: []*RouteOpts{ { Receiver: "notify-productionA", GroupBy: def.GroupBy, GroupByAll: false, GroupWait: 1 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, { Receiver: "notify-productionB", GroupBy: lset("job"), GroupByAll: false, GroupWait: 30 * time.Second, GroupInterval: 5 * time.Minute, RepeatInterval: 1 * time.Hour, }, }, keys: []string{ "{}/{level!=\"critical\",owner=\"team-A\"}/{env=\"production\"}", "{}/{level!=\"critical\",owner=\"team-A\"}/{env=~\"produ.*\",job=~\".*\"}", }, }, { input: model.LabelSet{ "group_by": "role", }, result: []*RouteOpts{ { Receiver: "notify-def", GroupBy: lset("role"), GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}"}, }, { input: model.LabelSet{ "env": "testing", "group_by": "role", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset("role"), GroupByAll: false, GroupWait: def.GroupWait, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}/{env=\"testing\"}"}, }, { input: model.LabelSet{ "env": "testing", "group_by": "role", "wait": "long", }, result: []*RouteOpts{ { Receiver: "notify-testing", GroupBy: lset("role"), GroupByAll: false, GroupWait: 2 * time.Minute, GroupInterval: def.GroupInterval, RepeatInterval: def.RepeatInterval, }, }, keys: []string{"{}/{group_by=\"role\"}/{env=\"testing\"}/{wait=\"long\"}"}, }, } for _, test := range tests { var matches []*RouteOpts var keys []string for _, r := range tree.Match(test.input) { matches = append(matches, &r.RouteOpts) keys = append(keys, r.Key()) } if !reflect.DeepEqual(matches, test.result) { t.Errorf("\nexpected:\n%v\ngot:\n%v", test.result, matches) } if !reflect.DeepEqual(keys, test.keys) { t.Errorf("\nexpected:\n%v\ngot:\n%v", test.keys, keys) } } } func TestRouteID(t *testing.T) { in := ` receiver: default routes: - continue: true matchers: - foo=bar receiver: test1 routes: - matchers: - bar=baz - continue: true matchers: - foo=bar receiver: test1 routes: - matchers: - bar=baz - continue: true matchers: - foo=bar receiver: test2 routes: - matchers: - bar=baz - continue: true matchers: - bar=baz receiver: test3 routes: - matchers: - baz=qux - matchers: - qux=corge - continue: true matchers: - qux=~"[a-zA-Z0-9]+" - continue: true matchers: - corge!~"[0-9]+" ` cr := config.Route{} require.NoError(t, yaml.Unmarshal([]byte(in), &cr)) r := NewRoute(&cr, nil) expected := []string{ "{}", "{}/{foo=\"bar\"}/0", "{}/{foo=\"bar\"}/0/{bar=\"baz\"}/0", "{}/{foo=\"bar\"}/1", "{}/{foo=\"bar\"}/1/{bar=\"baz\"}/0", "{}/{foo=\"bar\"}/2", "{}/{foo=\"bar\"}/2/{bar=\"baz\"}/0", "{}/{bar=\"baz\"}/3", "{}/{bar=\"baz\"}/3/{baz=\"qux\"}/0", "{}/{bar=\"baz\"}/3/{qux=\"corge\"}/1", "{}/{qux=~\"[a-zA-Z0-9]+\"}/4", "{}/{corge!~\"[0-9]+\"}/5", } var actual []string r.Walk(func(r *Route) { actual = append(actual, r.ID()) }) require.ElementsMatch(t, actual, expected) } func TestRouteIndices(t *testing.T) { in := ` receiver: 'notify-def' routes: - match: owner: 'team-A' receiver: 'notify-A' routes: - match: env: 'testing' receiver: 'notify-testing' group_by: [...] - match: env: "production" receiver: 'notify-productionA' group_wait: 1m continue: true - match_re: env: "produ.*" job: ".*" receiver: 'notify-productionB' group_wait: 30s group_interval: 5m repeat_interval: 1h group_by: ['job'] - match_re: owner: 'team-(B|C)' group_by: ['foo', 'bar'] group_wait: 2m receiver: 'notify-BC' - match: group_by: 'role' group_by: ['role'] routes: - match: env: 'testing' receiver: 'notify-testing' routes: - match: wait: 'long' group_wait: 2m ` var ctree config.Route if err := yaml.UnmarshalStrict([]byte(in), &ctree); err != nil { t.Fatal(err) } tree := NewRoute(&ctree, nil) // Collect all indices var indices []int var totalNodes int tree.Walk(func(r *Route) { indices = append(indices, r.Idx) totalNodes++ }) // All indices are unique seenIndices := make(map[int]bool) for _, idx := range indices { require.False(t, seenIndices[idx], "Index %d appears more than once", idx) seenIndices[idx] = true } // Root index equals total nodes - 1 require.Equal(t, totalNodes-1, tree.Idx, "Root index should equal total nodes - 1") // All indices are in range [0, totalNodes) for _, idx := range indices { require.GreaterOrEqual(t, idx, 0, "Index should be >= 0") require.Less(t, idx, totalNodes, "Index should be < total nodes") } } ================================================ FILE: doc/alertmanager-mixin/.gitignore ================================================ vendor dashboards_out ================================================ FILE: doc/alertmanager-mixin/.lint ================================================ exclusions: target-instance-rule: reason: no need to have every query contains two matchers within every selector - `{job=~"$job", instance=~"$instance"}` template-job-rule: entries: - dashboard: Alertmanager / Overview reason: multi-select is not always required template-instance-rule: entries: - dashboard: Alertmanager / Overview reason: multi-select is not always required panel-units-rule: entries: - dashboard: Alertmanager / Overview reason: Dashboard does not benefit from specific unit specification. ================================================ FILE: doc/alertmanager-mixin/Makefile ================================================ JSONNET_FMT := jsonnetfmt -n 2 --max-blank-lines 1 --string-style s --comment-style s ALERTMANAGER_ALERTS := alertmanager_alerts.yaml default: vendor build dashboards_out all: fmt build vendor: jb install fmt: find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ xargs -n 1 -- $(JSONNET_FMT) -i lint: build find . -name 'vendor' -prune -o -name '*.libsonnet' -print -o -name '*.jsonnet' -print | \ while read f; do \ $(JSONNET_FMT) "$$f" | diff -u "$$f" -; \ done mixtool lint mixin.libsonnet dashboards_out: mixin.libsonnet config.libsonnet $(wildcard dashboards/*) @mkdir -p dashboards_out jsonnet -J vendor -m dashboards_out dashboards.jsonnet build: vendor mixtool generate alerts mixin.libsonnet > $(ALERTMANAGER_ALERTS) clean: rm -rf $(ALERTMANAGER_ALERTS) ================================================ FILE: doc/alertmanager-mixin/README.md ================================================ # Alertmanager Mixin The Alertmanager Mixin is a set of configurable, reusable, and extensible alerts (and eventually dashboards) for Alertmanager. The alerts are designed to monitor a cluster of Alertmanager instances. To make them work as expected, the Prometheus server the alerts are evaluated on has to scrape all Alertmanager instances of the cluster, even if those instances are distributed over different locations. All Alertmanager instances in the same Alertmanager cluster must have the same `job` label. In turn, if monitoring multiple different Alertmanager clusters, instances from different clusters must have a different `job` label. The most basic use of the Alertmanager Mixin is to create a YAML file with the alerts from it. To do so, you need to have `jsonnetfmt` and `mixtool` installed. If you have a working Go development environment, it's easiest to run the following: ```bash $ go get github.com/monitoring-mixins/mixtool/cmd/mixtool $ go get github.com/google/go-jsonnet/cmd/jsonnetfmt ``` Edit `config.libsonnet` to match your environment and then build `alertmanager_alerts.yaml` with the alerts by running: ```bash $ make build ``` For instructions on more advanced uses of mixins, see https://github.com/monitoring-mixins/docs. ================================================ FILE: doc/alertmanager-mixin/alerts.jsonnet ================================================ std.manifestYamlDoc((import 'mixin.libsonnet').prometheusAlerts) ================================================ FILE: doc/alertmanager-mixin/alerts.libsonnet ================================================ { prometheusAlerts+:: { groups+: [ { name: 'alertmanager.rules', rules: [ { alert: 'AlertmanagerFailedReload', expr: ||| # Without max_over_time, failed scrapes could create false negatives, see # https://www.robustperception.io/alerting-on-gauges-in-prometheus-2-0 for details. max_over_time(alertmanager_config_last_reload_successful{%(alertmanagerSelector)s}[5m]) == 0 ||| % $._config, 'for': '10m', labels: { severity: 'critical', }, annotations: { summary: 'Reloading an Alertmanager configuration has failed.', description: 'Configuration has failed to load for %(alertmanagerName)s.' % $._config, }, }, { alert: 'AlertmanagerMembersInconsistent', expr: ||| # Without max_over_time, failed scrapes could create false negatives, see # https://www.robustperception.io/alerting-on-gauges-in-prometheus-2-0 for details. max_over_time(alertmanager_cluster_members{%(alertmanagerSelector)s}[5m]) < on (%(alertmanagerClusterLabels)s) group_left count by (%(alertmanagerClusterLabels)s) (max_over_time(alertmanager_cluster_members{%(alertmanagerSelector)s}[5m])) ||| % $._config, 'for': '15m', labels: { severity: 'critical', }, annotations: { summary: 'A member of an Alertmanager cluster has not found all other cluster members.', description: 'Alertmanager %(alertmanagerName)s has only found {{ $value }} members of the %(alertmanagerClusterName)s cluster.' % $._config, }, }, { alert: 'AlertmanagerFailedToSendAlerts', expr: ||| ( rate(alertmanager_notifications_failed_total{%(alertmanagerSelector)s}[15m]) / ignoring (reason) group_left rate(alertmanager_notifications_total{%(alertmanagerSelector)s}[15m]) ) > 0.01 ||| % $._config, 'for': '5m', labels: { severity: 'warning', }, annotations: { summary: 'An Alertmanager instance failed to send notifications.', description: 'Alertmanager %(alertmanagerName)s failed to send {{ $value | humanizePercentage }} of notifications to {{ $labels.integration }}.' % $._config, }, }, { alert: 'AlertmanagerClusterFailedToSendAlerts', expr: ||| min by (%(alertmanagerClusterLabels)s, integration) ( rate(alertmanager_notifications_failed_total{%(alertmanagerSelector)s, integration=~`%(alertmanagerCriticalIntegrationsRegEx)s`}[15m]) / ignoring (reason) group_left rate(alertmanager_notifications_total{%(alertmanagerSelector)s, integration=~`%(alertmanagerCriticalIntegrationsRegEx)s`}[15m]) > 0 ) > 0.01 ||| % $._config, 'for': '5m', labels: { severity: 'critical', }, annotations: { summary: 'All Alertmanager instances in a cluster failed to send notifications to a critical integration.', description: 'The minimum notification failure rate to {{ $labels.integration }} sent from any instance in the %(alertmanagerClusterName)s cluster is {{ $value | humanizePercentage }}.' % $._config, }, }, { alert: 'AlertmanagerClusterFailedToSendAlerts', expr: ||| min by (%(alertmanagerClusterLabels)s, integration) ( rate(alertmanager_notifications_failed_total{%(alertmanagerSelector)s, integration!~`%(alertmanagerCriticalIntegrationsRegEx)s`}[15m]) / ignoring (reason) group_left rate(alertmanager_notifications_total{%(alertmanagerSelector)s, integration!~`%(alertmanagerCriticalIntegrationsRegEx)s`}[15m]) > 0 ) > 0.01 ||| % $._config, 'for': '5m', labels: { severity: 'warning', }, annotations: { summary: 'All Alertmanager instances in a cluster failed to send notifications to a non-critical integration.', description: 'The minimum notification failure rate to {{ $labels.integration }} sent from any instance in the %(alertmanagerClusterName)s cluster is {{ $value | humanizePercentage }}.' % $._config, }, }, { alert: 'AlertmanagerConfigInconsistent', expr: ||| count by (%(alertmanagerClusterLabels)s) ( count_values by (%(alertmanagerClusterLabels)s) ("config_hash", alertmanager_config_hash{%(alertmanagerSelector)s}) ) != 1 ||| % $._config, 'for': '20m', // A config change across an Alertmanager cluster can take its time. But it's really bad if it persists for too long. labels: { severity: 'critical', }, annotations: { summary: 'Alertmanager instances within the same cluster have different configurations.', description: 'Alertmanager instances within the %(alertmanagerClusterName)s cluster have different configurations.' % $._config, }, }, // Both the following critical alerts, AlertmanagerClusterDown and // AlertmanagerClusterCrashlooping, fire if a whole cluster is // unhealthy. It is implied that a generic warning alert is in place // for individual instances being down or crashlooping. { alert: 'AlertmanagerClusterDown', expr: ||| ( count by (%(alertmanagerClusterLabels)s) ( avg_over_time(up{%(alertmanagerSelector)s}[5m]) < 0.5 ) / count by (%(alertmanagerClusterLabels)s) ( up{%(alertmanagerSelector)s} ) ) >= 0.5 ||| % $._config, 'for': '5m', labels: { severity: 'critical', }, annotations: { summary: 'Half or more of the Alertmanager instances within the same cluster are down.', description: '{{ $value | humanizePercentage }} of Alertmanager instances within the %(alertmanagerClusterName)s cluster have been up for less than half of the last 5m.' % $._config, }, }, { alert: 'AlertmanagerClusterCrashlooping', expr: ||| ( count by (%(alertmanagerClusterLabels)s) ( changes(process_start_time_seconds{%(alertmanagerSelector)s}[10m]) > 4 ) / count by (%(alertmanagerClusterLabels)s) ( up{%(alertmanagerSelector)s} ) ) >= 0.5 ||| % $._config, 'for': '5m', labels: { severity: 'critical', }, annotations: { summary: 'Half or more of the Alertmanager instances within the same cluster are crashlooping.', description: '{{ $value | humanizePercentage }} of Alertmanager instances within the %(alertmanagerClusterName)s cluster have restarted at least 5 times in the last 10m.' % $._config, }, }, ], }, ], }, } ================================================ FILE: doc/alertmanager-mixin/config.libsonnet ================================================ { _config+:: { local c = self, // alertmanagerSelector is inserted as part of the label selector in // PromQL queries to identify metrics collected from Alertmanager // servers. alertmanagerSelector: 'job="alertmanager"', // alertmanagerClusterLabels is a string with comma-separated // labels that are common labels of instances belonging to the // same Alertmanager cluster. Include not only enough labels to // identify cluster members, but also all common labels you want // to keep for resulting cluster-level alerts. alertmanagerClusterLabels: 'job', // alertmanagerNameLabels is a string with comma-separated // labels used to identify different alertmanagers within the same // Alertmanager HA cluster. // If you run Alertmanager on Kubernetes with the Prometheus // Operator, you can make use of the configured target labels for // nicer naming: // alertmanagerNameLabels: 'namespace,pod' alertmanagerNameLabels: 'instance', // alertmanagerName is an identifier for alerts. By default, it is built from 'alertmanagerNameLabels'. alertmanagerName: std.join('/', ['{{$labels.%s}}' % [label] for label in std.split(c.alertmanagerNameLabels, ',')]), // alertmanagerClusterName is inserted into annotations to name an // Alertmanager cluster. All labels used here must also be present // in alertmanagerClusterLabels above. alertmanagerClusterName: '{{$labels.job}}', // alertmanagerCriticalIntegrationsRegEx is matched against the // value of the `integration` label to determine if the // AlertmanagerClusterFailedToSendAlerts is critical or merely a // warning. This can be used to avoid paging about a failed // integration that is itself not used for critical alerts. // Example: @'pagerduty|webhook' alertmanagerCriticalIntegrationsRegEx: @'.*', dashboardNamePrefix: 'Alertmanager / ', dashboardTags: ['alertmanager-mixin'], }, } ================================================ FILE: doc/alertmanager-mixin/dashboards/overview.libsonnet ================================================ local grafana = import 'github.com/grafana/grafonnet/gen/grafonnet-latest/main.libsonnet'; local dashboard = grafana.dashboard; local prometheus = grafana.query.prometheus; local variable = dashboard.variable; local panel = grafana.panel; local row = panel.row; { grafanaDashboards+:: { local amQuerySelector = std.join(',', ['%s=~"$%s"' % [label, label] for label in std.split($._config.alertmanagerClusterLabels, ',')]), local amNameDashboardLegend = std.join('/', ['{{%s}}' % [label] for label in std.split($._config.alertmanagerNameLabels, ',')]), local datasource = variable.datasource.new('datasource', 'prometheus') + variable.datasource.generalOptions.withLabel('Data Source') + variable.datasource.generalOptions.withCurrent('Prometheus') + variable.datasource.generalOptions.showOnDashboard.withLabelAndValue(), local alertmanagerClusterSelectorVariables = [ variable.query.new(label) + variable.query.generalOptions.withLabel(label) + variable.query.withDatasourceFromVariable(datasource) + variable.query.queryTypes.withLabelValues(label, metric='alertmanager_alerts') + variable.query.generalOptions.withCurrent('') + variable.query.refresh.onTime() + variable.query.selectionOptions.withIncludeAll(false) + variable.query.withSort(type='alphabetical') for label in std.split($._config.alertmanagerClusterLabels, ',') ], local integrationVariable = variable.query.new('integration') + variable.query.withDatasourceFromVariable(datasource) + variable.query.queryTypes.withLabelValues('integration', metric='alertmanager_notifications_total{integration=~"%s"}' % $._config.alertmanagerCriticalIntegrationsRegEx) + variable.query.generalOptions.withCurrent('$__all') + variable.datasource.generalOptions.showOnDashboard.withNothing() + variable.query.refresh.onTime() + variable.query.selectionOptions.withIncludeAll(true) + variable.query.withSort(type='alphabetical'), local panelTimeSeriesStdOptions = {} + panel.timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + panel.timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + panel.timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + panel.timeSeries.options.legend.withShowLegend(false) + panel.timeSeries.options.tooltip.withMode('multi') + panel.timeSeries.queryOptions.withDatasource('prometheus', '$datasource'), 'alertmanager-overview.json': local alerts = panel.timeSeries.new('Alerts') + panel.timeSeries.panelOptions.withDescription('current set of alerts stored in the Alertmanager') + panel.timeSeries.standardOptions.withUnit('none') + panelTimeSeriesStdOptions + panel.timeSeries.queryOptions.withTargets([ prometheus.new( '$datasource', 'sum(alertmanager_alerts{%(amQuerySelector)s}) by (%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s)' % $._config { amQuerySelector: amQuerySelector }, ) + prometheus.withIntervalFactor(2) + prometheus.withLegendFormat('%(amNameDashboardLegend)s' % $._config { amNameDashboardLegend: amNameDashboardLegend }), ]); local alertsRate = panel.timeSeries.new('Alerts receive rate') + panel.timeSeries.panelOptions.withDescription('rate of successful and invalid alerts received by the Alertmanager') + panel.timeSeries.standardOptions.withUnit('ops') + panelTimeSeriesStdOptions + panel.timeSeries.queryOptions.withTargets([ prometheus.new( '$datasource', 'sum(rate(alertmanager_alerts_received_total{%(amQuerySelector)s}[$__rate_interval])) by (%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s)' % $._config { amQuerySelector: amQuerySelector }, ) + prometheus.withIntervalFactor(2) + prometheus.withLegendFormat('%(amNameDashboardLegend)s Received' % $._config { amNameDashboardLegend: amNameDashboardLegend }), prometheus.new( '$datasource', 'sum(rate(alertmanager_alerts_invalid_total{%(amQuerySelector)s}[$__rate_interval])) by (%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s)' % $._config { amQuerySelector: amQuerySelector }, ) + prometheus.withIntervalFactor(2) + prometheus.withLegendFormat('%(amNameDashboardLegend)s Invalid' % $._config { amNameDashboardLegend: amNameDashboardLegend }), ]); local notifications = panel.timeSeries.new('$integration: Notifications Send Rate') + panel.timeSeries.panelOptions.withDescription('rate of successful and invalid notifications sent by the Alertmanager') + panel.timeSeries.standardOptions.withUnit('ops') + panelTimeSeriesStdOptions + panel.timeSeries.panelOptions.withRepeat('integration') + panel.timeSeries.queryOptions.withTargets([ prometheus.new( '$datasource', 'sum(rate(alertmanager_notifications_total{%(amQuerySelector)s, integration="$integration"}[$__rate_interval])) by (integration,%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s)' % $._config { amQuerySelector: amQuerySelector }, ) + prometheus.withIntervalFactor(2) + prometheus.withLegendFormat('%(amNameDashboardLegend)s Total' % $._config { amNameDashboardLegend: amNameDashboardLegend }), prometheus.new( '$datasource', 'sum(rate(alertmanager_notifications_failed_total{%(amQuerySelector)s, integration="$integration"}[$__rate_interval])) by (integration,%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s)' % $._config { amQuerySelector: amQuerySelector }, ) + prometheus.withIntervalFactor(2) + prometheus.withLegendFormat('%(amNameDashboardLegend)s Failed' % $._config { amNameDashboardLegend: amNameDashboardLegend }), ]); local notificationDuration = panel.timeSeries.new('$integration: Notification Duration') + panel.timeSeries.panelOptions.withDescription('latency of notifications sent by the Alertmanager') + panel.timeSeries.standardOptions.withUnit('s') + panelTimeSeriesStdOptions + panel.timeSeries.panelOptions.withRepeat('integration') + panel.timeSeries.queryOptions.withTargets([ prometheus.new( '$datasource', ||| histogram_quantile(0.99, sum(rate(alertmanager_notification_latency_seconds_bucket{%(amQuerySelector)s, integration="$integration"}[$__rate_interval])) by (le,%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s) ) ||| % $._config { amQuerySelector: amQuerySelector }, ) + prometheus.withIntervalFactor(2) + prometheus.withLegendFormat('%(amNameDashboardLegend)s 99th Percentile' % $._config { amNameDashboardLegend: amNameDashboardLegend }), prometheus.new( '$datasource', ||| histogram_quantile(0.50, sum(rate(alertmanager_notification_latency_seconds_bucket{%(amQuerySelector)s, integration="$integration"}[$__rate_interval])) by (le,%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s) ) ||| % $._config { amQuerySelector: amQuerySelector }, ) + prometheus.withIntervalFactor(2) + prometheus.withLegendFormat('%(amNameDashboardLegend)s Median' % $._config { amNameDashboardLegend: amNameDashboardLegend }), prometheus.new( '$datasource', ||| sum(rate(alertmanager_notification_latency_seconds_sum{%(amQuerySelector)s, integration="$integration"}[$__rate_interval])) by (%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s) / sum(rate(alertmanager_notification_latency_seconds_count{%(amQuerySelector)s, integration="$integration"}[$__rate_interval])) by (%(alertmanagerClusterLabels)s,%(alertmanagerNameLabels)s) ||| % $._config { amQuerySelector: amQuerySelector }, ) + prometheus.withIntervalFactor(2) + prometheus.withLegendFormat('%(amNameDashboardLegend)s Average' % $._config { amNameDashboardLegend: amNameDashboardLegend }), ]); dashboard.new('%sOverview' % $._config.dashboardNamePrefix) + dashboard.time.withFrom('now-1h') + dashboard.withTags($._config.dashboardTags) + dashboard.withTimezone('utc') + dashboard.timepicker.withRefreshIntervals('30s') + dashboard.graphTooltip.withSharedCrosshair() + dashboard.withUid('alertmanager-overview') + dashboard.withVariables( [datasource] + alertmanagerClusterSelectorVariables + [integrationVariable] ) + dashboard.withPanels( grafana.util.grid.makeGrid([ row.new('Alerts') + row.withPanels([ alerts, alertsRate ]), row.new('Notifications') + row.withPanels([ notifications, notificationDuration ]) ], panelWidth=12, panelHeight=7) ) }, } ================================================ FILE: doc/alertmanager-mixin/dashboards.jsonnet ================================================ local dashboards = (import 'mixin.libsonnet').grafanaDashboards; { [name]: dashboards[name] for name in std.objectFields(dashboards) } ================================================ FILE: doc/alertmanager-mixin/dashboards.libsonnet ================================================ (import './dashboards/overview.libsonnet') ================================================ FILE: doc/alertmanager-mixin/jsonnetfile.json ================================================ { "version": 1, "dependencies": [ { "source": { "git": { "remote": "https://github.com/grafana/grafonnet.git", "subdir": "gen/grafonnet-latest" } }, "version": "main" } ], "legacyImports": false } ================================================ FILE: doc/alertmanager-mixin/jsonnetfile.lock.json ================================================ { "version": 1, "dependencies": [ { "source": { "git": { "remote": "https://github.com/grafana/grafonnet.git", "subdir": "gen/grafonnet-latest" } }, "version": "1ce5aec95ce32336fe47c8881361847c475b5254", "sum": "64fMUPI3frXGj4X1FqFd1t7r04w3CUSmXaDcJ23EYbQ=" }, { "source": { "git": { "remote": "https://github.com/grafana/grafonnet.git", "subdir": "gen/grafonnet-v11.1.0" } }, "version": "1ce5aec95ce32336fe47c8881361847c475b5254", "sum": "41w7p/rwrNsITqNHMXtGSJAfAyKmnflg6rFhKBduUxM=" }, { "source": { "git": { "remote": "https://github.com/jsonnet-libs/docsonnet.git", "subdir": "doc-util" } }, "version": "6ac6c69685b8c29c54515448eaca583da2d88150", "sum": "BrAL/k23jq+xy9oA7TWIhUx07dsA/QLm3g7ktCwe//U=" }, { "source": { "git": { "remote": "https://github.com/jsonnet-libs/xtd.git", "subdir": "" } }, "version": "63d430b69a95741061c2f7fc9d84b1a778511d9c", "sum": "qiZi3axUSXCVzKUF83zSAxklwrnitMmrDK4XAfjPMdE=" } ], "legacyImports": false } ================================================ FILE: doc/alertmanager-mixin/mixin.libsonnet ================================================ (import 'config.libsonnet') + (import 'alerts.libsonnet') + (import 'dashboards.libsonnet') ================================================ FILE: doc/arch.xml ================================================ 7V1bk9o6Ev41VO0+QPlu8ziXkHOqkq2pzFZlz6OwBWjjQaxtMpPz61eyLWOrZTAgG5hM5iEgy7Ksr7vVVzGyH17ePidos/pKIxyPLCN6G9mPI8syHctj//GWX0WLbxlFwzIhUdlp1/BM/sZlo+i2JRFOGx0zSuOMbJqNIV2vcZg12lCS0NdmtwWNm0/doCUGDc8himHrdxJlq6I1cI1d+x+YLFfiyaZRXnlBonPZkK5QRF9rTfankf2QUJoVn17eHnDMF0+sS3HfrOVqNbEEr7MuNzg4jEIrChfRFOEpDsemNS3G+Inibfm6/6IZWZAQZYSu2ZUnssExWePyDbJfYlleVyTDzxsU8u+vDPqRfb/KXmL2zWQfaYLWfF3vFySOH2hMk/w2ezb79PDAJni/TFBE2MTFtTVd8+7VGhm8T4zStPycZgn9gWsjBQb/Y1cilK5wVD73J04yNvv4LibLNWvLKJ/Ygq6z53LuvNeKJuRv1obEdPMOBemZdvl9hl5IzIn2LiG84325VuwJ+K0VArMClnEEpi84S36xLuUNjiCbkhksv/j6uqMsyy67rGpU5UztkqJLal5WQ+8AZx9KzFvw9xbIwf4cG35gRNF8bHsA/pHlxeyZ93P2Yck/fMMhJuydxQX2kOpa1TmRWyLyUzR9Gn9FJK7dXrt2LFUVRCDY0DyKXu7zv1YSSeh2HeVkxO9G5bWQ4crevT+CcCWCcCwHUISvIghTAz0AeeC5gCCeccYaCqnAxPAHYDJg/rQTYIKr9TIwxEvBgSfxtMyoYNTveL6i9Ee7DLgRVu9N1AtmOpazdRDKIkBTy4yiaYiCIPDHJtzoASBwxetwrNCG93t5W3I1b7KI6Wu4Qkk22SQ0xByLexWifa2uZR/aR01TxYX++YtrgaV8JOkGZeFqx1F1btClOM3sT94exUlmAiP/dxwnddOeesN0elA5UoJquhpYxjQBrJ9pmpINhJRtiVl8tEZ8jkCz7el0NlMA9EKiiD9/MJnmNRAKzG4izdWAD9zq7p7+PB+ENjYbiuidpiATLFBf0UBB8pYOkg/gksbsXVjTZ7zGCcpoktbJv7bQ3v+23GzNl2Wc5pbTHetgWpu3QgUor8tKAe9/1kD/eEr4m67wNv1nTdMoxm0RucPtbHU70tJDIHZTKNr2cDud0BpqS4mjJRZsQZNsRZd0jeJPu1ZJEtUWGq+jO+6PYV/nMQ1/FE0zEjeZs8aMziP/y/uxmf+HDzlxxde/yifgN5IVlxy//PqXeCL7/IQTwl6c2wSHRCN/t/0gsaWg2yQUvUr+yVCyxNVepAYzwTHKmM7dGEyFTXnrEyU5o5RE4PsNIjBla6KYQnlT3QUkjRM05bcvDVO8HRgmp5PqZU60LS3obHgmMV6z1bQMxtQ/ScT1J5l1S+6MUIZSJo9wCzfW6CxGcxw/0ZTkLqyaSSg2zy9Sh2oTBUakvN3OaZbRlx53A9NvcrvpuxMXMLyn4HcdOhAETdAZBA1CdTmDbih1xxTsM4StD7GAGtA3us1+SyQsu5vm2RMS0Jr+jkh2NA7H6/+dlI6DYqzACpJBc+vWAlvTB2IFAzrLAGwOtNxb9xvykoeF6nCpV7FtU6m2inw7ukfhj2W+vDVUF/k/1iV/2F26KcJXuTtTfFmQNw7IfTmfx1WW8bjXHV8GaxZGa2tCQrpeEAZcMgnZE60Z3yvZf7ydqe+zIgg2RutovF1zt1+K4jEK+TzTMe80TrmPd2a5Hl9TtseyzTqYbNbLo2i08jccpFGzRqMxXmQ55WWoXDgz0ER8nt1UmlS2laWwrUTbOcRnL4w5ngZGEGKEkeOMXftDZJzkX7+syHB9gNA5FlBlqriSpZIyBT4T1lHl0WNtpXlkdLGf/ouz7FeJKtpmNPcmigl+oRzBdleGzKD7TK7RSeZSu3ZZN6Da9B7tJpUj2UKW8M8fsKm0GEMeFAc6Ce36yEkXybgKm1vRa3omyZwuMXwHIHsXJxhFv6AjOc3ffdYb8gLXOvSSJ6Ykjh3yxm0j79t7dxdjYjWYvgqMnSpMRBe6WORalGY54UJL41tFo9dh8gHVIWWzIOvll1yzy4e8TMDVHjIyDyW8/yHhT+JzrxufG2paGEDCezBs8oij7eaKePJKcmNsu1vKQz8c6EPf5Yfs7IrcRWWnDx01N5KGNlgy0WXxgdZLjgyf8EckR8g+iaMU6V/DxXEMiJgI9X8AVl6VQm8i7WIAvIC30BnA7WRcpRa5czs1nE75zHVpmIIZGhqmnDHZRi1nep3kHLdqMge8TgwC9KvWbcM7pHueYzSf400lgiwGPNVShRQLleLn7TwNEzKHmXKMSbMmiSY4JX+jed6BI1++Huvt3o/cxy4CohIlHQIuMtUdJ1PKSqNyuqOqvqdOiC0svc8xYYs1rHwTo7NcE+XI46bHw2ver8VvAaMdBsBcg/wy69Jr55VukRbXJ9t0yS+B4QG3+VAWsh+EbhRMLR8ZwXQauWMRHteVNtaOcVd4DuLcTmIa88XgHmNBIM1+9h3HkPcdKdqhL/UL0IOoVeqXHnxXJ0MPQhCuOzcs17RCFM3nxjwcuxckCNMdjiCc/tXbQzHMfkW/LlEvROmBcFdwKVEvMvs/kGwi2Zr/sR9I73JAekMBeX1g9RA/VstcV5K5Tn8pBxDggTJZbgPgs8NHLQAHLTWfQwAMLfA/1ysyJxmFvr33nyFsCq3zInXZMPj7EG/T7KoKRKsMwZspEDVdeyLF+I2poj5CHJZRx1kETPTiDHnuCeMExqfYOGST4tGu3CyM6ZYn9mpL+NxT73tURm7lYNMBmOfJgNmKchYFXI7XB1x6HROtbvU2bfWUBK3r9Uu1Cb39Km7hvBoicUOen9OLU9IYyQ6CCn2zjr3CKXnT6LcGhRvwq2nkIujrdUIdRv8Q778rl7TI2DgtOXMA9LX6Ka4PRz1h0/3oGeeip7aZTHFLW0S0xREJB5KLpOUzEjQm9AP6UhQ35ykTMtX9BqHPAyqhqMKr/M7GZM/xIC0xzaatN5aITk8yNsA40LqDvNPQZc/aX5sMkYrGDGkIjQcbyGQhYjYfrH8060+tG2F9RfVmeQwCNPVPgpnxZ1oiMwLFsS21mEWq8L9zRh47Z+N7xDldZnPHtkSVyoFUObMPV5sL662aR9jK/rYvdNkraIdKaC8Hm9vML7JFls4lPKTuFQQOb3mbbZxDpNh6W8IDl7C93MFCi+8f6taoz5XkgkHwB3C5/i7gtx8JdAD9waqhZfQ9vS7Xd4pxHb2WxJEeUhEkHc6T4mM9WkyeVlfs7SKvH1TPOAlUMJDXdgjLEGdjuLdWXztYQfuQ5bSAaaGd9Qd7JGu5+4lIjOYkJhmf/AuNcIcsh90ScmT0H3t0RVFwkQQkkoICGAI3fa8nHNV1Tc1KwOUywUuUQZdVk8478VsNi2n+T1pk5WlTvRVhGs3TlV14onjgw4WXs7b0LDz0E35m6wvFWg+LXj8FoHQqNSvNrT5BkCphFcewqbxFOo5hgyCofvFGWv+PIwAvfQTgeGpoIj3Hl/KPTFchfFUHrGugPlDeYWtVfAfIwQUIPzzsQ/hyiq/ZjCyAvNuuii8YyOhN8YXUAUXR7dZSHCkbulLOoRS0Tv6QIlllAH8IhNj+EAD9CIBpM/fjdAEgD9RRABxbn9/2HF0F+pD03lPN3YWkS6e6a+/c40pPli4uNO1Uyu1Omat+ORO41OrLW/rUpHID1+B/oxPOfKnUYrU2TbcZ/8HRh+rnXI0R/JEWuliQEDNldx3iTZZOwpKcoGNGZXeWGifQsHuzgLxm5HXqKYoWTIUHwNSQBd9aZPzbG6KmBX9Lpy9LFKLQ4QTgD2a9AmY1LVWJ0XDcqvqJT3kj3+2rIbfPSXiANxvbZX3Rm5Dsy1E8c6Pdf/ZSi4JeW39Xwaei7Uxd0jabYiKwTlMlbUcSNyZId9OkTMozFod79KZMuh3caDdOlC3b5rUQZfUr8+dTZbcS5bNpsiwP648muxy4fGs0eTXkJutAnclN1rm8gURg+Rxd5GZ6vmNbC8+NHMPDEVL9ovJkMgEUdzix9Ng00irjG2ak1rU+ZdV32dghfK0q6Rb07za99fqc9SZw1kMF3VYp6McHStnXhPIfFt3RA9dVv9II8x7/Bw== ================================================ FILE: doc/design/secure-cluster-traffic.md ================================================ # Secure Alertmanager cluster traffic Type: Design document Date: 2019-02-21 Author: Max Inden ## Status Quo Alertmanager supports [high availability](https://github.com/prometheus/alertmanager/blob/master/README.md#high-availability) by interconnecting multiple Alertmanager instances building an Alertmanager cluster. Instances of a cluster communicate on top of a gossip protocol managed via Hashicorps [_Memberlist_](https://github.com/hashicorp/memberlist) library. _Memberlist_ uses two channels to communicate: TCP for reliable and UDP for best-effort communication. Alertmanager instances use the gossip layer to: - Keep track of membership - Replicate silence creation, update and deletion - Replicate notification log As of today the communication between Alertmanager instances in a cluster is sent in clear-text. ## Goal Instances in a cluster should communicate among each other in a secure fashion. Alertmanager should guarantee confidentiality, integrity and client authenticity for each message touching the wire. While this would improve the security of single datacenter deployments, one could see this as a necessity for wide-area-network deployments. ## Non-Goal Even though solutions might also be applicable to the API endpoints exposed by Alertmanager, it is not the goal of this design document to secure the API endpoints. ## Proposed Solution - TLS Memberlist _Memberlist_ enables users to implement their own [transport layer](https://godoc.org/github.com/hashicorp/memberlist#Transport) without the need of forking the library itself. That transport layer needs to support reliable as well as best-effort communication. Instead of using TCP and UDP like the default transport layer of _Memberlist_, the suggestion is to only use TCP for both reliable as well as best-effort communication. On top of that TCP layer, one can use mutual TLS to secure all communication. A proof-of-concept implementation can be found here: https://github.com/mxinden/memberlist-tls-transport. The data gossiped between instances does not have a low-latency requirement that TCP could not fulfill, same would apply for the relatively low data throughput requirements of Alertmanager. TCP connections could be kept alive beyond a single message to reduce latency as well as handshake overhead costs. While this is feasible in a 3-instance Alertmanager cluster, the discussed custom implementation would need to limit the amount of open connections for clusters with many instances (#connections = n*(n-1)/2). As of today, Alertmanager already forces _Memberlist_ to use the reliable TCP instead of the best-effort UDP connection to gossip large notification logs and silences between instances. The reason is, that those packets would otherwise exceed the [MTU](https://en.wikipedia.org/wiki/Maximum_transmission_unit) of most UDP setups. Splitting packets is not supported by _Memberlist_ and was not considered worth the effort to be implemented in Alertmanager either. For more info see this [Github issue](https://github.com/prometheus/alertmanager/issues/1412). With the last [Prometheus developer summit](https://docs.google.com/document/d/1-C5PycocOZEVIPrmM1hn8fBelShqtqiAmFptoG4yK70/edit) in mind, the Prometheus projects preferred security mechanism seems to be mutual TLS. Having Alertmanager use the same mechanism would ease deployment with the rest of the Prometheus stack. As a side effect (benefit) Alertmanager would only need a single open port (TCP traffic) instead of two open ports (TCP and UDP traffic) for cluster communication. This does not affect the API endpoint which remains a separate TCP port. ## Alternative Solutions ### Symmetric Memberlist _Memberlist_ supports [symmetric key encryption](https://godoc.org/github.com/hashicorp/memberlist#Keyring) via AES-128, AES-192 or AES-256 ciphers. One can specify multiple keys for rolling updates. Securing the cluster traffic via symmetric encryption would just involve small configuration changes in the Alertmanager code base. ### Replace Memberlist Coordinating membership might not be required by the Alertmanager cluster component. Instead this could be bound to static configuration or e.g. DNS service discovery. On the other hand, gossiping silences and notifications is ideally done in an eventual consistent gossip fashion, given that Alertmanager is supposed to scale beyond a 3-instance cluster and beyond local-area-network deployments. With these requirements in mind, replacing _Memberlist_ with an entirely self-built communication layer is a great undertaking. ### TLS Memberlist with DTLS Instead of redirecting all best-effort traffic via the reliable channel as proposed above, one could also secure the best-effort channel itself using UDP and [DTLS](https://en.wikipedia.org/wiki/Datagram_Transport_Layer_Security) in addition to securing the reliable traffic via TCP and TLS. DTLS is not supported by the Golang standard library. ================================================ FILE: doc/examples/simple.yml ================================================ global: # The smarthost and SMTP sender used for mail notifications. smtp_smarthost: 'localhost:25' smtp_from: 'alertmanager@example.org' smtp_auth_username: 'alertmanager' smtp_auth_password: 'password' # The directory from which notification templates are read. templates: - '/etc/alertmanager/template/*.tmpl' # The root route on which each incoming alert enters. route: # The labels by which incoming alerts are grouped together. For example, # multiple alerts coming in for cluster=A and alertname=LatencyHigh would # be batched into a single group. # # To aggregate by all possible labels use '...' as the sole label name. # This effectively disables aggregation entirely, passing through all # alerts as-is. This is unlikely to be what you want, unless you have # a very low alert volume or your upstream notification system performs # its own grouping. Example: group_by: [...] group_by: ['alertname', 'cluster', 'service'] # When a new group of alerts is created by an incoming alert, wait at # least 'group_wait' to send the initial notification. # This way ensures that you get multiple alerts for the same group that start # firing shortly after another are batched together on the first # notification. group_wait: 30s # When the first notification was sent, wait 'group_interval' to send a batch # of new alerts that started firing for that group. group_interval: 5m # If an alert has successfully been sent, wait 'repeat_interval' to # resend them. repeat_interval: 3h # A default receiver receiver: team-X-mails # All the above attributes are inherited by all child routes and can # overwritten on each. # The child route trees. routes: # This routes performs a regular expression match on alert labels to # catch alerts that are related to a list of services. - matchers: - service=~"foo1|foo2|baz" receiver: team-X-mails # The service has a sub-route for critical alerts, any alerts # that do not match, i.e. severity != critical, fall-back to the # parent node and are sent to 'team-X-mails' routes: - matchers: - severity="critical" receiver: team-X-pager - matchers: - service="files" receiver: team-Y-mails routes: - matchers: - severity="critical" receiver: team-Y-pager # This route handles all alerts coming from a database service. If there's # no team to handle it, it defaults to the DB team. - matchers: - service="database" receiver: team-DB-pager # Also group alerts by affected database. group_by: [alertname, cluster, database] routes: - matchers: - owner="team-X" receiver: team-X-pager continue: true - matchers: - owner="team-Y" receiver: team-Y-pager # Inhibition rules allow to mute a set of alerts given that another alert is # firing. # We use this to mute any warning-level notifications if the same alert is # already critical. inhibit_rules: - source_matchers: [severity="critical"] target_matchers: [severity="warning"] # Apply inhibition if the alertname is the same. # CAUTION: # If all label names listed in `equal` are missing # from both the source and target alerts, # the inhibition rule will apply! equal: [alertname, cluster, service] receivers: - name: 'team-X-mails' email_configs: - to: 'team-X+alerts@example.org' - name: 'team-X-pager' email_configs: - to: 'team-X+alerts-critical@example.org' pagerduty_configs: - service_key: - name: 'team-Y-mails' email_configs: - to: 'team-Y+alerts@example.org' - name: 'team-Y-pager' pagerduty_configs: - service_key: - name: 'team-DB-pager' pagerduty_configs: - service_key: tracing: endpoint: localhost:4317 insecure: true sampling_fraction: 1.0 ================================================ FILE: docs/alertmanager.md ================================================ --- title: Alertmanager sort_rank: 2 nav_icon: sliders --- The [Alertmanager](https://github.com/prometheus/alertmanager) handles alerts sent by client applications such as the Prometheus server. It takes care of deduplicating, grouping, and routing them to the correct receiver integration such as email, PagerDuty, or OpsGenie. It also takes care of silencing and inhibition of alerts. The following describes the core concepts the Alertmanager implements. Consult the [configuration documentation](configuration.md) to learn how to use them in more detail. ## Grouping Grouping categorizes alerts of similar nature into a single notification. This is especially useful during larger outages when many systems fail at once and hundreds to thousands of alerts may be firing simultaneously. **Example:** Dozens or hundreds of instances of a service are running in your cluster when a network partition occurs. Half of your service instances can no longer reach the database. Alerting rules in Prometheus were configured to send an alert for each service instance if it cannot communicate with the database. As a result hundreds of alerts are sent to Alertmanager. As a user, one only wants to get a single page while still being able to see exactly which service instances were affected. Thus one can configure Alertmanager to group alerts by their cluster and alertname so it sends a single compact notification. Grouping of alerts, timing for the grouped notifications, and the receivers of those notifications are configured by a routing tree in the configuration file. ## Inhibition Inhibition is a concept of suppressing notifications for certain alerts if certain other alerts are already firing. **Example:** An alert is firing that informs that an entire cluster is not reachable. Alertmanager can be configured to mute all other alerts concerning this cluster if that particular alert is firing. This prevents notifications for hundreds or thousands of firing alerts that are unrelated to the actual issue. Inhibitions are configured through the Alertmanager's configuration file. ## Silences Silences are a straightforward way to simply mute alerts for a given time. A silence is configured based on matchers, just like the routing tree. Incoming alerts are checked whether they match all the equality or regular expression matchers of an active silence. If they do, no notifications will be sent out for that alert. Silences are configured in the web interface of the Alertmanager. ## Client behavior The Alertmanager has [special requirements](alerts_api.md) for behavior of its client. Those are only relevant for advanced use cases where Prometheus is not used to send alerts. ## High Availability Alertmanager supports configuration to create a cluster for high availability. This can be configured using the [--cluster-*](https://github.com/prometheus/alertmanager#high-availability) flags. It's important not to load balance traffic between Prometheus and its Alertmanagers, but instead, point Prometheus to a list of all Alertmanagers. ## Alert limits (optional) Alertmanager supports configuration to limit the number of active alerts per alertname. This can be configured using the [--alerts.per-alertname-limit] flag. When the limit is reached any new alerts are dropped, heartbeats from already know alerts are processed. The known alert (fingerprint) automatically expire to make room for new alerts. This feature is useful when an unexpected high number of instances of the same alert are sent to Alertmanager. Limiting the number of alerts per alertname can prevent reliability issues and avoid alert receivers from being flooded. The `alertmanager_alerts_limited_total` metric shows the total number of alerts that were dropped due to per alert name limit. Enabling the `alert-names-in-metrics` feature flag will add the `alertname` label to the metric. ================================================ FILE: docs/alerts_api.md ================================================ --- title: Alerts API sort_rank: 6 nav_icon: sliders --- **Important**: Prometheus takes care of sending alerts to the Alertmanager. It is recommended to configure alerting rules in Prometheus based on time series data instead of sending alerts to the Alerts API, as Prometheus supports a number of special cases to make sure alerts are delivered even if Alertmanager crashes or restarts. You send alerts to Alertmanager via APIv2. The APIv2 is specified as an OpenAPI specification that can be found [here](https://github.com/prometheus/alertmanager/blob/master/api/v2/openapi.yaml). APIv1 was deprecated in Alertmanager version 0.16.0 and removed in Alertmanager version 0.27.0. To send alerts to APIv2 make a POST request to `api/v2/alerts`. You must set the `Content-Type` header to `application/json`, and send JSON data containing an array of alerts. Here is an example: ```json [ { "labels": { "alertname": "", "": "", ... }, "annotations": { "": "", }, "startsAt": "", "endsAt": "", "generatorURL": "" }, ... ] ``` All alerts have labels, annotations, an optional `startsAt` timestamp and an optional `endsAt` timestamp. All timestamps are expected in the RFC3339 format. Labels are used to deduplicate identical instances of the same alert, while annotations are used to include other information about the alert, such as a summary, description or a URL to a runbook. The `startsAt` timestamp is the time the alert fired. If omitted, Alertmanager sets `startsAt` to the current time. The `endsAt` timestamp is the time the alert should be resolved. If omitted, Alertmanager sets `endsAt` to the current time + `resolve_timeout`. The `generatorURL` is a unique URL which links to the source of the alert. For example, it might link to the firing rule in Prometheus. ## Expectations from clients Clients are expected to re-send firing alerts to the Alertmanager at regular intervals until the alert is resolved. The exact interval depends on a number of variables such as the `endsAt` timestamp, or if omitted the value of `resolve_timeout`. If the `endsAt` timestamp is omitted, the Alertmanager will update the existing `endsAt` timestamp for the alert to the current time + `resolve_timeout`. Firing alerts are resolved once their `endsAt` timestamp has elapsed. To ensure resolved notifications are sent for resolved alerts, clients are also expected to re-send resolved alerts to the Alertmanager for up to 5 minutes after the alert has resolved. As the Alertmanager is stateless, this ensures that a resolved notification is sent even if the Alertmanager crashes or is restarted. ================================================ FILE: docs/configuration.md ================================================ --- title: Configuration sort_rank: 3 nav_icon: sliders --- [Alertmanager](https://github.com/prometheus/alertmanager) is configured via command-line flags and a configuration file. While the command-line flags configure immutable system parameters, the configuration file defines inhibition rules, notification routing and notification receivers. The [visual editor](https://www.prometheus.io/webtools/alerting/routing-tree-editor) can assist in building routing trees. To view all available command-line flags, run `alertmanager -h`. Alertmanager can reload its configuration at runtime. If the new configuration is not well-formed, the changes will not be applied and an error is logged. A configuration reload is triggered by sending a `SIGHUP` to the process or sending an HTTP POST request to the `/-/reload` endpoint. ## Limits Alertmanager supports a number of configurable limits via command-line flags. To limit the maximum number of silences, including expired ones, use the `--silences.max-silences` flag. You can limit the maximum size of individual silences with `--silences.max-silence-size-bytes`, where the unit is in bytes. Both limits are disabled by default. ## Configuration file introduction To specify which configuration file to load, use the `--config.file` flag. ```bash ./alertmanager --config.file=alertmanager.yml ``` The file is written in the [YAML format](http://en.wikipedia.org/wiki/YAML), defined by the scheme described below. Brackets indicate that a parameter is optional. For non-list parameters the value is set to the specified default. Generic placeholders are defined as follows: * ``: a duration matching the regular expression `((([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?|0)`, e.g. `1d`, `1h30m`, `5m`, `10s` * ``: a string matching the regular expression `[a-zA-Z_][a-zA-Z0-9_]*` * ``: a string of unicode characters * ``: a valid path in the current working directory * ``: a boolean that can take the values `true` or `false` * ``: a regular string * ``: a regular string that is a secret, such as a password * ``: a string which is template-expanded before usage * ``: a string which is template-expanded before usage that is a secret * ``: an integer value * ``: any valid [RE2 regular expression](https://github.com/google/re2/wiki/Syntax) (The regex is anchored on both ends. To un-anchor the regex, use `.*.*`.) The other placeholders are specified separately. A provided [valid example file](https://github.com/prometheus/alertmanager/blob/main/doc/examples/simple.yml) shows usage in context. ## File layout and global settings The global configuration specifies parameters that are valid in all other configuration contexts. They also serve as defaults for other configuration sections. The other top-level sections are documented below on this page. ```yaml global: # The default SMTP From header field. [ smtp_from: ] # The default SMTP smarthost used for sending emails, including port number. # Port number usually is 25, or 587 for SMTP over TLS (sometimes referred to as STARTTLS). # Example: smtp.example.org:587 [ smtp_smarthost: ] # The default hostname to identify to the SMTP server. [ smtp_hello: | default = "localhost" ] # SMTP Auth using CRAM-MD5, LOGIN and PLAIN. If empty, Alertmanager doesn't authenticate to the SMTP server. # PLAIN is only supported when using TLS. [ smtp_auth_username: ] # SMTP Auth using LOGIN and PLAIN. [ smtp_auth_password: ] # SMTP Auth using LOGIN and PLAIN. [ smtp_auth_password_file: ] # SMTP Auth using PLAIN. [ smtp_auth_identity: ] # SMTP Auth using CRAM-MD5. [ smtp_auth_secret: ] # SMTP Auth using CRAM-MD5. [ smtp_auth_secret_file: ] # The default SMTP TLS requirement. # Note that Go does not support unencrypted connections to remote SMTP endpoints. [ smtp_require_tls: | default = true ] # The default TLS configuration for SMTP receivers [ smtp_tls_config: ] # Force implicit TLS regardless of SMTP port [ smtp_force_implicit_tls: ] # Default settings for the JIRA integration. [ jira_api_url: ] # The API URL to use for Slack notifications. [ slack_api_url: ] [ slack_api_url_file: ] [ slack_app_token: ] [ slack_app_token_file: ] [ slack_app_url: ] [ victorops_api_key: ] [ victorops_api_key_file: ] [ victorops_api_url: | default = "https://alert.victorops.com/integrations/generic/20131114/alert/" ] [ pagerduty_url: | default = "https://events.pagerduty.com/v2/enqueue" ] [ opsgenie_api_key: ] [ opsgenie_api_key_file: ] [ opsgenie_api_url: | default = "https://api.opsgenie.com/" ] [ rocketchat_api_url: | default = "https://open.rocket.chat/" ] [ rocketchat_token: ] [ rocketchat_token_file: ] [ rocketchat_token_id: ] [ rocketchat_token_id_file: ] [ wechat_api_url: | default = "https://qyapi.weixin.qq.com/cgi-bin/" ] [ wechat_api_secret: ] [ wechat_api_secret_file: ] [ wechat_api_corp_id: ] [ telegram_api_url: | default = "https://api.telegram.org" ] [ telegram_bot_token: ] [ telegram_bot_token_file: ] [ webex_api_url: | default = "https://webexapis.com/v1/messages" ] [ mattermost_webhook_url: ] [ mattermost_webhook_url_file: ] # The default HTTP client configuration [ http_config: ] # ResolveTimeout is the default value used by alertmanager if the alert does # not include EndsAt, after this time passes it can declare the alert as resolved if it has not been updated. # This has no impact on alerts from Prometheus, as they always include EndsAt. [ resolve_timeout: | default = 5m ] # Files from which custom notification template definitions are read. # The last component may use a wildcard matcher, e.g. 'templates/*.tmpl'. templates: [ - ... ] # The root node of the routing tree. route: # A list of notification receivers. receivers: - ... # A list of inhibition rules. inhibit_rules: [ - ... ] # DEPRECATED: use time_intervals below. # A list of mute time intervals for muting routes. mute_time_intervals: [ - ... ] # A list of time intervals for muting/activating routes. time_intervals: [ - ... ] ``` ## Route-related settings Routing-related settings allow configuring how alerts are routed, aggregated, throttled, and muted based on time. ### `` A route block defines a node in a routing tree and its children. Its optional configuration parameters are inherited from its parent node if not set. Every alert enters the routing tree at the configured top-level route, which must match all alerts (i.e. not have any configured matchers). It then traverses the child nodes. If `continue` is set to false, it stops after the first matching child. If `continue` is true on a matching node, the alert will continue matching against subsequent siblings. If an alert does not match any children of a node (no matching child nodes, or none exist), the alert is handled based on the configuration parameters of the current node. See [Alertmanager concepts](https://prometheus.io/docs/alerting/alertmanager/#grouping) for more information on grouping. ```yaml [ receiver: ] # The labels by which incoming alerts are grouped together. For example, # multiple alerts coming in for cluster=A and alertname=LatencyHigh would # be batched into a single group. # # To aggregate by all possible labels use the special value '...' as the sole label name, for example: # group_by: ['...'] # This effectively disables aggregation entirely, passing through all # alerts as-is. This is unlikely to be what you want, unless you have # a very low alert volume or your upstream notification system performs # its own grouping. [ group_by: '[' , ... ']' ] # Whether an alert should continue matching subsequent sibling nodes. [ continue: | default = false ] # DEPRECATED: Use matchers below. # A set of equality matchers an alert has to fulfill to match the node. match: [ : , ... ] # DEPRECATED: Use matchers below. # A set of regex-matchers an alert has to fulfill to match the node. match_re: [ : , ... ] # A list of matchers that an alert has to fulfill to match the node. matchers: [ - ... ] # How long to wait before sending the first notification for a new group of # alerts. Allows to wait for alerts to arrive from other rule groups or # Prometheus servers, and for one or more inhibiting alerts to arrive and mute # any target alerts before the first notification. # # A short group_wait will reduce the time to wait before sending the first # notification for a new group of alerts. However, if group_wait is too short # then the first notification might not contain the complete set of expected # alerts, and alerts that should be inhibited might not be inhibited if the # inhibiting alerts have not arrived in time. # # A long group_wait will increase the time to wait before sending the first # notification for a new group of alerts. However, if group_wait is too long # then notifications for firing alerts might not be sent within a reasonable # time. # # If an alert is resolved before group_wait has elapsed, no notification will # be sent for that alert. This reduces noise of flapping alerts. # A notification for any alerts that missed the initial group_wait will be # sent at the next group_interval instead. # # If omitted, child routes inherit the group_wait of the parent route. [ group_wait: | default = 30s ] # How long to wait before sending subsequent notifications for an existing # group of alerts after group_wait. # # The group_interval is a recurring timer that starts as soon as group_wait # has elapsed. At each group_interval, Alertmanager checks if any new alerts # have fired or any firing alerts have resolved since the last group_interval, # and if they have a notification is sent. If they haven't, Alertmanager checks # if the repeat_interval has elapsed instead. # # Note: group_interval also sets the context timeout for the notification # pipeline for each send. So if sending a notification takes longer than the # group_interval, the notification will get canceled. This can happen with # small group_interval values and slow notification receivers. # # If omitted, child routes inherit the group_interval of the parent route. [ group_interval: | default = 5m ] # How long to wait before repeating the last notification. Notifications are # not repeated if any new alerts have fired or any firing alerts have resolved # since the last group_interval. # # Since the repeat_interval is checked after each group_interval, it should # be a multiple of the group_interval. If it's not, the repeat_interval # is rounded up to the next multiple of the group_interval. # # In addition, if repeat_interval is longer then `--data.retention`, the # notification will be repeated at the end of the data retention period # instead. # # If omitted, child routes inherit the repeat_interval of the parent route. [ repeat_interval: | default = 4h ] # Times when the route should be muted. These must match the name of a # time interval defined in the time_intervals section. # Additionally, the root node cannot have any mute times. # When a route is muted it will not send any notifications, but # otherwise acts normally (including ending the route-matching process # if the `continue` option is not set.) mute_time_intervals: [ - ...] # Times when the route should be active. These must match the name of a # time interval defined in the time_intervals section. An empty value # means that the route is always active. # Additionally, the root node cannot have any active times. # The route will send notifications only when active, but otherwise # acts normally (including ending the route-matching process # if the `continue` option is not set). active_time_intervals: [ - ...] # Zero or more child routes. routes: [ - ... ] ``` #### Example ```yaml # The root route with all parameters, which are inherited by the child # routes if they are not overwritten. route: receiver: 'default-receiver' group_wait: 30s group_interval: 5m repeat_interval: 4h group_by: [cluster, alertname] # All alerts that do not match the following child routes # will remain at the root node and be dispatched to 'default-receiver'. routes: # All alerts with service=mysql or service=cassandra # are dispatched to the database pager. - receiver: 'database-pager' group_wait: 10s matchers: - service=~"mysql|cassandra" # All alerts with the team=frontend label match this sub-route. # They are grouped by product and environment rather than cluster # and alertname. - receiver: 'frontend-pager' group_by: [product, environment] matchers: - team="frontend" # All alerts with the service=inhouse-service label match this sub-route. # the route will be muted during offhours and holidays time intervals. # even if it matches, it will continue to the next sub-route - receiver: 'dev-pager' matchers: - service="inhouse-service" mute_time_intervals: - offhours - holidays continue: true # All alerts with the service=inhouse-service label match this sub-route # the route will be active only during offhours and holidays time intervals. - receiver: 'on-call-pager' matchers: - service="inhouse-service" active_time_intervals: - offhours - holidays ``` ### `` A `time_interval` specifies a named interval of time that may be referenced in the routing tree to mute/activate particular routes for particular times of the day. ```yaml name: time_intervals: [ - ... ] ``` #### `` A `time_interval_spec` contains the actual definition for an interval of time. The syntax supports the following fields: ```yaml - times: [ - ...] weekdays: [ - ...] days_of_month: [ - ...] months: [ - ...] years: [ - ...] location: ``` All fields are lists. Within each non-empty list, at least one element must be satisfied to match the field. If a field is left unspecified, any value will match the field. For an instant of time to match a complete time interval, all fields must match. Some fields support ranges and negative indices, and are detailed below. If a time zone is not specified, then the times are taken to be in UTC. `time_range`: Ranges inclusive of the starting time and exclusive of the end time to make it easy to represent times that start/end on hour boundaries. For example, `start_time: '17:00'` and `end_time: '24:00'` will begin at 17:00 and finish immediately before 24:00. They are specified like so: ```yaml times: - start_time: HH:MM end_time: HH:MM ``` `weekday_range`: A list of days of the week, where the week begins on Sunday and ends on Saturday. Days should be specified by name (e.g. 'Sunday'). For convenience, ranges are also accepted of the form `:` and are inclusive on both ends. For example: `['monday:wednesday','saturday', 'sunday']` `days_of_month_range`: A list of numerical days in the month. Days begin at 1. Negative values are also accepted which begin at the end of the month, e.g. -1 during January would represent January 31. For example: `['1:5', '-3:-1']`. Extending past the start or end of the month will cause it to be clamped. E.g. specifying `['1:31']` during February will clamp the actual end date to 28 or 29 depending on leap years. Inclusive on both ends. `month_range`: A list of calendar months identified by a case-insensitive name (e.g. 'January') or by number, where January = 1. Ranges are also accepted. For example, `['1:3', 'may:august', 'december']`. Inclusive on both ends. `year_range`: A numerical list of years. Ranges are accepted. For example, `['2020:2022', '2030']`. Inclusive on both ends. `location`: A string that matches a location in the IANA time zone database. For example, `'Australia/Sydney'`. The location provides the time zone for the time interval. For example, a time interval with a location of `'Australia/Sydney'` that contained something like: ```yaml times: - start_time: 09:00 end_time: 17:00 weekdays: ['monday:friday'] ``` would include any time that fell between the hours of 9:00AM and 5:00PM, between Monday and Friday, using the local time in Sydney, Australia. You may also use `'Local'` as a location to use the local time of the machine where Alertmanager is running, or `'UTC'` for UTC time. If no timezone is provided, the time interval is taken to be in UTC time.**Note:** On Windows, only `Local` or `UTC` are supported unless you provide a custom time zone database using the `ZONEINFO` environment variable. ## Inhibition-related settings Inhibition allows muting a set of alerts based on the presence of another set of alerts. This allows establishing dependencies between systems or services such that only the most relevant of a set of interconnected alerts are sent out during an outage. See [Alertmanager concepts](https://prometheus.io/docs/alerting/alertmanager/#inhibition) for more information on inhibition. ### `` An inhibition rule mutes an alert (target) matching a set of matchers when an alert (source) exists that matches another set of matchers. Both target and source alerts must have the same label values for the label names in the `equal` list. Semantically, a missing label and a label with an empty value are the same thing. Therefore, if all the label names listed in `equal` are missing from both the source and target alerts, the inhibition rule will apply. To prevent an alert from inhibiting itself, an alert that matches _both_ the target and the source side of a rule cannot be inhibited by alerts for which the same is true (including itself). However, we recommend to choose target and source matchers in a way that alerts never match both sides. It is much easier to reason about and does not trigger this special case. ```yaml # Optional name of the inhibition rule. # Duplicate names are allowed but will affect the per-rule metrics. name: # DEPRECATED: Use target_matchers below. # Matchers that have to be fulfilled in the alerts to be muted. target_match: [ : , ... ] # DEPRECATED: Use target_matchers below. target_match_re: [ : , ... ] # A list of matchers that have to be fulfilled by the target # alerts to be muted. target_matchers: [ - ... ] # DEPRECATED: Use source_matchers below. # Matchers for which one or more alerts have to exist for the # inhibition to take effect. source_match: [ : , ... ] # DEPRECATED: Use source_matchers below. source_match_re: [ : , ... ] # A list of matchers for which one or more alerts have # to exist for the inhibition to take effect. source_matchers: [ - ... ] # Labels that must have an equal value in the source and target # alert for the inhibition to take effect. [ equal: '[' , ... ']' ] ``` ## Label matchers Label matchers match alerts to routes, silences, and inhibition rules. **Important**: Prometheus is adding support for UTF-8 in labels and metrics. In order to also support UTF-8 in the Alertmanager, Alertmanager versions 0.27 and later have a new parser for matchers that has a number of backwards incompatible changes. While most matchers will be forward-compatible, some will not. Alertmanager is operating a transition period where it supports both UTF-8 and classic matchers, and has provided a number of tools to help you prepare for the transition. If this is a new Alertmanager installation, we recommend enabling UTF-8 strict mode before creating an Alertmanager configuration file. You can find instructions on how to enable UTF-8 strict mode [here](#utf-8-strict-mode). If this is an existing Alertmanager installation, we recommend running the Alertmanager in the default mode called fallback mode before enabling UTF-8 strict mode. In this mode, Alertmanager will log a warning if you need to make any changes to your configuration file before UTF-8 strict mode can be enabled. Alertmanager will make UTF-8 strict mode the default in the next two versions, so it's important to transition as soon as possible. Irrespective of whether an Alertmanager installation is a new or existing installation, you can also use `amtool` to validate that an Alertmanager configuration file is compatible with UTF-8 strict mode before enabling it in Alertmanager server. You do not need a running Alertmanager server to do this. You can find instructions on how to validate an Alertmanager configuration file using `amtool` [here](#verification). ### Alertmanager server operational modes During the transition period, Alertmanager supports three modes of operation. These are known as fallback mode, UTF-8 strict mode and classic mode. Fallback mode is the default mode. Operators of Alertmanager servers should transition to UTF-8 strict mode before the end of the transition period. Alertmanager will make UTF-8 strict mode the default in the next two versions, so it's important to transition as soon as possible. #### Fallback mode Alertmanager runs in a special mode called fallback mode as its default mode. As operators, you should not experience any difference in how your routes, silences or inhibition rules work. In fallback mode, configurations are first parsed as UTF-8 matchers, and if incompatible with the UTF-8 parser, are then parsed as classic matchers. If your Alertmanager configuration contains matchers that are incompatible with the UTF-8 parser, Alertmanager will parse them as classic matchers and log a warning. This warning also includes a suggestion on how to change the matchers from classic matchers to UTF-8 matchers. For example: ``` ts=2024-02-11T10:00:00Z caller=parse.go:176 level=warn msg="Alertmanager is moving to a new parser for labels and matchers, and this input is incompatible. Alertmanager has instead parsed the input using the classic matchers parser as a fallback. To make this input compatible with the UTF-8 matchers parser please make sure all regular expressions and values are double-quoted and backslashes are escaped. If you are still seeing this message please open an issue." input="foo=" origin=config err="end of input: expected label value" suggestion="foo=\"\"" ``` Here the matcher `foo=` can be made into a valid UTF-8 matcher by double quoting the right hand side of the expression to give `foo=""`. These two matchers are equivalent, however with UTF-8 matchers the right hand side of the matcher is a required field. In rare cases, a configuration can cause disagreement between the UTF-8 and classic parser. This happens when a matcher is valid in both parsers, but due to added support for UTF-8, results in different parsings depending on which parser is used. If your Alertmanager configuration has disagreement, Alertmanager will use the classic parser and log a warning. For example: ``` ts=2024-02-11T10:00:00Z caller=parse.go:183 level=warn msg="Matchers input has disagreement" input="qux=\"\\xf0\\x9f\\x99\\x82\"\n" origin=config ``` Any occurrences of disagreement should be looked at on a case by case basis as depending on the nature of the disagreement, the configuration might not need updating before enabling UTF-8 strict mode. For example `\xf0\x9f\x99\x82` is the byte sequence for the 🙂 emoji. If the intention is to match a literal 🙂 emoji then no change is required. However, if the intention is to match the literal `\xf0\x9f\x99\x82` then the matcher should be changed to `qux="\\xf0\\x9f\\x99\\x82"`. #### UTF-8 strict mode In UTF-8 strict mode, Alertmanager disables support for classic matchers: ```bash alertmanager --config.file=config.yml --enable-feature="utf8-strict-mode" ``` This mode should be enabled for new Alertmanager installations, and existing Alertmanager installations once all warnings of incompatible matchers have been resolved. Alertmanager will not start in UTF-8 strict mode until all the warnings of incompatible matchers have been resolved: ``` ts=2024-02-11T10:00:00Z caller=coordinator.go:118 level=error component=configuration msg="Loading configuration file failed" file=config.yml err="end of input: expected label value" ``` UTF-8 strict mode will be the default mode of Alertmanager at the end of the transition period. #### Classic mode Classic mode is equivalent to Alertmanager versions 0.26.0 and older: ```bash alertmanager --config.file=config.yml --enable-feature="classic-mode" ``` You can use this mode if you suspect there is an issue with fallback mode or UTF-8 strict mode. In such cases, please open an issue on GitHub with as much information as possible. ### Verification You can use `amtool` to validate that an Alertmanager configuration file is compatible with UTF-8 strict mode before enabling it in Alertmanager server. You do not need a running Alertmanager server to do this. Just like Alertmanager server, `amtool` will log a warning if the configuration is incompatible or contains disagreement: ``` amtool check-config config.yml Checking 'config.yml' level=warn msg="Alertmanager is moving to a new parser for labels and matchers, and this input is incompatible. Alertmanager has instead parsed the input using the classic matchers parser as a fallback. To make this input compatible with the UTF-8 matchers parser please make sure all regular expressions and values are double-quoted and backslashes are escaped. If you are still seeing this message please open an issue." input="foo=" origin=config err="end of input: expected label value" suggestion="foo=\"\"" level=warn msg="Matchers input has disagreement" input="qux=\"\\xf0\\x9f\\x99\\x82\"\n" origin=config SUCCESS Found: - global config - route - 2 inhibit rules - 2 receivers - 0 templates ``` You will know if a configuration is compatible with UTF-8 strict mode when no warnings are logged in `amtool`: ``` amtool check-config config.yml Checking 'config.yml' SUCCESS Found: - global config - route - 2 inhibit rules - 2 receivers - 0 templates ``` You can also use `amtool` in UTF-8 strict mode as an additional level of verification. You will know that a configuration is invalid because the command will fail: ``` amtool check-config config.yml --enable-feature="utf8-strict-mode" level=warn msg="UTF-8 mode enabled" Checking 'config.yml' FAILED: end of input: expected label value amtool: error: failed to validate 1 file(s) ``` You will know that a configuration is valid because the command will succeed: ``` amtool check-config config.yml --enable-feature="utf8-strict-mode" level=warn msg="UTF-8 mode enabled" Checking 'config.yml' SUCCESS Found: - global config - route - 2 inhibit rules - 2 receivers - 0 templates ``` ### `` (Shared) #### UTF-8 matchers A UTF-8 matcher consists of three tokens: - An unquoted literal or a double-quoted string for the label name. - One of `=`, `!=`, `=~`, or `!~`. `=` means equals, `!=` means not equal, `=~` means matches the regular expression and `!~` means doesn't match the regular expression. - An unquoted literal or a double-quoted string for the regular expression or label value. Unquoted literals can contain all UTF-8 characters other than the reserved characters. The reserved characters include whitespace and all characters in ``` { } ! = ~ , \ " ' ` ```. For example, `foo`, `[a-zA-Z]+`, and `Προμηθεύς` (Prometheus in Greek) are all examples of valid unquoted literals. However, `foo!` is not a valid literal as `!` is a reserved character. Double-quoted strings can contain all UTF-8 characters. Unlike unquoted literals, there are no reserved characters. However, literal double quotes and backslashes must be escaped with a single backslash. For example, to match the regular expression `\d+` the backslash must be escaped `"\\d+"`. This is because double-quoted strings follow the same rules as Go's [string literals](https://go.dev/ref/spec#String_literals). Double-quoted strings also support UTF-8 code points. For example, `"foo!"`, `"bar,baz"`, `"\"baz qux\""` and `"\xf0\x9f\x99\x82"`. #### Classic matchers A classic matcher is a string with a syntax inspired by PromQL and OpenMetrics. The syntax of a classic matcher consists of three tokens: - A valid Prometheus label name. - One of `=`, `!=`, `=~`, or `!~`. `=` means equals, `!=` means that the strings are not equal, `=~` is used for equality of regex expressions and `!~` is used for un-equality of regex expressions. They have the same meaning as known from PromQL selectors. - A UTF-8 string, which may be enclosed in double quotes. Before or after each token, there may be any amount of whitespace. The 3rd token may be the empty string. Within the 3rd token, OpenMetrics escaping rules apply: `\"` for a double-quote, `\n` for a line feed, `\\` for a literal backslash. Unescaped `"` must not occur inside the 3rd token (only as the 1st or last character). However, literal line feed characters are tolerated, as are single `\` characters not followed by `\`, `n`, or `"`. They act as a literal backslash in that case. #### Composition of matchers You can compose matchers to create complex match expressions. When composed, all matchers must match for the entire expression to match. For example, the expression `{alertname="Watchdog", severity=~"warning|critical"}` will match an alert with labels `alertname=Watchdog, severity=critical` but not an alert with labels `alertname=Watchdog, severity=none` as while the alertname is Watchdog the severity is neither warning nor critical. You can compose matchers into expressions with a YAML list: ```yaml matchers: - alertname = Watchdog - severity =~ "warning|critical" ``` or as a PromQL inspired expression where each matcher is comma separated: ``` {alertname="Watchdog", severity=~"warning|critical"} ``` A single trailing comma is permitted: ``` {alertname="Watchdog", severity=~"warning|critical",} ``` The open `{` and close `}` brace are optional: ``` alertname="Watchdog", severity=~"warning|critical" ``` However, both must be either present or omitted. You cannot have incomplete open or close braces: ``` {alertname="Watchdog", severity=~"warning|critical" ``` ``` alertname="Watchdog", severity=~"warning|critical"} ``` You cannot have duplicate open or close braces either: ``` {{alertname="Watchdog", severity=~"warning|critical",}} ``` Whitespace (spaces, tabs and newlines) is permitted outside double quotes and has no effect on the matchers themselves. For example: ``` { alertname = "Watchdog", severity =~ "warning|critical", } ``` is equivalent to: ``` {alertname="Watchdog",severity=~"warning|critical"} ``` #### More examples Here are some more examples: 1. Two equals matchers composed as a YAML list: ```yaml matchers: - foo = bar - dings != bums ``` 2. Two matchers combined composed as a short-form YAML list: ```yaml matchers: [ foo = bar, dings != bums ] ``` As shown below, in the short-form, it's better to use double quotes to avoid problems with special characters like commas: ```yaml matchers: [ "foo = \"bar,baz\"", "dings != bums" ] ``` 3. You can also put both matchers into one PromQL-like string. Single quotes work best here: ```yaml matchers: [ '{foo="bar", dings!="bums"}' ] ``` 4. To avoid issues with escaping and quoting rules in YAML, you can also use a YAML block: ```yaml matchers: - | {quote=~"She said: \"Hi, all!( How're you…)?\""} ``` ## General receiver-related settings These receiver settings allow configuring notification destinations (receivers) and HTTP client options for HTTP-based receivers. ### `` Receiver is a named configuration of one or more notification integrations. Note: As part of lifting the past moratorium on new receivers it was agreed that, in addition to the existing requirements, new notification integrations will be required to have a committed maintainer with push access. ```yaml # The unique name of the receiver. name: # Configurations for several notification integrations. discord_configs: [ - , ... ] email_configs: [ - , ... ] mattermost_configs: [ - , ... ] msteams_configs: [ - , ... ] msteamsv2_configs: [ - , ... ] jira_configs: [ - , ... ] opsgenie_configs: [ - , ... ] pagerduty_configs: [ - , ... ] incidentio_configs: [ - , ... ] pushover_configs: [ - , ... ] rocketchat_configs: [ - , ... ] slack_configs: [ - , ... ] sns_configs: [ - , ... ] telegram_configs: [ - , ... ] victorops_configs: [ - , ... ] webex_configs: [ - , ... ] webhook_configs: [ - , ... ] wechat_configs: [ - , ... ] ``` ### `` (Shared) An `http_config` allows configuring the HTTP client that the receiver uses to communicate with HTTP-based API services. ```yaml # Note that `basic_auth` and `authorization` options are mutually exclusive. # Sets the `Authorization` header with the configured username and password. # password and password_file are mutually exclusive. basic_auth: [ username: ] [ password: ] [ password_file: ] # Optional the `Authorization` header configuration. authorization: # Sets the authentication type. [ type: | default: Bearer ] # Sets the credentials. It is mutually exclusive with # `credentials_file`. [ credentials: ] # Sets the credentials with the credentials read from the configured file. # It is mutually exclusive with `credentials`. [ credentials_file: ] # Optional OAuth 2.0 configuration. # Cannot be used at the same time as basic_auth or authorization. oauth2: [ ] # Whether to enable HTTP2. [ enable_http2: | default: true ] # Optional proxy URL. [ proxy_url: ] # Comma-separated string that can contain IPs, CIDR notation, domain names # that should be excluded from proxying. IP and domain names can # contain port numbers. [ no_proxy: ] # Use proxy URL indicated by environment variables (HTTP_PROXY, http_proxy, HTTPS_PROXY, https_proxy, NO_PROXY, and no_proxy) [ proxy_from_environment: | default: false ] # Specifies headers to send to proxies during CONNECT requests. [ proxy_connect_header: [ : [, ...] ] ] # Configure whether HTTP requests follow HTTP 3xx redirects. [ follow_redirects: | default = true ] # Configures the TLS settings. tls_config: [ ] # Custom HTTP headers to be sent along with each request. # Headers that are set by Prometheus itself can't be overwritten. http_headers: [ ] ``` #### `` (Shared) ```yaml # Header name. : # Header values. [ values: [, ...] ] # Headers values. Hidden in configuration page. [ secrets: [, ...] ] # Files to read header values from. [ files: [, ...] ] ``` #### `` (Shared) OAuth 2.0 authentication using the client credentials grant type. Alertmanager fetches an access token from the specified endpoint with the given client access and secret keys. ```yaml client_id: [ client_secret: ] # Read the client secret from a file. # It is mutually exclusive with `client_secret`. [ client_secret_file: ] # Scopes for the token request. scopes: [ - ... ] # The URL to fetch the token from. token_url: # Optional parameters to append to the token URL. endpoint_params: [ : ... ] # Configures the token request's TLS settings. tls_config: [ ] # Optional proxy URL. [ proxy_url: ] # Comma-separated string that can contain IPs, CIDR notation, domain names # that should be excluded from proxying. IP and domain names can # contain port numbers. [ no_proxy: ] # Use proxy URL indicated by environment variables (HTTP_PROXY, https_proxy, HTTPs_PROXY, https_proxy, and no_proxy) [ proxy_from_environment: | default: false ] # Specifies headers to send to proxies during CONNECT requests. [ proxy_connect_header: [ : [, ...] ] ] ``` #### `` (Shared) A `tls_config` allows configuring TLS connections. ```yaml # CA certificate to validate the server certificate with. [ ca_file: ] # Certificate and key files for client cert authentication to the server. [ cert_file: ] [ key_file: ] # ServerName extension to indicate the name of the server. # http://tools.ietf.org/html/rfc4366#section-3.1 [ server_name: ] # Disable validation of the server certificate. [ insecure_skip_verify: | default = false] # Minimum acceptable TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS # 1.1), TLS12 (TLS 1.2), TLS13 (TLS 1.3). # If unset, Prometheus will use Go default minimum version, which is TLS 1.2. # See MinVersion in https://pkg.go.dev/crypto/tls#Config. [ min_version: ] # Maximum acceptable TLS version. Accepted values: TLS10 (TLS 1.0), TLS11 (TLS # 1.1), TLS12 (TLS 1.2), TLS13 (TLS 1.3). # If unset, Prometheus will use Go default maximum version, which is TLS 1.3. # See MaxVersion in https://pkg.go.dev/crypto/tls#Config. [ max_version: ] ``` ## Receiver integration settings These settings allow configuring specific receiver integrations. ### `` Discord notifications are sent via the [Discord webhook API](https://discord.com/developers/docs/resources/webhook). See Discord's ["Intro to Webhooks" article](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) to learn how to configure a webhook integration for a channel. ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The Discord webhook URL. # webhook_url and webhook_url_file are mutually exclusive. webhook_url: webhook_url_file: # Message title template. [ title: | default = '{{ template "discord.default.title" . }}' ] # Message body template. [ message: | default = '{{ template "discord.default.message" . }}' ] # Message content template. Limited to 2000 characters. [ content: | default = '{{ template "discord.default.content" . }}' ] # Message username. [ username: | default = '' ] # Message avatar URL. [ avatar_url: | default = '' ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` ### `` ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = false ] # The email address to send notifications to. # Allows a comma separated list of rfc5322 compliant email addresses. to: # The sender's address. [ from: | default = global.smtp_from ] # The SMTP host through which emails are sent. [ smarthost: | default = global.smtp_smarthost ] # The hostname to identify to the SMTP server. [ hello: | default = global.smtp_hello ] # SMTP authentication information. # auth_password and auth_password_file are mutually exclusive. # auth_secret and auth_secret_file are mutually exclusive. [ auth_username: | default = global.smtp_auth_username ] [ auth_password: | default = global.smtp_auth_password ] [ auth_password_file: | default = global.smtp_auth_password_file ] [ auth_secret: | default = global.smtp_auth_secret ] [ auth_secret_file: | default = global.smtp_auth_secret_file ] [ auth_identity: | default = global.smtp_auth_identity ] # The SMTP TLS requirement. # Note that Go does not support unencrypted connections to remote SMTP endpoints. [ require_tls: | default = global.smtp_require_tls ] # Force use of implicit TLS (direct TLS connection) for better security. # true: force use of implicit TLS (direct TLS connection on any port) # nil (default): auto-detect based on port (465=implicit, other=explicit) for backward compatibility [ force_implicit_tls: | default = nil ] # TLS configuration. tls_config: [ | default = global.smtp_tls_config ] # The HTML body of the email notification. [ html: | default = '{{ template "email.default.html" . }}' ] # The text body of the email notification. [ text: ] # Further headers email header key/value pairs. Overrides any headers # previously set by the notification implementation. [ headers: { : , ... } ] # Email threading configuration. threading: # Whether to enable threading, which makes alert notifications in the same # alert group show up in the same email thread. [ enabled: | default = false ] # What granularity of current date to thread by. Accepted values: daily, none. # (none means group by alert group key, no date). [ thread_by_date: | default = daily ] ``` #### Email TLS Configuration Examples ```yaml # Example 1: Force implicit TLS on any port (recommended for security) receivers: - name: email-implicit-tls email_configs: - to: alerts@example.com smarthost: smtp.example.com:8465 force_implicit_tls: true # Use direct TLS connection on port 8465 # Example 2: Backward compatible (no force_implicit_tls specified) receivers: - name: email-default email_configs: - to: alerts@example.com smarthost: smtp.example.com:465 # Auto-detects implicit TLS - to: alerts@example.com smarthost: smtp.example.com:587 # Auto-detects explicit TLS ``` ### `` Mattermost notifications are sent via the [Mattermost webhook API](https://developers.mattermost.com/integrate/webhooks/incoming/). ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The Mattermost webhook URL. # webhook_url and webhook_url_file are mutually exclusive. webhook_url: webhook_url_file: # Overrides the channel the message posts in. Use the channel’s name and not the display name, e.g. use town-square, not Town Square. [ channel: | default = '' ] # Overrides the username the message posts as. # Defaults to the username set during webhook creation; if no username was set during creation, webhook is used. [ username: | default = '' ] # Overrides the profile picture the message posts with. [ icon_url: | default = '' ] # Overrides the profile picture and icon_url parameter. [ icon_emoji: | default = '' ] # Message attachments used for richer formatting options. # It is for compatibility with Slack. [ fallback: | default = '{{ template "mattermost.default.fallback" . }}' ] [ color: | default = '{{ template "mattermost.default.color" . }}' ] [ title: | default = '{{ template "mattermost.default.title" . }}' ] [ title_link: | default = '{{ template "mattermost.default.titlelink" . }}' ] [ text: | default = '{{ template "mattermost.default.text" . }}' ] [ pretext: | default = '' ] [ author_name: | default = '' ] [ author_link: | default = '' ] [ author_icon: | default = '' ] [ fields: | default = '' ] [ ... ] [ thumb_url: | default = '' ] [ footer: | default = '' ] [ footer_icon: | default = '' ] [ image_url: | default = '' ] # Deprecated: use top-level fields instead; `attachments` will be removed in a future. [ attachments: ] [ ... ] [ props: ] [ priority: ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` #### `` See [Mattermost documentation](https://developers.mattermost.com/integrate/reference/message-attachments/) for more info. ```yaml [ fallback: | default = '' ] [ color: | default = '' ] [ pretext: | default = '' ] [ text: | default = '' ] [ author_name: | default = '' ] [ author_link: | default = '' ] [ author_icon: | default = '' ] [ title: | default = '' ] [ title_link: | default = '' ] # Same as Slack fields. [ fields: | default = '' ] [ ... ] [ thumb_url: | default = '' ] [ footer: | default = '' ] [ footer_icon: | default = '' ] [ image_url: | default = '' ] ``` #### `` ```yaml # Props card allows for extra information (Markdown-formatted text) to be sent to Mattermost that will only be displayed in the RHS panel after a user selects the info icon displayed alongside the post. [ card: | default = '' ] ``` #### `` ```yaml # priority adds label to the message. Possible values are "urgent", "important" and "standard". [ priority: | default = '' ] # If set to true, the message will be marked as requiring an acknowledgment from the users by displaying a checkmark icon next to the message. Keep in mind that this requires the message priority to be set to Important or Urgent. # Only for enterprise version of Mattermost. [ requested_ack: | default = false ] # Only for Urgent messages. If set to true recipients will receive a persistent notification every five minutes until they acknowledge the message. # Only for enterprise version of Mattermost. [ persistent_notifications: | default = false ] ``` ### `` Microsoft Teams notifications are sent via the [Incoming Webhooks](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors) API endpoint. DEPRECATION NOTICE: Microsoft is deprecating the creation and usage of [Microsoft 365 connectors via Microsoft Teams](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/). Consider migrating to using [Workflows](https://learn.microsoft.com/en-us/power-automate/teams/send-a-message-in-teams) with the msteamsv2 config. ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The incoming webhook URL. # webhook_url and webhook_url_file are mutually exclusive. [ webhook_url: ] [ webhook_url_file: ] # Message title template. [ title: | default = '{{ template "msteams.default.title" . }}' ] # Message summary template. [ summary: | default = '{{ template "msteams.default.summary" . }}' ] # Message body template. [ text: | default = '{{ template "msteams.default.text" . }}' ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` ### `` Microsoft Teams v2 notifications using the new message format with adaptive cards as required by [flows](https://learn.microsoft.com/en-us/power-automate/teams/overview). Please follow [the documentation](https://support.microsoft.com/en-gb/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) for more information on how to set up this integration. ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The incoming webhook URL. # webhook_url and webhook_url_file are mutually exclusive. [ webhook_url: ] [ webhook_url_file: ] # Message title template. [ title: | default = '{{ template "msteamsv2.default.title" . }}' ] # Message body template. [ text: | default = '{{ template "msteamsv2.default.text" . }}' ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` ### `` JIRA notifications are sent via [JIRA Rest API v2](https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/) or [JIRA REST API v3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/#version). Note: This integration is only tested against a Jira Cloud instance. Jira Data Center (on premise instance) can work, but it's not guaranteed. Both APIs have the same feature set. The difference is that V2 supports [Wiki Markup](https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=all) for the issue description and V3 supports [Atlassian Document Format (ADF)](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/). The default `jira.default.description` template only works with V2. ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The URL to send API requests to. The full API path must be included. # Example: https://company.atlassian.net/rest/api/2/ [ api_url: | default = global.jira_api_url ] # The API Type to use for search requests, can be either auto, cloud or datacenter # Example: cloud [ api_type: | default = auto ] # The project key where issues are created. project: # Issue summary configuration. [ summary: # Template for the issue summary. [ template: | default = '{{ template "jira.default.summary" . }}' ] # If set to false, the summary will not be updated when updating an existing issue. [ enable_update: | default = true ] ] # Issue description configuration. [ description: # Template for the issue description. [ template: | default = '{{ template "jira.default.description" . }}' ] # If set to false, the description will not be updated when updating an existing issue. [ enable_update: | default = true ] ] # Labels to be added to the issue. labels: [ - ... ] # Priority of the issue. [ priority: | default = '{{ template "jira.default.priority" . }}' ] # Type of the issue (e.g. Bug). [ issue_type: ] # Name of the workflow transition to resolve an issue. The target status must have the category "done". # NOTE: The name of the transition can be localized and depends on the language setting of the service account. [ resolve_transition: ] # Name of the workflow transition to reopen an issue. The target status should not have the category "done". # NOTE: The name of the transition can be localized and depends on the language setting of the service account. [ reopen_transition: ] # If reopen_transition is defined, ignore issues with that resolution. [ wont_fix_resolution: ] # If reopen_transition is defined, reopen the issue when it is not older than this value (rounded down to the nearest minute). # The resolutiondate field is used to determine the age of the issue. [ reopen_duration: ] # Other issue and custom fields. fields: [ : ... ] # The HTTP client's configuration. You must use this configuration to supply the personal access token (PAT) as part of the HTTP `Authorization` header. # For Jira Cloud, use basic_auth with the email address as the username and the PAT as the password. # For Jira Data Center, use the 'authorization' field with 'credentials: '. [ http_config: | default = global.http_config ] ``` The `labels` field is a list of labels added to the issue. Template expressions are supported. For example: ```yaml labels: - 'alertmanager' - '{{ .CommonLabels.severity }}' ``` #### `` Jira issue field can have multiple types. Depends on the field type, the values must be provided differently. See https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/#setting-custom-field-data-for-other-field-types for further examples. ```yaml fields: # Components components: { name: "Monitoring" } # Custom Field TextField customfield_10001: "Random text" # Custom Field SelectList customfield_10002: {"value": "red"} # Custom Field MultiSelect customfield_10003: [{"value": "red"}, {"value": "blue"}, {"value": "green"}] ``` ### `` OpsGenie notifications are sent via the [OpsGenie API](https://docs.opsgenie.com/docs/alert-api). ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The API key to use when talking to the OpsGenie API. [ api_key: | default = global.opsgenie_api_key ] # The filepath to API key to use when talking to the OpsGenie API. Conflicts with api_key. [ api_key_file: | default = global.opsgenie_api_key_file ] # The base URL for OpsGenie API requests. [ api_url: | default = global.opsgenie_api_url ] # Alert text limited to 130 characters. [ message: | default = '{{ template "opsgenie.default.message" . }}' ] # A description of the alert. [ description: | default = '{{ template "opsgenie.default.description" . }}' ] # A backlink to the sender of the notification. [ source: | default = '{{ template "opsgenie.default.source" . }}' ] # A set of arbitrary key/value pairs that provide further detail # about the alert. # All common labels are included as details by default. [ details: { : , ... } ] # List of responders responsible for notifications. responders: [ - ... ] # Comma separated list of tags attached to the notifications. [ tags: ] # Additional alert note. [ note: ] # Priority level of alert. Possible values are P1, P2, P3, P4, and P5. [ priority: ] # Whether to update message and description of the alert in OpsGenie if it already exists # By default, the alert is never updated in OpsGenie, the new message only appears in activity log. [ update_alerts: | default = false ] # Optional field that can be used to specify which domain alert is related to. [ entity: ] # Comma separated list of actions that will be available for the alert. [ actions: ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` #### `` ```yaml # Exactly one of these fields should be defined. [ id: ] [ name: ] [ username: ] # One of `team`, `teams`, `user`, `escalation` or `schedule`. # # The `teams` responder is configured using the `name` field above. # This field can contain a comma-separated list of team names. # If the list is empty, no responders are configured. type: ``` ### `` PagerDuty notifications are sent via the [PagerDuty API](https://developer.pagerduty.com/documentation/integration/events). PagerDuty provides [documentation](https://www.pagerduty.com/docs/guides/prometheus-integration-guide/) on how to integrate. There are important differences with Alertmanager's v0.11 and greater support of PagerDuty's Events API v2. ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The routing and service keys are mutually exclusive. # The PagerDuty integration key (when using PagerDuty integration type `Events API v2`). # It is mutually exclusive with `routing_key_file`. routing_key: # Read the Pager Duty routing key from a file. # It is mutually exclusive with `routing_key`. routing_key_file: # The PagerDuty integration key (when using PagerDuty integration type `Prometheus`). # It is mutually exclusive with `service_key_file`. service_key: # Read the Pager Duty service key from a file. # It is mutually exclusive with `service_key`. service_key_file: # The URL to send API requests to [ url: | default = global.pagerduty_url ] # The client identification of the Alertmanager. [ client: | default = '{{ template "pagerduty.default.client" . }}' ] # A backlink to the sender of the notification. [ client_url: | default = '{{ template "pagerduty.default.clientURL" . }}' ] # A description of the incident. [ description: | default = '{{ template "pagerduty.default.description" .}}' ] # Severity of the incident. [ severity: | default = 'error' ] # Unique location of the affected system. [ source: | default = client ] # A set of arbitrary key/value pairs that provide further detail about the incident. # Nested key/value pairs are accepted when using PagerDuty integration type `Events API v2`. [ details: { : , ... } | default = { firing: '{{ .Alerts.Firing | toJSON }}' resolved: '{{ .Alerts.Resolved | toJSON }}' num_firing: '{{ .Alerts.Firing | len }}' num_resolved: '{{ .Alerts.Resolved | len }}' } ] # Images to attach to the incident. images: [ ... ] # Links to attach to the incident. links: [ ... ] # The part or component of the affected system that is broken. [ component: ] # A cluster or grouping of sources. [ group: ] # The class/type of the event. [ class: ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] # The maximum time to wait for a pagerduty request to complete, before failing the # request and allowing it to be retried. The default value of 0s indicates that # no timeout should be applied. # NOTE: This will have no effect if set higher than the group_interval. [ timeout: | default = 0s ] ``` #### `` (PagerDuty) The fields are documented in the [PagerDuty API documentation](https://developer.pagerduty.com/docs/events-api-v2/trigger-events/#the-images-property). ```yaml href: src: alt: ``` #### `` (PagerDuty) The fields are documented in the [PagerDuty API documentation](https://developer.pagerduty.com/docs/events-api-v2/trigger-events/#the-links-property). ```yaml href: text: ``` ### `` Pushover notifications are sent via the [Pushover API](https://pushover.net/api). ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The recipient user's key. # user_key and user_key_file are mutually exclusive. user_key: user_key_file: # Your registered application's API token, see https://pushover.net/apps # You can also register a token by cloning this Prometheus app: # https://pushover.net/apps/clone/prometheus # token and token_file are mutually exclusive. token: token_file: # Notification title. [ title: | default = '{{ template "pushover.default.title" . }}' ] # Notification message. [ message: | default = '{{ template "pushover.default.message" . }}' ] # A supplementary URL shown alongside the message. [ url: | default = '{{ template "pushover.default.url" . }}' ] # Optional device to send notification to, see https://pushover.net/api#device [ device: ] # Optional sound to use for notification, see https://pushover.net/api#sound [ sound: ] # Priority, see https://pushover.net/api#priority [ priority: | default = '{{ if eq .Status "firing" }}2{{ else }}0{{ end }}' ] # How often the Pushover servers will send the same notification to the user. # Must be at least 30 seconds. [ retry: | default = 1m ] # How long your notification will continue to be retried for, unless the user # acknowledges the notification. [ expire: | default = 1h ] # Optional time to live (TTL) to use for notification, see https://pushover.net/api#ttl [ ttl: ] # Optional HTML/monospace formatting for the message, see https://pushover.net/api#html # html and monospace formatting are mutually exclusive. [ html: | default = false ] [ monospace: | default = false ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` ### `` Rocketchat notifications are sent via the [Rocketchat REST API](https://developer.rocket.chat/reference/api/rest-api/endpoints/messaging/chat-endpoints/postmessage). ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] [ api_url: | default = global.rocketchat_api_url ] [ channel: | default = global.rocketchat_api_url ] # The sender token and token_id # See https://docs.rocket.chat/use-rocket.chat/user-guides/user-panel/my-account#personal-access-tokens # token and token_file are mutually exclusive. # token_id and token_id_file are mutually exclusive. token: token_file: token_id: token_id_file: [ color: ... ] [ image_url ] [ thumb_url ] [ link_names ] [ short_fields: | default = false ] actions: [ ... ] ``` #### `` The fields are documented in the [Rocketchat API documentation](https://developer.rocket.chat/reference/api/rest-api/endpoints/messaging/chat-endpoints/postmessage#attachment-field-objects). ```yaml [ title: ] [ value: ] [ short: | default = rocketchat_config.short_fields ] ``` #### `` The fields are documented in the [Rocketchat API api models](https://github.com/RocketChat/Rocket.Chat.Go.SDK/blob/master/models/message.go). ```yaml [ type: | ignored, only "button" is supported ] [ text: ] [ url: ] [ msg: ] ``` ### `` Slack notifications can be sent via [Incoming webhooks](https://api.slack.com/messaging/webhooks) or [Bot tokens](https://api.slack.com/authentication/token-types). If using an incoming webhook then `api_url` must be set to the URL of the incoming webhook, or written to the file referenced in `api_url_file`. If using Bot tokens then `api_url` must be set to [`https://slack.com/api/chat.postMessage`](https://api.slack.com/methods/chat.postMessage), the bot token must be set as the authorization credentials in `http_config`, and `channel` must contain either the name of the channel or Channel ID to send notifications to. If using the name of the channel the # is optional. The notification contains an [attachment](https://docs.slack.dev/legacy/legacy-messaging/legacy-secondary-message-attachments/). ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = false ] # The Slack webhook URL. Either api_url/api_url_file OR app_token/app_token_file should be set, but not both. # Defaults to global settings if none are set here. [ api_url: | default = global.slack_api_url ] [ api_url_file: | default = global.slack_api_url_file ] # Slack App token for OAuth authentication. Mutually exclusive with api_url/api_url_file. # Defaults to global settings if no local authorization or webhook URL is configured. [ app_token: | default = global.slack_app_token ] [ app_token_file: | default = global.slack_app_token_file ] # The Slack App URL. Required when using app_token authentication. [ app_url: | default = global.slack_app_url ] # The channel or user to send notifications to. channel: # API request data as defined by the Slack webhook API. [ icon_emoji: | default = '{{ template "slack.default.iconemoji" . }}' ] [ icon_url: | default = '{{ template "slack.default.iconurl" . }}' ] [ link_names: | default = false ] # The text content of the Slack message. # If set, this is sent as the top-level 'text' field in the Slack payload. # This is useful for simple notifications or compatibility with Slack Workflow Webhooks. [ message_text: ] [ username: | default = '{{ template "slack.default.username" . }}' ] # The following parameters define the attachment. actions: [ ... ] [ callback_id: | default = '{{ template "slack.default.callbackid" . }}' ] [ color: | default = '{{ template "slack.default.color" . }}' ] [ fallback: | default = '{{ template "slack.default.fallback" . }}' ] fields: [ ... ] [ footer: | default = '{{ template "slack.default.footer" . }}' ] [ mrkdwn_in: [ , ... ] | default = ["fallback", "pretext", "text"] ] [ pretext: | default = '{{ template "slack.default.pretext" . }}' ] [ short_fields: | default = false ] [ text: | default = '{{ template "slack.default.text" . }}' ] [ title: | default = '{{ template "slack.default.title" . }}' ] [ title_link: | default = '{{ template "slack.default.titlelink" . }}' ] [ image_url: ] [ thumb_url: ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] # The maximum time to wait for a slack request to complete, before failing the # request and allowing it to be retried. The default value of 0s indicates that # no timeout should be applied. # NOTE: This will have no effect if set higher than the group_interval. [ timeout: | default = 0s ] # Enables updating existing Slack messages instead of creating new ones on alert state change. # Webhook URLs do not support updates. [ update_message: | default = false ] ``` #### `` (Slack) The fields are documented in the Slack API documentation for [message attachments](https://docs.slack.dev/legacy/legacy-messaging/legacy-secondary-message-attachments/) and [interactive messages](https://docs.slack.dev/legacy/legacy-messaging/legacy-interactive-message-field-guide/#action_fields). ```yaml text: type: # Either url or name and value are mandatory. [ url: ] [ name: ] [ value: ] [ confirm: ] [ style: | default = '' ] ``` ##### `` (Slack) The fields are documented in the [Slack API documentation](https://api.slack.com/legacy/interactive-message-field-guide#confirmation_fields). ```yaml text: [ dismiss_text: | default '' ] [ ok_text: | default '' ] [ title: | default '' ] ``` #### `` (Slack) The fields are documented in the [Slack API documentation](https://docs.slack.dev/legacy/legacy-messaging/legacy-secondary-message-attachments/). ```yaml title: value: [ short: | default = slack_config.short_fields ] ``` ### `` ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The SNS API URL i.e. https://sns.us-east-2.amazonaws.com. # If not specified, the SNS API URL from the SNS SDK will be used. [ api_url: ] # Configures AWS's Signature Verification 4 signing process to sign requests. sigv4: [ ] # SNS topic ARN, i.e. arn:aws:sns:us-east-2:698519295917:My-Topic # If you don't specify this value, you must specify a value for the phone_number or target_arn. # If you are using a FIFO SNS topic you should set a message group interval longer than 5 minutes # to prevent messages with the same group key being deduplicated by the SNS default deduplication window [ topic_arn: ] # Subject line when the message is delivered to email endpoints. [ subject: | default = '{{ template "sns.default.subject" .}}' ] # Phone number if message is delivered via SMS in E.164 format. # If you don't specify this value, you must specify a value for the topic_arn or target_arn. [ phone_number: ] # The mobile platform endpoint ARN if message is delivered via mobile notifications. # If you don't specify this value, you must specify a value for the topic_arn or phone_number. [ target_arn: ] # The message content of the SNS notification. [ message: | default = '{{ template "sns.default.message" .}}' ] # SNS message attributes. attributes: [ : ... ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` #### `` (SNS) ```yaml # The AWS region. If blank, the region from the default credentials chain is used. [ region: ] # The AWS API keys. Both access_key and secret_key must be supplied or both must be blank. # If blank the environment variables `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are used. [ access_key: ] [ secret_key: ] # Named AWS profile used to authenticate. [ profile: ] # AWS Role ARN, an alternative to using AWS API keys. [ role_arn: ] ``` ### `` ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The Telegram API URL i.e. https://api.telegram.org. # If not specified, default API URL will be used. [ api_url: | default = global.telegram_api_url ] # Telegram bot token. It is mutually exclusive with `bot_token_file`. [ bot_token: ] # Read the Telegram bot token from a file. It is mutually exclusive with `bot_token`. [ bot_token_file: ] # ID of the chat where to send the messages. It is mutually exclusive with `chat_id_file`. [ chat_id: ] # Read the chat ID from a file. It is mutually exclusive with `chat_id`. [ chat_id_file: ] # Optional ID of the message thread where to send the messages. [ message_thread_id: ] # Message template. [ message: default = '{{ template "telegram.default.message" .}}' ] # Disable telegram notifications [ disable_notifications: | default = false ] # Parse mode for telegram message, supported values are MarkdownV2, Markdown, HTML and empty string for plain text. # If the message exceeds Telegram's character limit, it will be truncated or replaced with a fallback message if parse_mode is set to HTML. [ parse_mode: | default = "HTML" ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` ### `` VictorOps notifications are sent out via the [VictorOps API](https://help.victorops.com/knowledge-base/rest-endpoint-integration-guide/) ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The API key to use when talking to the VictorOps API. # It is mutually exclusive with `api_key_file`. [ api_key: | default = global.victorops_api_key ] # Reads the API key to use when talking to the VictorOps API from a file. # It is mutually exclusive with `api_key`. [ api_key_file: | default = global.victorops_api_key_file ] # The VictorOps API URL. [ api_url: | default = global.victorops_api_url ] # A key used to map the alert to a team. routing_key: # Describes the behavior of the alert (CRITICAL, WARNING, INFO). [ message_type: | default = 'CRITICAL' ] # Contains summary of the alerted problem. [ entity_display_name: | default = '{{ template "victorops.default.entity_display_name" . }}' ] # Contains long explanation of the alerted problem. [ state_message: | default = '{{ template "victorops.default.state_message" . }}' ] # The monitoring tool the state message is from. [ monitoring_tool: | default = '{{ template "victorops.default.monitoring_tool" . }}' ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] ``` ### `` The webhook receiver allows configuring a generic receiver. ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The endpoint to send HTTP POST requests to. # url and url_file are mutually exclusive. url: url_file: # The HTTP client's configuration. [ http_config: | default = global.http_config ] # The maximum number of alerts to include in a single webhook message. Alerts # above this threshold are truncated. When leaving this at its default value of # 0, all alerts are included. [ max_alerts: | default = 0 ] # The maximum time to wait for a webhook request to complete, before failing the # request and allowing it to be retried. The default value of 0s indicates that # no timeout should be applied. # NOTE: This will have no effect if set higher than the group_interval. [ timeout: | default = 0s ] # Define custom payload to be sent to the webhook endpoint. # USE AT YOUR OWN RISK: This is an advanced configuration option that allows you # to define a custom payload using Go templates. Be aware that the Alertmanager does not # perform any validation on the resulting payload, and it is your responsibility to # ensure that the generated payload is in the desired format expected by the receiving endpoint. # The payload has to be valid JSON. You can use the `toJson` function to help with this. # THE ALERTMANAGER TEAM WILL NOT PROVIDE ANY SUPPORT FOR ISSUES ARISING FROM THE USE OF THIS OPTION. [ payload: { : , ... } ] ``` The Alertmanager will send HTTP POST requests in the following JSON format to the configured endpoint: ``` { "version": "4", "groupKey": , // key identifying the group of alerts (e.g. to deduplicate) "truncatedAlerts": , // how many alerts have been truncated due to "max_alerts" "status": "", "receiver": , "groupLabels": , "commonLabels": , "commonAnnotations": , "externalURL": , // backlink to the Alertmanager. "alerts": [ { "status": "", "labels": , "annotations": , "startsAt": "", "endsAt": "", "generatorURL": , // identifies the entity that caused the alert "fingerprint": // fingerprint to identify the alert }, ... ] } ``` There is a list of [integrations](https://prometheus.io/docs/operating/integrations/#alertmanager-webhook-receiver) with this feature. ### `` incident.io notifications are sent via the [incident.io Alert Sources API](https://api-docs.incident.io/tag/Alert-Sources-V2#operation/Alert%20Sources%20V2_Create). When configuring this integration, you can do so via the `http_config` by setting the `authorization` directly or using one of `alert_source_token` or `alert_source_token_file`. The configuration of `alert_source_token` or `alert_source_token_file` takes precedence over `http_config`. Please be aware that if the payload exceeds incident.io's API limits (512KB), the integration will automatically truncate all alerts except the first one. ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The HTTP client's configuration. [ http_config: | default = global.http_config ] # The URL to send the incident.io alert. This would typically be provided by the # incident.io team when setting up an alert source. # URL and URL_file are mutually exclusive. url: url_file: # The alert source token is used to authenticate with incident.io. # alert_source_token and alert_source_token_file are mutually exclusive. [ alert_source_token: ] [ alert_source_token_file: ] # The maximum number of alerts to be sent per incident.io message. # Alerts exceeding this threshold will be truncated. Setting this to 0 # allows an unlimited number of alerts. Note that if the payload exceeds # incident.io's size limits (512KB), the notifier will automatically drop # all alerts except the first one. If the payload is still too # large after this truncation, you will receive a 429 response and alerts # will not be ingested. [ max_alerts: | default = 0 ] # Timeout is the maximum time allowed to invoke incident.io. Setting this to 0 # does not impose a timeout. [ timeout: | default = 0s ] ``` ### `` WeChat notifications are sent via the [WeChat API](https://developers.weixin.qq.com/doc/offiaccount/en/Message_Management/Service_Center_messages.html). ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = false ] # The API key to use when talking to the WeChat API. Either api_secret or api_secret_file should be set. [ api_secret: | default = global.wechat_api_secret ] [ api_secret_file: | default = global.wechat_api_secret_file ] # The WeChat API URL. [ api_url: | default = global.wechat_api_url ] # The corp id for authentication. [ corp_id: | default = global.wechat_api_corp_id ] # API request data as defined by the WeChat API. [ message: | default = '{{ template "wechat.default.message" . }}' ] # Type of the message type, supported values are `text` and `markdown`. [ message_type: | default = 'text' ] [ agent_id: | default = '{{ template "wechat.default.agent_id" . }}' ] [ to_user: | default = '{{ template "wechat.default.to_user" . }}' ] [ to_party: | default = '{{ template "wechat.default.to_party" . }}' ] [ to_tag: | default = '{{ template "wechat.default.to_tag" . }}' ] ``` ### `` ```yaml # Whether to notify about resolved alerts. [ send_resolved: | default = true ] # The Webex Teams API URL i.e. https://webexapis.com/v1/messages # If not specified, default API URL will be used. [ api_url: | default = global.webex_api_url ] # ID of the Webex Teams room where to send the messages. room_id: # Message template. [ message: default = '{{ template "webex.default.message" .}}' ] # The HTTP client's configuration. You must use this configuration to supply the bot token as part of the HTTP `Authorization` header. [ http_config: | default = global.http_config ] ``` ## Tracing Configuration ### `` ```yaml # The tracing client type, supported values are `http` and `grpc`. [ client_type: | default = "grpc" ] # The tracing endpoint. [ endpoint: | default = "" ] # The sampling fraction. [ sampling_fraction: | default = 0.0 ] # Whether to disable TLS. [ insecure: | default = false ] # The HTTP client's configuration. [ tls_config: ] # Custom HTTP headers. [ http_headers: [ ] ] # The tracing compression. [ compression: | default = "gzip" ] # The tracing timeout. [ timeout: | default = 0s ] ``` ================================================ FILE: docs/high_availability.md ================================================ --- title: High Availability sort_rank: 4 nav_icon: network --- Alertmanager supports configuration to create a cluster for high availability. This document describes how the HA mechanism works, its design goals, and operational considerations. ## Design Goals The Alertmanager HA implementation is designed around three core principles: 1. **Single pane view and management** - Silences and alerts can be viewed and managed from any cluster member, providing a unified operational experience 2. **Survive cluster split-brain with "fail open"** - During network partitions, Alertmanager prefers to send duplicate notifications rather than miss critical alerts 3. **At-least-once delivery** - The system guarantees that notifications are delivered at least once, in line with the fail-open philosophy These goals prioritize operational reliability and alert delivery over strict exactly-once semantics. ## Architecture Overview An Alertmanager cluster consists of multiple Alertmanager instances that communicate using a gossip protocol. Each instance: - Receives alerts independently from Prometheus servers - Participates in a peer-to-peer gossip mesh - Replicates state (silences and notification log) to other cluster members - Processes and sends notifications independently ``` ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Prometheus 1 │ │ Prometheus 2 │ │ Prometheus N │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ │ alerts │ alerts │ alerts │ │ │ ▼ ▼ ▼ ┌────────────────────────────────────────────┐ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ AM-1 │ │ AM-2 │ │ AM-3 │ │ │ │ (pos: 0) ├──┤ (pos: 1) ├──┤ (pos: 2) │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ Gossip Protocol (Memberlist) │ └────────────────────────────────────────────┘ │ │ │ ▼ ▼ ▼ Receivers Receivers Receivers ``` ## Gossip Protocol Alertmanager uses [Hashicorp's Memberlist](https://github.com/hashicorp/memberlist) library to implement gossip-based communication. The gossip protocol handles: ### Membership Management - **Automatic peer discovery** - Instances can be configured with a list of known peers and will automatically discover other cluster members - **Health checking** - Regular probes detect failed members (default: every 1 second) - **Failure detection** - Failed members are marked and can attempt to rejoin ### State Replication The gossip layer replicates three types of state: 1. **Silences** - Create, update, and delete operations are broadcast to all peers 2. **Notification log** - Records of which notifications were sent to prevent duplicates 3. **Membership changes** - Join, leave, and failure events State is eventually consistent - all cluster members will converge to the same state given sufficient time and network connectivity. ### Gossip Settling When an Alertmanager starts or rejoins the cluster, it waits for gossip to "settle" before processing notifications. This prevents sending notifications based on incomplete state. The settling algorithm waits until: - The number of peers remains stable for 3 consecutive checks (default interval: push-pull interval) - Or a timeout occurs (configurable via context) During this time, the instance already receives and stores alerts but defers notification processing. ## Notification Pipeline in HA Mode The notification pipeline operates differently in a clustered environment to ensure deduplication while maintaining at-least-once delivery: ``` ┌────────────────────────────────────────────────┐ │ DISPATCHER STAGE │ ├────────────────────────────────────────────────┤ │ 1. Find matching route(s) │ │ 2. Find/create aggregation group within route │ │ 3. Throttle by group wait or group interval │ └───────────────────┬────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────┐ │ NOTIFIER STAGE │ ├────────────────────────────────────────────────┤ │ 1. Wait for HA gossip to settle │◄─── Ensures complete state │ 2. Filter inhibited alerts │ │ 3. Filter non-time-active alerts │ │ 4. Filter time-muted alerts │ │ 5. Filter silenced alerts │◄─── Uses replicated silences │ 6. Wait according to HA cluster peer index │◄─── Staggered notifications │ 7. Dedupe by repeat interval/HA state │◄─── Uses notification log │ 8. Notify & retry intermittent failures │ │ 9. Update notification log │◄─── Replicated to peers └────────────────────────────────────────────────┘ ``` ### HA-Specific Stages #### 1. Gossip Settling Wait Before the first notification from a group, the instance waits for gossip to settle. This ensures: - Silences are fully replicated - The notification log contains recent send records from other instances - The cluster membership is stable **Implementation**: `peer.WaitReady(ctx)` #### 2. Peer Position-Based Wait To prevent all cluster members from sending notifications simultaneously, each instance waits based on its position in the sorted peer list: ``` wait_time = peer_position × peer_timeout ``` For example, with 3 instances and a 15-second peer timeout: - Instance `am-1` (position 0): waits 0 seconds - Instance `am-2` (position 1): waits 15 seconds - Instance `am-3` (position 2): waits 30 seconds This staggered timing allows: - The first instance to send the notification - Subsequent instances to see the notification log entry - Deduplication to prevent duplicate sends **Implementation**: `clusterWait()` in `cmd/alertmanager/main.go:594` Position is determined by sorting all peer names alphabetically: ```go func (p *Peer) Position() int { all := p.mlist.Members() sort.Slice(all, func(i, j int) bool { return all[i].Name < all[j].Name }) // Find position of self in sorted list } ``` #### 3. Deduplication via Notification Log The `DedupStage` queries the notification log to determine if a notification should be sent: ```go // Check notification log for recent sends entry := nflog.Query(receiver, groupKey) if entry.exists && !shouldNotify(entry, alerts, repeatInterval) { // Skip: already notified recently return nil } ``` Deduplication checks: - **Firing alerts changed?** If yes, notify - **Resolved alerts changed?** If yes and `send_resolved: true`, notify - **Repeat interval elapsed?** If yes, notify - **Otherwise**: Skip notification (deduplicated) The notification log is replicated via gossip, so all cluster members share the same send history. ## Split-Brain Handling (Fail Open) During a network partition, the cluster may split into multiple groups that cannot communicate. Alertmanager's "fail open" design ensures alerts are still delivered: ### Scenario: Network Partition ``` Before partition: ┌────────┬────────┬────────┐ │ AM-1 │ AM-2 │ AM-3 │ └────────┴────────┴────────┘ Unified cluster After partition: ┌────────┐ │ ┌────────┬────────┐ │ AM-1 │ │ │ AM-2 │ AM-3 │ └────────┘ │ └────────┴────────┘ Partition A │ Partition B ``` ### Behavior During Partition **In Partition A** (AM-1 alone): - AM-1 sees itself as position 0 - Waits 0 × timeout = 0 seconds - Sends notifications (no dedup from AM-2/AM-3) **In Partition B** (AM-2, AM-3): - AM-2 is position 0, AM-3 is position 1 - AM-2 waits 0 seconds, sends notification - AM-3 sees AM-2's notification log entry, deduplicates **Result**: Duplicate notifications sent (one from Partition A, one from Partition B) This is **intentional** - Alertmanager prefers duplicate notifications over missed alerts. ### After Partition Heals When the network partition heals: 1. Gossip protocol detects all peers again 2. Notification logs are merged (via CRDT-like merge with timestamp) 3. Future notifications are deduplicated correctly across all instances 4. Silences created in either partition are replicated to all peers ## Silence Management in HA Silences are first-class replicated state in the cluster. ### Silence Creation and Updates When a silence is created or updated on any instance: 1. **Local storage** - Silence is stored in the local state map 2. **Broadcast** - Silence is serialized (protobuf) and broadcast via gossip 3. **Merge on receive** - Other instances receive and merge the silence: ```go // Merge logic: last-write-wins based on UpdatedAt timestamp if !exists || incoming.UpdatedAt > existing.UpdatedAt { accept_update() } ``` 4. **Indexing** - The silence matcher cache is updated for fast alert matching ### Silence Expiry Silences have: - `StartsAt`, `EndsAt` - The active time range - `ExpiresAt` - When to garbage collect (EndsAt + retention period) - `UpdatedAt` - For conflict resolution during merge Each instance independently: - Evaluates silence state (pending/active/expired) based on current time - Garbage collects expired silences past their retention period - The GC is local only (no gossip) since all instances converge to the same decision ### Single Pane of Glass Users can interact with any Alertmanager instance in the cluster: - **View silences** - All instances have the same silence state (eventually consistent) - **Create/update silences** - Changes made on any instance propagate to all peers - **Delete silences** - Implemented as "expire immediately" + gossip This provides a unified operational experience regardless of which instance you access. ## Operational Considerations ### Configuration To configure a cluster, each Alertmanager instance needs: ```yaml # alertmanager.yml global: # ... other config ... # No cluster config in YAML - use CLI flags ``` Command-line flags: ```bash alertmanager \ --cluster.listen-address=0.0.0.0:9094 \ --cluster.peer=am-1.example.com:9094 \ --cluster.peer=am-2.example.com:9094 \ --cluster.peer=am-3.example.com:9094 \ --cluster.advertise-address=$(hostname):9094 \ --cluster.peer-timeout=15s \ --cluster.gossip-interval=200ms \ --cluster.pushpull-interval=60s ``` Key flags: - `--cluster.listen-address` - Bind address for cluster communication (default: `0.0.0.0:9094`) - `--cluster.peer` - List of peer addresses (can be repeated) - `--cluster.advertise-address` - Address advertised to peers (auto-detected if omitted) - `--cluster.peer-timeout` - Wait time per peer position for deduplication (default: `15s`) - `--cluster.gossip-interval` - How often to gossip (default: `200ms`) - `--cluster.pushpull-interval` - Full state sync interval (default: `60s`) - `--cluster.probe-interval` - Peer health check interval (default: `1s`) - `--cluster.settle-timeout` - Max time to wait for gossip settling (default: context timeout) ### Prometheus Configuration **Important**: Configure Prometheus to send alerts to **all** Alertmanager instances, not via a load balancer. ```yaml # prometheus.yml alerting: alertmanagers: - static_configs: - targets: - am-1.example.com:9093 - am-2.example.com:9093 - am-3.example.com:9093 ``` This ensures: - **Redundancy** - If one Alertmanager is down, others still receive alerts - **Independent processing** - Each instance independently evaluates routing, grouping, and deduplication - **No single point of failure** - Load balancers introduce a single point of failure ### Cluster Size Considerations Since Alertmanager uses gossip without quorum or voting, **any N instances tolerate up to N-1 failures** - as long as one instance is alive, notifications will be sent. However, cluster size involves tradeoffs: **Benefits of more instances:** - Greater resilience to simultaneous failures (hardware, network, datacenter outages) - Continued operation even during maintenance windows **Costs of more instances:** - In case of partitions there will be an increase in duplicate notifications - More gossip traffic **Typical deployments:** - **2-3 instances** - Common for single-datacenter production deployments - **4-5 instances** - Multi-datacenter or highly critical environments **Note**: Unlike consensus-based systems (etcd, Raft), odd vs. even cluster sizes make no difference - there is no voting or quorum. ### Monitoring Cluster Health Key metrics to monitor: ``` # Cluster size alertmanager_cluster_members # Peer health alertmanager_cluster_peer_info # Peer position (affects notification timing) alertmanager_peer_position # Failed peers alertmanager_cluster_failed_peers # State replication alertmanager_nflog_gossip_messages_propagated_total alertmanager_silences_gossip_messages_propagated_total ``` ### Security By default, cluster communication is unencrypted. For production deployments, especially across WANs, use mutual TLS: ```bash alertmanager \ --cluster.tls-config=/etc/alertmanager/cluster-tls.yml ``` See [Secure Cluster Traffic](../doc/design/secure-cluster-traffic.md) for details. ### Persistence Each Alertmanager instance persists: - **Silences** - Stored in a snapshot file (default: `data/silences`) - **Notification log** - Stored in a snapshot file (default: `data/nflog`) On restart: 1. Instance loads silences and notification log from disk 2. Joins the cluster and gossips with peers 3. Merges state received from peers (newer timestamps win) 4. Begins processing notifications after gossip settling **Note**: Alerts themselves are **not** persisted - Prometheus re-sends firing alerts regularly. ### Common Pitfalls 1. **Load balancing Prometheus → Alertmanager** - ❌ Don't use a load balancer - ✅ Configure all instances in Prometheus 2. **Not waiting for gossip to settle** - Can lead to missed silences or duplicate notifications on startup - The `--cluster.settle-timeout` flag controls this 3. **Network ACLs blocking cluster port** - Ensure port 9094 (or your `--cluster.listen-address` port) is open between all instances - Both TCP and UDP are used by default (TCP only if using TLS transport) 4. **Unroutable advertise addresses** - If `--cluster.advertise-address` is not set, Alertmanager tries to auto-detect - For cloud/NAT environments, explicitly set a routable address 5. **Mismatched cluster configurations** - All instances should have the same `--cluster.peer-timeout` and gossip settings - Mismatches can cause unnecessary duplicates or missed notifications ## How It Works: End-to-End Example ### Scenario: 3-instance cluster, new alert group 1. **Alert arrives** at all 3 instances from Prometheus 2. **Dispatcher** creates aggregation group, waits `group_wait` (e.g., 30s) 3. **After group_wait**: - Each instance prepares to notify 4. **Notifier stage**: - All instances wait for gossip to settle (if just started) - **AM-1** (position 0): waits 0s, checks notification log (empty), sends notification, logs to nflog - **AM-2** (position 1): waits 15s, checks notification log (sees AM-1's entry), **skips** notification - **AM-3** (position 2): waits 30s, checks notification log (sees AM-1's entry), **skips** notification 5. **Result**: Exactly one notification sent (by AM-1) ### Scenario: AM-1 fails 1. **Alert arrives** at AM-2 and AM-3 only 2. **Dispatcher** creates group, waits `group_wait` 3. **Notifier stage**: - AM-1 is not in cluster (failed probe) - **AM-2** is now position 0: waits 0s, sends notification - **AM-3** is now position 1: waits 15s, sees AM-2's entry, skips 4. **Result**: Notification still sent (fail-open) ### Scenario: Network partition during notification 1. **Alert arrives** at all instances 2. **Network partition** splits AM-1 from AM-2/AM-3 3. **In partition A** (AM-1): - Position 0, waits 0s, sends notification 4. **In partition B** (AM-2, AM-3): - AM-2 is position 0, waits 0s, sends notification - AM-3 is position 1, waits 15s, deduplicates 5. **Result**: Two notifications sent (one per partition) - fail-open behavior ## Troubleshooting ### Check cluster status ```bash # View cluster members via API curl http://am-1:9093/api/v2/status # Check metrics curl http://am-1:9093/metrics | grep cluster ``` ### Diagnose split-brain If you suspect split-brain: 1. Check `alertmanager_cluster_members` on each instance - Should match total cluster size 2. Check `alertmanager_cluster_peer_info{state="alive"}` - Should show all peers as alive 3. Review network connectivity between instances ### Debug duplicate notifications Duplicate notifications can occur due to: 1. **Network partitions** (expected, fail-open) 2. **Gossip not settled** - Check `--cluster.settle-timeout` 3. **Clock skew** - Ensure NTP is configured on all instances 4. **Notification log not replicating** - Check gossip metrics Enable debug logging: ```bash alertmanager --log.level=debug ``` Look for: - `"Waiting for gossip to settle..."` - `"gossip settled; proceeding"` - Deduplication decisions in notification pipeline ## Further Reading - [Alertmanager Configuration](configuration.md) - [Secure Cluster Traffic Design](../doc/design/secure-cluster-traffic.md) - [Hashicorp Memberlist Documentation](https://github.com/hashicorp/memberlist) ================================================ FILE: docs/https.md ================================================ --- title: HTTPS and authentication sort_rank: 11 --- Alertmanager supports basic authentication and TLS. This is **experimental** and might change in the future. Currently TLS is supported for the HTTP traffic and gossip traffic. ## HTTP Traffic To specify which web configuration file to load, use the `--web.config.file` flag. The file is written in [YAML format](https://en.wikipedia.org/wiki/YAML), defined by the scheme described below. Brackets indicate that a parameter is optional. For non-list parameters the value is set to the specified default. The file is read upon every http request, such as any change in the configuration and the certificates is picked up immediately. Generic placeholders are defined as follows: * ``: a boolean that can take the values `true` or `false` * ``: a valid path in the current working directory * ``: a regular string that is a secret, such as a password * ``: a regular string ``` tls_server_config: # Certificate and key files for server to use to authenticate to client. cert_file: key_file: # Server policy for client authentication. Maps to ClientAuth Policies. # For more detail on clientAuth options: # https://golang.org/pkg/crypto/tls/#ClientAuthType # # NOTE: If you want to enable client authentication, you need to use # RequireAndVerifyClientCert. Other values are insecure. [ client_auth_type: | default = "NoClientCert" ] # CA certificate for client certificate authentication to the server. [ client_ca_file: ] # Verify that the client certificate has a Subject Alternate Name (SAN) # which is an exact match to an entry in this list, else terminate the # connection. SAN match can be one or multiple of the following: DNS, # IP, e-mail, or URI address from https://pkg.go.dev/crypto/x509#Certificate. [ client_allowed_sans: [ - ] ] # Minimum TLS version that is acceptable. [ min_version: | default = "TLS12" ] # Maximum TLS version that is acceptable. [ max_version: | default = "TLS13" ] # List of supported cipher suites for TLS versions up to TLS 1.2. If empty, # Go default cipher suites are used. Available cipher suites are documented # in the go documentation: # https://golang.org/pkg/crypto/tls/#pkg-constants # # Note that only the cipher returned by the following function are supported: # https://pkg.go.dev/crypto/tls#CipherSuites [ cipher_suites: [ - ] ] # prefer_server_cipher_suites controls whether the server selects the # client's most preferred ciphersuite, or the server's most preferred # ciphersuite. If true then the server's preference, as expressed in # the order of elements in cipher_suites, is used. [ prefer_server_cipher_suites: | default = true ] # Elliptic curves that will be used in an ECDHE handshake, in preference # order. Available curves are documented in the go documentation: # https://golang.org/pkg/crypto/tls/#CurveID [ curve_preferences: [ - ] ] http_server_config: # Enable HTTP/2 support. Note that HTTP/2 is only supported with TLS. # This can not be changed on the fly. [ http2: | default = true ] # List of headers that can be added to HTTP responses. [ headers: # Set the Content-Security-Policy header to HTTP responses. # Unset if blank. [ Content-Security-Policy: ] # Set the X-Frame-Options header to HTTP responses. # Unset if blank. Accepted values are deny and sameorigin. # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options [ X-Frame-Options: ] # Set the X-Content-Type-Options header to HTTP responses. # Unset if blank. Accepted value is nosniff. # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options [ X-Content-Type-Options: ] # Set the X-XSS-Protection header to all responses. # Unset if blank. # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection [ X-XSS-Protection: ] # Set the Strict-Transport-Security header to HTTP responses. # Unset if blank. # Please make sure that you use this with care as this header might force # browsers to load Prometheus and the other applications hosted on the same # domain and subdomains over HTTPS. # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security [ Strict-Transport-Security: ] ] # Usernames and hashed passwords that have full access to the web # server via basic authentication. If empty, no basic authentication is # required. Passwords are hashed with bcrypt. basic_auth_users: [ : ... ] ``` ## Gossip Traffic To specify whether to use mutual TLS for gossip, use the `--cluster.tls-config` flag. The server and client sides of the gossip are configurable. ``` tls_server_config: # Certificate and key files for server to use to authenticate to client. cert_file: key_file: # Server policy for client authentication. Maps to ClientAuth Policies. # For more detail on clientAuth options: # https://golang.org/pkg/crypto/tls/#ClientAuthType [ client_auth_type: | default = "NoClientCert" ] # CA certificate for client certificate authentication to the server. [ client_ca_file: ] # Minimum TLS version that is acceptable. [ min_version: | default = "TLS12" ] # Maximum TLS version that is acceptable. [ max_version: | default = "TLS13" ] # List of supported cipher suites for TLS versions up to TLS 1.2. If empty, # Go default cipher suites are used. Available cipher suites are documented # in the go documentation: # https://golang.org/pkg/crypto/tls/#pkg-constants [ cipher_suites: [ - ] ] # prefer_server_cipher_suites controls whether the server selects the # client's most preferred ciphersuite, or the server's most preferred # ciphersuite. If true then the server's preference, as expressed in # the order of elements in cipher_suites, is used. [ prefer_server_cipher_suites: | default = true ] # Elliptic curves that will be used in an ECDHE handshake, in preference # order. Available curves are documented in the go documentation: # https://golang.org/pkg/crypto/tls/#CurveID [ curve_preferences: [ - ] ] tls_client_config: # Path to the CA certificate with which to validate the server certificate. [ ca_file: ] # Certificate and key files for client cert authentication to the server. [ cert_file: ] [ key_file: ] # Server name extension to indicate the name of the server. # http://tools.ietf.org/html/rfc4366#section-3.1 [ server_name: ] # Disable validation of the server certificate. [ insecure_skip_verify: | default = false] ``` ================================================ FILE: docs/index.md ================================================ --- title: Alerting sort_rank: 7 nav_icon: bell-o --- ================================================ FILE: docs/integrations.md ================================================ --- title: Notification Integrations sort_rank: 4 --- Alertmanager supports a number of notification integrations via the [configuration file](configuration.md). ## Available Integrations | Name | Configuration | External Configuration | API Reference | |------|---------------|-------------------------------------|---------------| | [Amazon SNS](https://aws.amazon.com/sns/) | [sns_config](configuration.md#sns_config) | [Amazon SNS Documentation](https://docs.aws.amazon.com/sns/) | [SNS API Reference](https://docs.aws.amazon.com/sns/latest/api/welcome.html) | | [Discord](https://discord.com/) | [discord_config](configuration.md#discord_config) | [Intro to Webhooks](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) | [Discord Webhook API](https://discord.com/developers/docs/resources/webhook) | | [Email](https://en.wikipedia.org/wiki/Email) | [email_config](configuration.md#email_config) | - | [SMTP](https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol) | | [incident.io](https://incident.io/) | [incidentio_config](configuration.md#incidentio_config) | [Alert Sources Documentation](https://api-docs.incident.io/tag/Alert-Sources-V2) | [Alert Sources V2 API](https://api-docs.incident.io/tag/Alert-Sources-V2#operation/Alert%20Sources%20V2_Create) | | [Jira](https://www.atlassian.com/software/jira) | [jira_config](configuration.md#jira_config) | [Jira Cloud Platform](https://developer.atlassian.com/cloud/jira/platform/) | [REST API v2](https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/) / [REST API v3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/) | | [Mattermost](https://mattermost.com/) | [mattermost_config](configuration.md#mattermost_config) | [Incoming Webhooks](https://developers.mattermost.com/integrate/webhooks/incoming/) | [Mattermost Webhook API](https://developers.mattermost.com/integrate/webhooks/incoming/) | | [Microsoft Teams](https://www.microsoft.com/en-us/microsoft-teams/) | [msteams_config](configuration.md#msteams_config) | [Incoming Webhooks (Deprecated)](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors) | [Microsoft Teams Connectors](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors) | | [Microsoft Teams v2](https://www.microsoft.com/en-us/microsoft-teams/) | [msteamsv2_config](configuration.md#msteamsv2_config) | [Workflows for Teams](https://support.microsoft.com/en-gb/office/create-incoming-webhooks-with-workflows-for-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498) | [Power Automate Flows](https://learn.microsoft.com/en-us/power-automate/teams/overview) | | [OpsGenie](https://www.atlassian.com/software/opsgenie) | [opsgenie_config](configuration.md#opsgenie_config) | [OpsGenie Documentation](https://docs.opsgenie.com/) | [OpsGenie Alert API](https://docs.opsgenie.com/docs/alert-api) | | [PagerDuty](https://www.pagerduty.com/) | [pagerduty_config](configuration.md#pagerduty_config) | [Prometheus Integration Guide](https://www.pagerduty.com/docs/guides/prometheus-integration-guide/) | [PagerDuty Events API](https://developer.pagerduty.com/documentation/integration/events) | | [Pushover](https://pushover.net/) | [pushover_config](configuration.md#pushover_config) | [Pushover Documentation](https://pushover.net/api) | [Pushover API](https://pushover.net/api) | | [Rocket.Chat](https://rocket.chat/) | [rocketchat_config](configuration.md#rocketchat_config) | [Personal Access Tokens](https://docs.rocket.chat/use-rocket.chat/user-guides/user-panel/my-account#personal-access-tokens) | [Rocket.Chat REST API](https://developer.rocket.chat/reference/api/rest-api/endpoints/messaging/chat-endpoints/postmessage) | | [Slack](https://slack.com/) | [slack_config](configuration.md#slack_config) | [Incoming Webhooks](https://api.slack.com/messaging/webhooks) / [Bot Tokens](https://api.slack.com/authentication/token-types) | [Slack API](https://api.slack.com/methods/chat.postMessage) | | [Telegram](https://telegram.org/) | [telegram_config](configuration.md#telegram_config) | [Telegram Bots](https://core.telegram.org/bots) | [Telegram Bot API](https://core.telegram.org/bots/api) | | [VictorOps](https://victorops.com/) | [victorops_config](configuration.md#victorops_config) | [REST Endpoint Integration Guide](https://help.victorops.com/knowledge-base/rest-endpoint-integration-guide/) | [VictorOps REST API](https://help.victorops.com/knowledge-base/rest-endpoint-integration-guide/) | | [Webex](https://www.webex.com/) | [webex_config](configuration.md#webex_config) | [Webex for Developers](https://developer.webex.com/) | [Webex Messages API](https://developer.webex.com/docs/api/v1/messages) | | [Webhook](https://en.wikipedia.org/wiki/Webhook) | [webhook_config](configuration.md#webhook_config) | [Webhook Integrations](https://prometheus.io/docs/operating/integrations/#alertmanager-webhook-receiver) | - | | [WeChat](https://www.wechat.com/) | [wechat_config](configuration.md#wechat_config) | [WeChat Work Documentation](https://developers.weixin.qq.com/doc/offiaccount/en/Message_Management/Service_Center_messages.html) | [WeChat Work API](https://developers.weixin.qq.com/doc/offiaccount/en/Message_Management/Service_Center_messages.html) | For notification mechanisms not natively supported by the Alertmanager, the webhook receiver allows for integration. An incomplete list can be found [here](https://prometheus.io/docs/operating/integrations/#alertmanager-webhook-receiver). ================================================ FILE: docs/management_api.md ================================================ --- title: Management API sort_rank: 9 --- Alertmanager provides a set of management API to ease automation and integrations. ### Health check ``` GET /-/healthy HEAD /-/healthy ``` This endpoint always returns 200 and should be used to check Alertmanager health. ### Readiness check ``` GET /-/ready HEAD /-/ready ``` This endpoint returns 200 when Alertmanager is ready to serve traffic (i.e. respond to queries). ### Reload ``` POST /-/reload ``` This endpoint triggers a reload of the Alertmanager configuration file. An alternative way to trigger a configuration reload is by sending a `SIGHUP` to the Alertmanager process. ================================================ FILE: docs/notification_examples.md ================================================ --- title: Notification template examples sort_rank: 8 --- The following are all different examples of alerts and corresponding Alertmanager configuration file setups (alertmanager.yml). Each use the [Go templating](http://golang.org/pkg/text/template/) system. ## Customizing Slack notifications In this example we've customised our Slack notification to send a URL to our organisation's wiki on how to deal with the particular alert that's been sent. ``` global: # Also possible to place this URL in a file. # Ex: `slack_api_url_file: '/etc/alertmanager/slack_url'` slack_api_url: '' route: receiver: 'slack-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'slack-notifications' slack_configs: - channel: '#alerts' text: 'https://internal.myorg.net/wiki/alerts/{{ .GroupLabels.app }}/{{ .GroupLabels.alertname }}' ``` ## Accessing annotations in CommonAnnotations In this example we again customize the text sent to our Slack receiver accessing the `summary` and `description` stored in the `CommonAnnotations` of the data sent by the Alertmanager. Alert ``` groups: - name: Instances rules: - alert: InstanceDown expr: up == 0 for: 5m labels: severity: page # Prometheus templates apply here in the annotation and label fields of the alert. annotations: description: '{{ $labels.instance }} of job {{ $labels.job }} has been down for more than 5 minutes.' summary: 'Instance {{ $labels.instance }} down' ``` Receiver ``` - name: 'team-x' slack_configs: - channel: '#alerts' # Alertmanager templates apply here. text: " \nsummary: {{ .CommonAnnotations.summary }}\ndescription: {{ .CommonAnnotations.description }}" ``` ## Ranging over all received Alerts Finally, assuming the same alert as the previous example, we customize our receiver to range over all of the alerts received from the Alertmanager, printing their respective annotation summaries and descriptions on new lines. Receiver ``` - name: 'default-receiver' slack_configs: - channel: '#alerts' title: "{{ range .Alerts }}{{ .Annotations.summary }}\n{{ end }}" text: "{{ range .Alerts }}{{ .Annotations.description }}\n{{ end }}" ``` ## Defining reusable templates Going back to our first example, we can also provide a file containing named templates which are then loaded by Alertmanager in order to avoid complex templates that span many lines. Create a file under `/alertmanager/template/myorg.tmpl` and create a template in it named "slack.myorg.text": ``` {{ define "slack.myorg.text" }}https://internal.myorg.net/wiki/alerts/{{ .GroupLabels.app }}/{{ .GroupLabels.alertname }}{{ end}} ``` The configuration now loads the template with the given name for the "text" field and we provide a path to our custom template file: ``` global: slack_api_url: '' route: receiver: 'slack-notifications' group_by: [alertname, datacenter, app] receivers: - name: 'slack-notifications' slack_configs: - channel: '#alerts' text: '{{ template "slack.myorg.text" . }}' templates: - '/etc/alertmanager/templates/myorg.tmpl' ``` This example is explained in further detail in this [blogpost](https://prometheus.io/blog/2016/03/03/custom-alertmanager-templates/). ================================================ FILE: docs/notifications.md ================================================ --- title: Notification template reference sort_rank: 7 --- Prometheus creates and sends alerts to the Alertmanager which then sends notifications out to different receivers based on their labels. A receiver can be one of many integrations including: Slack, PagerDuty, email, or a custom integration via the generic webhook interface. The notifications sent to receivers are constructed via templates. The Alertmanager comes with default templates but they can also be customized. To avoid confusion it's important to note that the Alertmanager templates differ from [templating in Prometheus](https://prometheus.io/docs/visualization/template_reference/), however Prometheus templating also includes the templating in alert rule labels/annotations. The Alertmanager's notification templates are based on the [Go templating](http://golang.org/pkg/text/template) system. Note that some fields are evaluated as text, and others as HTML which will affect escaping. # Data Structures ## Data `Data` is the structure passed to notification templates and webhook pushes. | Name | Type | Notes | | ------------- | ------------- | -------- | | Receiver | string | Defines the receiver's name that the notification will be sent to (slack, email etc.). | | Status | string | Defined as firing if at least one alert is firing, otherwise resolved. | | Alerts | [Alert](#alert) | List of all alert objects in this group ([see below](#alert)). | | GroupLabels | [KV](#kv) | The labels these alerts were grouped by. | | CommonLabels | [KV](#kv) | The labels common to all of the alerts. | | CommonAnnotations | [KV](#kv) | Set of common annotations to all of the alerts. Used for longer additional strings of information about the alert. | | ExternalURL | string | Backlink to the Alertmanager that sent the notification. | The `Alerts` type exposes functions for filtering alerts: - `Alerts.Firing` returns a list of currently firing alert objects in this group - `Alerts.Resolved` returns a list of resolved alert objects in this group ## Alert `Alert` holds one alert for notification templates. | Name | Type | Notes | | ------------- | ------------- | -------- | | Status | string | Defines whether or not the alert is resolved or currently firing. | | Labels | [KV](#kv) | A set of labels to be attached to the alert. | | Annotations | [KV](#kv) | A set of annotations for the alert. | | StartsAt | time.Time | The time the alert started firing. If omitted, the current time is assigned by the Alertmanager. | | EndsAt | time.Time | Only set if the end time of an alert is known. Otherwise set to a configurable timeout period from the time since the last alert was received. | | GeneratorURL | string | A backlink which identifies the causing entity of this alert. | | Fingerprint | string | Fingerprint that can be used to identify the alert. | ## KV `KV` is a set of key/value string pairs used to represent labels and annotations. ``` type KV map[string]string ``` Annotation example containing two annotations: ``` { summary: "alert summary", description: "alert description", } ``` In addition to direct access of data (labels and annotations) stored as KV, there are also methods for sorting, removing, and viewing the LabelSets: ### KV methods | Name | Arguments | Returns | Notes | | ------------- | ------------- | -------- | -------- | | SortedPairs | - | Pairs (list of key/value string pairs.) | Returns a sorted list of key/value pairs. | | Remove | []string | KV | Returns a copy of the key/value map without the given keys. | | Names | - | []string | Returns the names of the label names in the LabelSet. | | Values | - | []string | Returns a list of the values in the LabelSet. | # Functions Note the [default functions](http://golang.org/pkg/text/template/#hdr-Functions) also provided by Go templating. ## Strings | Name | Arguments | Description | | ---------------- | -------------------------- | ----------- | | date | string, time.Time | Returns the text representation of the time in the specified format. For documentation on formats refer to [pkg.go.dev/time](https://pkg.go.dev/time#pkg-constants). | | humanizeDuration | number or string | Returns a human-readable string representing the duration, and the error if it happened. | | join | sep string, s []string | [strings.Join](http://golang.org/pkg/strings/#Join), concatenates the elements of s to create a single string. The separator string sep is placed between elements in the resulting string. (note: argument order inverted for easier pipelining in templates.) | | match | pattern, string | [Regexp.MatchString](https://golang.org/pkg/regexp/#MatchString). Match a string using Regexp. | | reReplaceAll | pattern, replacement, text | [Regexp.ReplaceAllString](http://golang.org/pkg/regexp/#Regexp.ReplaceAllString) Regexp substitution, unanchored. | | safeHtml | text string | [html/template.HTML](https://golang.org/pkg/html/template/#HTML), Marks string as HTML not requiring auto-escaping. | | safeUrl | text string | [html/template.URL](https://golang.org/pkg/html/template/#URL), Marks string as URL not requiring auto-escaping. | | since | time.Time | [time.Since](https://pkg.go.dev/time#Since), returns the duration of how much time passed from the provided time till the current system time. | | stringSlice | ...string | Returns the passed strings as a slice of strings. | | title | string | [strings.Title](http://golang.org/pkg/strings/#Title), capitalises first character of each word. | | toJson | any | [json.Marshal](https://pkg.go.dev/encoding/json#Marshal), returns the JSON encoding of the value. | | toLower | string | [strings.ToLower](http://golang.org/pkg/strings/#ToLower), converts all characters to lower case. | | toUpper | string | [strings.ToUpper](http://golang.org/pkg/strings/#ToUpper), converts all characters to upper case. | | trimSpace | string | [strings.TrimSpace](https://pkg.go.dev/strings#TrimSpace), removes leading and trailing white spaces. | | tz | string, time.Time | Returns the time in the timezone. For example, Europe/Paris. | | urlUnescape | text string | [url.QueryUnescape](https://pkg.go.dev/net/url#QueryUnescape), unescapes a URL with % encoding | ================================================ FILE: docs/overview.md ================================================ --- title: Alerting overview sort_rank: 1 nav_icon: sliders --- Alerting with Prometheus is separated into two parts. Alerting rules in Prometheus servers send alerts to an Alertmanager. The [Alertmanager](alertmanager.md) then manages those alerts, including silencing, inhibition, aggregation and sending out notifications via methods such as email, on-call notification systems, and chat platforms. The main steps to setting up alerting and notifications are: * Setup and [configure](configuration.md) the Alertmanager * [Configure Prometheus](https://prometheus.io/docs/prometheus/latest/configuration/configuration/#alertmanager_config) to talk to the Alertmanager * Create [alerting rules](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/) in Prometheus ================================================ FILE: examples/ha/send_alerts.sh ================================================ #!/usr/bin/env bash alerts1='[ { "labels": { "alertname": "DiskRunningFull", "dev": "sda1", "instance": "example1" }, "annotations": { "info": "The disk sda1 is running full", "summary": "please check the instance example1" } }, { "labels": { "alertname": "DiskRunningFull", "dev": "sda2", "instance": "example1" }, "annotations": { "info": "The disk sda2 is running full", "summary": "please check the instance example1", "runbook": "the following link http://test-url should be clickable" } }, { "labels": { "alertname": "DiskRunningFull", "dev": "sda1", "instance": "example2" }, "annotations": { "info": "The disk sda1 is running full", "summary": "please check the instance example2" } }, { "labels": { "alertname": "DiskRunningFull", "dev": "sdb2", "instance": "example2" }, "annotations": { "info": "The disk sdb2 is running full", "summary": "please check the instance example2" } }, { "labels": { "alertname": "DiskRunningFull", "dev": "sda1", "instance": "example3", "severity": "critical" } }, { "labels": { "alertname": "DiskRunningFull", "dev": "sda1", "instance": "example3", "severity": "warning" } } ]' curl -XPOST -d"$alerts1" http://localhost:9093/api/v1/alerts curl -XPOST -d"$alerts1" http://localhost:9094/api/v1/alerts curl -XPOST -d"$alerts1" http://localhost:9095/api/v1/alerts ================================================ FILE: examples/ha/tls/Makefile ================================================ # Based on https://github.com/wolfeidau/golang-massl/ .PHONY: start start: goreman start .PHONY: gen-certs gen-certs: certs/ca.pem certs/node1.pem certs/node1-key.pem certs/node2.pem certs/node2-key.pem certs/ca.pem certs/ca-key.pem: certs/ca-csr.json cd certs; cfssl gencert -initca ca-csr.json | cfssljson -bare ca certs/node1.pem certs/node1-key.pem: certs/ca-config.json certs/ca.pem certs/ca-key.pem certs/node1-csr.json cd certs; cfssl gencert \ -ca=ca.pem \ -ca-key=ca-key.pem \ -config=ca-config.json \ -hostname=localhost,127.0.0.1 \ -profile=massl node1-csr.json | cfssljson -bare node1 certs/node2.pem certs/node2-key.pem: certs/ca-config.json certs/ca.pem certs/ca-key.pem certs/node2-csr.json cd certs; cfssl gencert \ -ca=ca.pem \ -ca-key=ca-key.pem \ -config=ca-config.json \ -hostname=localhost,127.0.0.1 \ -profile=massl node2-csr.json | cfssljson -bare node2 ================================================ FILE: examples/ha/tls/Procfile ================================================ a1: ./../../../alertmanager --log.level=debug --storage.path=$TMPDIR/a1 --web.listen-address=:9093 --cluster.listen-address=127.0.0.1:8001 --config.file=../alertmanager.yml --cluster.tls-config=./tls_config_node1.yml a2: ./../../../alertmanager --log.level=debug --storage.path=$TMPDIR/a2 --web.listen-address=:9094 --cluster.listen-address=127.0.0.1:8002 --cluster.peer=127.0.0.1:8001 --config.file=../alertmanager.yml --cluster.tls-config=./tls_config_node2.yml a3: ./../../../alertmanager --log.level=debug --storage.path=$TMPDIR/a3 --web.listen-address=:9095 --cluster.listen-address=127.0.0.1:8003 --cluster.peer=127.0.0.1:8001 --config.file=../alertmanager.yml --cluster.tls-config=./tls_config_node1.yml a4: ./../../../alertmanager --log.level=debug --storage.path=$TMPDIR/a4 --web.listen-address=:9096 --cluster.listen-address=127.0.0.1:8004 --cluster.peer=127.0.0.1:8001 --config.file=../alertmanager.yml --cluster.tls-config=./tls_config_node2.yml a5: ./../../../alertmanager --log.level=debug --storage.path=$TMPDIR/a5 --web.listen-address=:9097 --cluster.listen-address=127.0.0.1:8005 --cluster.peer=127.0.0.1:8001 --config.file=../alertmanager.yml --cluster.tls-config=./tls_config_node1.yml a6: ./../../../alertmanager --log.level=debug --storage.path=$TMPDIR/a6 --web.listen-address=:9098 --cluster.listen-address=127.0.0.1:8006 --cluster.peer=127.0.0.1:8001 --config.file=../alertmanager.yml --cluster.tls-config=./tls_config_node2.yml wh: go run ../../webhook/echo.go ================================================ FILE: examples/ha/tls/README.md ================================================ # TLS Transport Config Example ## Usage 1. Install dependencies: 1. `go install github.com/cloudflare/cfssl/cmd/cfssl` 2. `go install github.com/mattn/goreman` 2. Build Alertmanager (root of repository): 1. `go mod download` 1. `make build`. 2. `make start` (inside this directory). ## Testing 1. Start the cluster (as explained above) 2. Navigate to one of the Alertmanager instances at `localhost:9093`. 3. Create a silence. 4. Navigate to the other Alertmanager instance at `localhost:9094`. 5. Observe that the silence created in the other Alertmanager instance has been synchronized over to this instance. 6. Repeat. ================================================ FILE: examples/ha/tls/certs/ca-config.json ================================================ { "signing": { "default": { "expiry": "876000h" }, "profiles": { "massl": { "usages": ["signing", "key encipherment", "server auth", "client auth"], "expiry": "876000h" } } } } ================================================ FILE: examples/ha/tls/certs/ca-csr.json ================================================ { "CN": "massl", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "AU", "L": "Melbourne", "O": "massl", "OU": "VIC", "ST": "Victoria" } ] } ================================================ FILE: examples/ha/tls/certs/ca-key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAuljDjKVGwlyiuKTSHc1QpoZPX9dbgwU/9113ctI8U/ZwMWLp nZ4f/zVpf4LW5foM9zSEUGPiyJe/NaTZUOXkRBSIQ13QroK4OJ1XGacQKpTxewCb ChESZEfKWEhnP/Y7BYc4z1Li6Dkxh4TIElHwOVe62jbhNnzYlr4evmSuiuItAc8u hEYxncThPzmHEWPXKw8CFNhxCSYsjbb72UAIht0knMHQ7VXBX1VuuL0rolJBiToC va+I6CjG0c6qfi9/BcPsuW6cNjmQnwTg6SaSoGO/5zgbxBgy9MZQEot88d1T2XH6 rBANYsfojvyCXuytWnj04mvdAWwmFh0hhq+nxQIDAQABAoIBAQCwcL1vXUq7W4UD OaRtbWrQ0dk0ETBnxT/E0y33fRJ8GZovWM2EXSVEuukSP+uEQ5elNYeWqo0fi3cT ruvJSnMw9xPyXVDq+4C8slW3R1TqTK683VzvUizM4KC5qIyCpn1KBbgHrh6E7Sp1 e4cIuaawVN3qIg5qThmx2YA4nBIcEt68q9cpy3NgEe+EQf44zM/St+y8kSkDUOVw fNKX0WfZ/hPL1TAYpWiIgSf+m/V3d/1l/scvMYONcuSjXSORCyoeAWYtOQgf78wW 9j3kiBTaqDYCUZFnY/ltlZrm8ltAaKVJ0MmPKjVh8GJBXZp9fSVU8Y4ZIZRSeuBA OoStHGAdAoGBAMluMIE33hGny2V0dNzW23D84eXQK38AsdP632jQeuzxBknItg45 qAfzh8F8W10DQnSv5tj0bmOHfo0mG09bu9eo5nLLINOE7Ju/7ly/76RNJNJ4ADjx JKZi/PpvfP+s/fzel0X3OPgA+CJKzUHuqlU4V9BLc7focZAYtaM2w7rHAoGBAOzU eXpapkqYhbYRcsrVV57nZV0rLzsLVJBpJg2zC8un95ALrr0rlZfuPJfOCY/uuS1w f8ixRz2MkRWGreLHy35NB4GV0sF9VPn1jMp9SuBNvO0JRUMWuDAdVe8SCjXadrOh +m3yKJSkFKDchglUYnZKV1skgA/b9jjjnu2fvd0TAoGAVUTnFZxvzmuIp78fxWjS 5ka23hE8iHvjy4e00WsHzovNjKiBoQ35Orx16ItbJcm+dSUNhSQcItf104yhHPwJ Tab7PvcMQ15OxzP9lJfPu27Iuqv/9Bro1+Kpkt5lPNqffk9AHGcmX54RbHrb3yBI TOEYE14Nc3nbsRM0uQ3y13sCgYB5Om4QZpSWvKo9P4M+NqTKb3JglblwhOU9osVa 39ra3dkIgCJrLQM/KTEVF9+nMLDThLG0fqKT6/9cQHuECXet6Co+d/3RE6HK7Zmr ESWh2ckqoMM2i0uvPWT+ooJdfL2kR/bUDtAc/jyc9yUZY3ufR4Cd4/o1pAfOqR1y T4G1xwKBgQChE4VWawCVg2qanRjvZcdNk0zpZx4dxqqKYq/VHuSfjNLQixIZsgXT xx9BHuORn6c/nurqEStLwN3BzbpPU/j6YjMUmTslSH2sKhHwWNYGBZC52aJiOOda Bz6nAkihG0n2PjYt2T84w6FWHgLJuSsmiEVJcb+AOdyKh1MlzJiwMQ== -----END RSA PRIVATE KEY----- ================================================ FILE: examples/ha/tls/certs/ca.csr ================================================ -----BEGIN CERTIFICATE REQUEST----- MIICpzCCAY8CAQAwYjELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIw EAYDVQQHEwlNZWxib3VybmUxDjAMBgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMx DjAMBgNVBAMTBW1hc3NsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA uljDjKVGwlyiuKTSHc1QpoZPX9dbgwU/9113ctI8U/ZwMWLpnZ4f/zVpf4LW5foM 9zSEUGPiyJe/NaTZUOXkRBSIQ13QroK4OJ1XGacQKpTxewCbChESZEfKWEhnP/Y7 BYc4z1Li6Dkxh4TIElHwOVe62jbhNnzYlr4evmSuiuItAc8uhEYxncThPzmHEWPX Kw8CFNhxCSYsjbb72UAIht0knMHQ7VXBX1VuuL0rolJBiToCva+I6CjG0c6qfi9/ BcPsuW6cNjmQnwTg6SaSoGO/5zgbxBgy9MZQEot88d1T2XH6rBANYsfojvyCXuyt Wnj04mvdAWwmFh0hhq+nxQIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAJFmooMt TocElxCb3DGJTRUXxr4DqcATASIX35a2wV3MmPqUHHXr6BQkO/FRho66EsZf3DE/ mumou01K+KByxgsmw04CACjSeZ2t/g6pAsDCKrx/BwL3tAo09lG2Y2Ah0BND2Cta EZpTliU2MimZlk7UZb8VIXh2Tx56fZRoHLzO4U4+FY8ZR+tspxPRM7hLg/aUqA5D zGj6kByX8aYjxsmQokP4rx/w2mz6vwt4cZ1pXwr0RderkMIh9Har/0k9X1WIAP61 PNQx74qnaq+icjtN2+8gvJE/CJL/wfcwW6kQwEtX1xsTpnzyFaRoYpSPQrvkCtiW +WzgnOh7RvKyAYI= -----END CERTIFICATE REQUEST----- ================================================ FILE: examples/ha/tls/certs/ca.pem ================================================ Certificate: Data: Version: 3 (0x2) Serial Number: 7a:d7:1c:f3:22:da:b1:20:31:bf:25:16:b6:04:d5:29:1e:a3:7c:12 Signature Algorithm: sha256WithRSAEncryption Issuer: C=AU, ST=Victoria, L=Melbourne, O=massl, OU=VIC, CN=massl Validity Not Before: Nov 6 22:02:17 2024 GMT Not After : Nov 1 22:02:17 2044 GMT Subject: C=AU, ST=Victoria, L=Melbourne, O=massl, OU=VIC, CN=massl Subject Public Key Info: Public Key Algorithm: rsaEncryption Public-Key: (2048 bit) Modulus: 00:ba:58:c3:8c:a5:46:c2:5c:a2:b8:a4:d2:1d:cd: 50:a6:86:4f:5f:d7:5b:83:05:3f:f7:5d:77:72:d2: 3c:53:f6:70:31:62:e9:9d:9e:1f:ff:35:69:7f:82: d6:e5:fa:0c:f7:34:84:50:63:e2:c8:97:bf:35:a4: d9:50:e5:e4:44:14:88:43:5d:d0:ae:82:b8:38:9d: 57:19:a7:10:2a:94:f1:7b:00:9b:0a:11:12:64:47: ca:58:48:67:3f:f6:3b:05:87:38:cf:52:e2:e8:39: 31:87:84:c8:12:51:f0:39:57:ba:da:36:e1:36:7c: d8:96:be:1e:be:64:ae:8a:e2:2d:01:cf:2e:84:46: 31:9d:c4:e1:3f:39:87:11:63:d7:2b:0f:02:14:d8: 71:09:26:2c:8d:b6:fb:d9:40:08:86:dd:24:9c:c1: d0:ed:55:c1:5f:55:6e:b8:bd:2b:a2:52:41:89:3a: 02:bd:af:88:e8:28:c6:d1:ce:aa:7e:2f:7f:05:c3: ec:b9:6e:9c:36:39:90:9f:04:e0:e9:26:92:a0:63: bf:e7:38:1b:c4:18:32:f4:c6:50:12:8b:7c:f1:dd: 53:d9:71:fa:ac:10:0d:62:c7:e8:8e:fc:82:5e:ec: ad:5a:78:f4:e2:6b:dd:01:6c:26:16:1d:21:86:af: a7:c5 Exponent: 65537 (0x10001) X509v3 extensions: X509v3 Key Usage: critical Certificate Sign, CRL Sign X509v3 Basic Constraints: critical CA:TRUE X509v3 Subject Key Identifier: 77:80:D3:12:52:AA:EA:09:C6:60:32:59:80:9B:C2:FB:87:E5:AD:90 Signature Algorithm: sha256WithRSAEncryption Signature Value: 92:f2:a4:8f:7d:04:f1:7e:08:b0:6b:3e:0c:b9:88:29:18:b6: ce:88:4e:84:b0:10:8b:ca:b5:d6:6a:fb:12:52:14:f2:4e:01: bb:b3:8b:a0:b4:65:d9:fd:d4:c7:6b:44:54:3a:e5:5b:c9:0e: bd:3c:3b:f7:41:0a:67:1d:5a:21:32:7c:42:3b:b1:37:b4:c0: 78:07:4b:ae:e2:18:77:90:85:33:70:46:20:61:1a:7a:67:38: 0a:cf:fc:1c:bd:d2:c6:1a:0e:09:5a:d5:36:74:8a:8e:66:0f: 1f:47:69:7a:17:a7:d3:bf:74:40:85:3f:80:a2:53:00:2a:65: 3c:3f:ca:44:d9:ec:71:cf:17:4e:3d:b0:1e:5e:e8:73:ab:0a: 27:95:02:88:2b:b0:46:9a:4d:a4:7d:05:ba:df:4c:e5:65:d3: 2b:12:fd:17:74:51:f2:bb:d1:0e:32:8c:e9:ee:42:5c:d7:3c: 85:60:f0:1a:52:fc:11:31:e1:12:8c:c9:a0:1f:1f:52:7e:d9: 1e:a0:c7:f7:48:05:9d:dc:f5:c1:59:5a:9b:e7:bd:a3:37:54: 8a:42:c7:10:d7:51:19:99:e2:e7:d3:56:66:18:4a:d0:d1:f6: 25:1d:c9:f9:48:60:43:cc:6f:9c:ba:95:03:3e:a0:5a:ad:26: d8:ce:4c:4a -----BEGIN CERTIFICATE----- MIIDlDCCAnygAwIBAgIUetcc8yLasSAxvyUWtgTVKR6jfBIwDQYJKoZIhvcNAQEL BQAwYjELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIwEAYDVQQHEwlN ZWxib3VybmUxDjAMBgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMxDjAMBgNVBAMT BW1hc3NsMB4XDTI0MTEwNjIyMDIxN1oXDTQ0MTEwMTIyMDIxN1owYjELMAkGA1UE BhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIwEAYDVQQHEwlNZWxib3VybmUxDjAM BgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMxDjAMBgNVBAMTBW1hc3NsMIIBIjAN BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuljDjKVGwlyiuKTSHc1QpoZPX9db gwU/9113ctI8U/ZwMWLpnZ4f/zVpf4LW5foM9zSEUGPiyJe/NaTZUOXkRBSIQ13Q roK4OJ1XGacQKpTxewCbChESZEfKWEhnP/Y7BYc4z1Li6Dkxh4TIElHwOVe62jbh NnzYlr4evmSuiuItAc8uhEYxncThPzmHEWPXKw8CFNhxCSYsjbb72UAIht0knMHQ 7VXBX1VuuL0rolJBiToCva+I6CjG0c6qfi9/BcPsuW6cNjmQnwTg6SaSoGO/5zgb xBgy9MZQEot88d1T2XH6rBANYsfojvyCXuytWnj04mvdAWwmFh0hhq+nxQIDAQAB o0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU d4DTElKq6gnGYDJZgJvC+4flrZAwDQYJKoZIhvcNAQELBQADggEBAJLypI99BPF+ CLBrPgy5iCkYts6IToSwEIvKtdZq+xJSFPJOAbuzi6C0Zdn91MdrRFQ65VvJDr08 O/dBCmcdWiEyfEI7sTe0wHgHS67iGHeQhTNwRiBhGnpnOArP/By90sYaDgla1TZ0 io5mDx9HaXoXp9O/dECFP4CiUwAqZTw/ykTZ7HHPF049sB5e6HOrCieVAogrsEaa TaR9BbrfTOVl0ysS/Rd0UfK70Q4yjOnuQlzXPIVg8BpS/BEx4RKMyaAfH1J+2R6g x/dIBZ3c9cFZWpvnvaM3VIpCxxDXURmZ4ufTVmYYStDR9iUdyflIYEPMb5y6lQM+ oFqtJtjOTEo= -----END CERTIFICATE----- ================================================ FILE: examples/ha/tls/certs/node1-csr.json ================================================ { "CN": "system:server", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "AU", "L": "Melbourne", "O": "system:node1", "OU": "massl", "ST": "Victoria" } ] } ================================================ FILE: examples/ha/tls/certs/node1-key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEA1b9bm4rvDtpYsqgtCC52+L535d4/Q2O10fWD2i2CfRXXfYJQ 5cr4AV2iqScFsJSs7KwyQde/c4VWj/vEA2/SJHZFBlknKdCcrgHVebrvnzm6Guze ICutZSKocFXy9Kw+YmWuA64nHVfmSCKG07GhXhEsLsSCn4PTDYOiGAUm1GdSDxUp 8yUXec13Eb20mld0xE9kQnCnEWRnxMXtQJXoz9lpLc7DgXtN6nCXSG/CqdDPOduU nmseaxyAGpAFnUmxcqUuYAJUQ1hUOJhk0RVSsLTmu+FGdOxk79AxmmKQ2z9l/GuA VikVJGTxY4jRPezxHQ3bdqzzCIdJxTxLinftZQIDAQABAoIBADpxQtvphemau8vF feKRycfDVEcOmF+VoL4SkgWSke4fjbbsbbAW6e59qp7zY3PfgtSHVIp6Mgek+oEN xo9mAKAlkkPlFncxadWN/M921FPF1ePMxgMnzhYr/sAQUAikG76NrKGm+VzljrpE bnbtR4DP0zPKWSjCQ2+bgTNuHSrPwUtEngVT6ugjfWU1RitlvjTsZ9hSuOSBlS7P rjbQGaEh53PraDut8PIlF4wIF+nLeERFP/a6DC8Btpbv9P50YRosag6yU/G+OYX9 spvBPvRJGrubslKnNRz9AcjbVd3QhL+Tm7mV7iakK918jLWb95Ro4WW+9lT6IAi6 xRSOr9UCgYEA5wI3JhKkYa4PST7ALqmJSDkPH8+tctiEx+ovmnqBufFuLWFoO/rc EOYslnaZM3UVCnhrFv7+LxezSI5DyQu8dBEzf0RMICvXUNBkGC7ZJQL428fjXPhX 8mZIoJ0ol4hbamr8yTYlK0vGTwqN1bDj71w6NszuN4ecN1cKNWsMbnMCgYEA7N8Y MzHWNijMr7xZ1lXl4Ye9+kp3aTUjUYBBaxFr4bQ8y0fH48lzq3qOso5wgvp0DKYo uemD5QKbo81LKoTRLa+nxPq0UqKm9FiSWmnrcxMuph989oZ1ZFHA2B+nvbuMTF8J 8sESclTSbgkG87DpycJOUwG3XAcXM+80pXuzJscCgYB+Dzxu/09KqoRW8PJIxGVQ zypMrrS07iiPO2FcyCtQf8oi43vQ91Ttt91vAisZ5HNl8k5mDyJAKovANToSVOAy 6kwSz/9GswXdaMqmU7JVOyj4Lj0JN9AuS9ioJPrIrjVMfjORzYU8+i2uZlD94niP 3uE5lF0OWmdJ36qHefIftwKBgQDcPQZcO19H1iGS2FbTYeSvEK5ENM7YRG8FTXIF 4hnjrtjDzYb+tYVWEErznFrifYo/ZJMDYSqgWQ9reusDqqBvkR41mUDmgJMpJ91U MZ2YzmIWVbqz4QrvbtAWY0Bsuh/VtpwiWQAUy+coJj6PgJOvY3m91h+tcm5RfHz/ zIcjawKBgA6kDcOLOnWcvhP3XwtW5dcWlNuBBNEKna+kIT/5Vlrh91y2w7F54DNK i0w5CZCpbTugJmZ67XLHnfongC7e2vAQ3atoT96RU4mf9614qs9LMtGAbnuCLB8+ sT2rnaZKtzr83ensbYkbBxP/zmPBfFQ9FKcIYIA7En8zAIr2T3vJ -----END RSA PRIVATE KEY----- ================================================ FILE: examples/ha/tls/certs/node1.csr ================================================ -----BEGIN CERTIFICATE REQUEST----- MIIC5TCCAc0CAQAwczELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIw EAYDVQQHEwlNZWxib3VybmUxFTATBgNVBAoTDHN5c3RlbTpub2RlMTEOMAwGA1UE CxMFbWFzc2wxFjAUBgNVBAMTDXN5c3RlbTpzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQDVv1ubiu8O2liyqC0ILnb4vnfl3j9DY7XR9YPaLYJ9 Fdd9glDlyvgBXaKpJwWwlKzsrDJB179zhVaP+8QDb9IkdkUGWScp0JyuAdV5uu+f Oboa7N4gK61lIqhwVfL0rD5iZa4DricdV+ZIIobTsaFeESwuxIKfg9MNg6IYBSbU Z1IPFSnzJRd5zXcRvbSaV3TET2RCcKcRZGfExe1AlejP2WktzsOBe03qcJdIb8Kp 0M8525Seax5rHIAakAWdSbFypS5gAlRDWFQ4mGTRFVKwtOa74UZ07GTv0DGaYpDb P2X8a4BWKRUkZPFjiNE97PEdDdt2rPMIh0nFPEuKd+1lAgMBAAGgLTArBgkqhkiG 9w0BCQ4xHjAcMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0B AQsFAAOCAQEAW/tTyJaBfWtbC9hYUmhh8lxUztv2+WT4xaR/jdQ46sk/87vKuwI6 4AkkGfiPLLqgW3xbQOwk5/ynRabttbsgTUHt744RtRFLzfcQKEBZoNPvrfHvmDil YqHIOx2SJ5hzIBwVlVSBn50hdSSED1Ip22DaU8GukzuacB8+2rhg3MOWJbKVt5aR 03H4XkAynLS1FHNOraDIv1eT58D3l4hanrNOZIa0xAuChd25qLO/JHvU/3wccGUA KNg3vGOy2Q8qVBrTFLn+yQHuOr/wSupXESO1jiI/h+txsBQnZ6oYfZnVJ+7o3Oln 3Hguw77aYeTAeZQPPbmJbDLegLG0ZC6RmA== -----END CERTIFICATE REQUEST----- ================================================ FILE: examples/ha/tls/certs/node1.pem ================================================ -----BEGIN CERTIFICATE----- MIIEAjCCAuqgAwIBAgIUbYMGwSgQF8iRZ5xmhflInj8VZ0owDQYJKoZIhvcNAQEL BQAwYjELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIwEAYDVQQHEwlN ZWxib3VybmUxDjAMBgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMxDjAMBgNVBAMT BW1hc3NsMCAXDTIxMDUwNTE2MTYwMFoYDzIxMjEwNDExMTYxNjAwWjBzMQswCQYD VQQGEwJBVTERMA8GA1UECBMIVmljdG9yaWExEjAQBgNVBAcTCU1lbGJvdXJuZTEV MBMGA1UEChMMc3lzdGVtOm5vZGUxMQ4wDAYDVQQLEwVtYXNzbDEWMBQGA1UEAxMN c3lzdGVtOnNlcnZlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANW/ W5uK7w7aWLKoLQgudvi+d+XeP0NjtdH1g9otgn0V132CUOXK+AFdoqknBbCUrOys MkHXv3OFVo/7xANv0iR2RQZZJynQnK4B1Xm67585uhrs3iArrWUiqHBV8vSsPmJl rgOuJx1X5kgihtOxoV4RLC7Egp+D0w2DohgFJtRnUg8VKfMlF3nNdxG9tJpXdMRP ZEJwpxFkZ8TF7UCV6M/ZaS3Ow4F7Tepwl0hvwqnQzznblJ5rHmscgBqQBZ1JsXKl LmACVENYVDiYZNEVUrC05rvhRnTsZO/QMZpikNs/ZfxrgFYpFSRk8WOI0T3s8R0N 23as8wiHScU8S4p37WUCAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0l BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYE FGprx5v+KrO4DeOtA6kps4BL/zKyMB8GA1UdIwQYMBaAFHeA0xJSquoJxmAyWYCb wvuH5a2QMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsF AAOCAQEAmWTdMLyWOrNAS0uY+u3FUV3Hm50xF1PfxbT6wK1hu6vH6B63E0o9K2/1 U25Ie8Y2IzFocKMvbqC+mrY56G0bWoUlMONhthYqm8uTKtjlFO33A9I7WIT9Tw+B nnwZZO7+Ljkd30qSzBinCjrIEx31Vq2pr54ungd8+wK8nfz/zdZnJcqxcN9zvCXB GTE8yCuqGWKk/oDuIzVjr73U0QaWi+vThqJtBjhOIWQHHVJwbIyhuYzUaivgZPYB 8eKXWk4JH3eAcq5z5koNGyCcZd/k4WnvxZYxNBAkoQ6AWVfEMGOCaRjD1FTnMbpG BW79ndJqLmn8OH+DeCnSWhTWxAgg+Q== -----END CERTIFICATE----- ================================================ FILE: examples/ha/tls/certs/node2-csr.json ================================================ { "CN": "system:server", "key": { "algo": "rsa", "size": 2048 }, "names": [ { "C": "AU", "L": "Melbourne", "O": "system:node2", "OU": "massl", "ST": "Victoria" } ] } ================================================ FILE: examples/ha/tls/certs/node2-key.pem ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAtCtzT9vhRMTbhAg/pm8eBn+4IvVQeVqnHoEon9IKIx5fyvqS Q6Ui3xSik9kJq5FSAa1mScajJwfB1o6ycaSP6n+Q88Py4v7q65n0stCHoJCH0uPw MQyEhwX7nNilV9C4UZTyZ2StDdAjmMBHiN81EJAqH2d4Xtgrd/IIWhljSXm+aPbu QjSz8BtR/7+MswrCdlJ8y6gWi020kt6GSHjmaxI1jStGvBxxksK86v3J97wfNwWY 7GJi70uBrvO0pk5bYckDzUTKeN1QGvBnZ8uDXs7pPysvftJr85GzX0iE9YLMDxO3 qc/PlwCdxM8H6gHTTkLPizGZtpMF9Z497pW9YQIDAQABAoIBAFfQwdCPxHmnVbNB 7fwqNsFGKTLozMOJeuE0ZN+ZGZXKbTha70WHTLrcrO1RIRR9rTHiGXQmHEmez0zL mpAnfHn4mWcm/9DCHTCehpVNbH3HVFxm+yB9EG9bbCsjsVtfASfKaGgauvp7k44V UgiVeqDLE6zg2tunk3BQCOAZdbpOiXrdvoZiGx2Q4SMLPfzmfIyH4BUT836pLTmp o6/yNiFqQWfCgjeEAOQor4TcdzYIT+3wP51HfAjhZKMIvmjwL16ov1/QpmWRD4ni 4svzYpeMYpl5OrZkKeDS4ZIQBGjxk+fzPmfFUbfVRSI2gDORsah8HoRVI4LnwKWn 7kQDv0ECgYEA6V+KVb8bPzCZNbroEZFdug6YtT4yv5Mj3/kpMTIvA3vtu02v8e7F O56yT43QfUZA0Ar37O0HQ6mbpPsRE5RSr70i40RR+slMZVHX/AQViG7oQJGBijPt 1tFdLnb+1wSON3jYt2975Kw2IfgOXprWtEmL5zGuplEUjx9Lbdf1HjkCgYEAxaNe XgXdAiWFoY4Qq6xBRO/WNZCdn3Ysqx6snCtDRilxeNyDoE/6x2Ma9/NRBtIiulAb s09vDRfJKLbzocUhIn8BQ+GkbAS/A6+x2vcuGhK3F84xqZdbrCqvqdJS8K824jug vUCfCBJlyNRDz8kEsN5odLM1xkij93Jv23HvGGkCgYEAptcz6ctfalSPI9eEs5KO REbNK73UwBssaaISreYnsED4G5EVuUuvW8k/xxomtHj2OwWsa4ilSd1GtbL8aVf/ qT35ZCrixP0GjeTuGXC+CDTp+8dKqggoAAzbpi1SUVwjZEsT/EhKdZgcdzqE42Ol HWz7BQUCzEpo/U0tOtFKnxkCgYEAi05Vy8wyNbsg7/jlAzyNXPv4bxUaJTX00kDy xbkw2BmKI/i6xprZVwUiEzdsG3SuicjBXahVzFLBtXMPUy1R57DBwYkgjgriYMTM hlzIIBSk/aCXHMTVFwuXegoH8CJwexIwgHU2I0hkeiQ0EBfOuKRr2CYhdzvoZxhA g9tQ/lECgYAjPYoXfNI3rHCWUmaD5eDJZpE0xuJeiiy5auojykdAc7vVapNaIyMK G3EaU44RtXcSwH19TlH9UCm3MH1QiIwaBOzGcKj3Ut6ZyFKuWDUk4yqvps3uZU/h h16Tp49Ja7/4LY1uuEngg1KMEiWgk5jiU7G0H9zrtEiTj9c3FDKDvg== -----END RSA PRIVATE KEY----- ================================================ FILE: examples/ha/tls/certs/node2.csr ================================================ -----BEGIN CERTIFICATE REQUEST----- MIIC5TCCAc0CAQAwczELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIw EAYDVQQHEwlNZWxib3VybmUxFTATBgNVBAoTDHN5c3RlbTpub2RlMjEOMAwGA1UE CxMFbWFzc2wxFjAUBgNVBAMTDXN5c3RlbTpzZXJ2ZXIwggEiMA0GCSqGSIb3DQEB AQUAA4IBDwAwggEKAoIBAQC0K3NP2+FExNuECD+mbx4Gf7gi9VB5WqcegSif0goj Hl/K+pJDpSLfFKKT2QmrkVIBrWZJxqMnB8HWjrJxpI/qf5Dzw/Li/urrmfSy0Ieg kIfS4/AxDISHBfuc2KVX0LhRlPJnZK0N0COYwEeI3zUQkCofZ3he2Ct38ghaGWNJ eb5o9u5CNLPwG1H/v4yzCsJ2UnzLqBaLTbSS3oZIeOZrEjWNK0a8HHGSwrzq/cn3 vB83BZjsYmLvS4Gu87SmTlthyQPNRMp43VAa8Gdny4Nezuk/Ky9+0mvzkbNfSIT1 gswPE7epz8+XAJ3EzwfqAdNOQs+LMZm2kwX1nj3ulb1hAgMBAAGgLTArBgkqhkiG 9w0BCQ4xHjAcMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0B AQsFAAOCAQEARh0Pi36mNmyprU4j25GWNqQYCJ6cBGnaPeiwr8/F3rsGsF4LTQdP xW2oBrEWyYRidNCkSMrPkcSiXu1Loy9APwSAXgJZWMYy0Ccdbd3P7dtGNOZkKaLA QKntGA5E1YAbzNhlt7NviGpqZ49K2aOgcGBTnDZ7xDzmg4uo3tcHgzOCwarYZT8l qVpc3jAyxRBOrxVKPZNFb4hAFvUm8k6/Etn5n4otN0JT3KGewbfQY50CxW5ShK52 QCs2PmFMYHHmG11FD3W755MxzhL6UmMy20GUgWWthGmR1LugcBgDtWO/7bqqC9tT XYDTDJ1j0g3Y0cvy2+kltrams4lGE3xs6g== -----END CERTIFICATE REQUEST----- ================================================ FILE: examples/ha/tls/certs/node2.pem ================================================ -----BEGIN CERTIFICATE----- MIIEAjCCAuqgAwIBAgIUex5xEYsDJPUg8idU0Sql2ixGdTwwDQYJKoZIhvcNAQEL BQAwYjELMAkGA1UEBhMCQVUxETAPBgNVBAgTCFZpY3RvcmlhMRIwEAYDVQQHEwlN ZWxib3VybmUxDjAMBgNVBAoTBW1hc3NsMQwwCgYDVQQLEwNWSUMxDjAMBgNVBAMT BW1hc3NsMCAXDTIxMDUwNTE2MTYwMFoYDzIxMjEwNDExMTYxNjAwWjBzMQswCQYD VQQGEwJBVTERMA8GA1UECBMIVmljdG9yaWExEjAQBgNVBAcTCU1lbGJvdXJuZTEV MBMGA1UEChMMc3lzdGVtOm5vZGUyMQ4wDAYDVQQLEwVtYXNzbDEWMBQGA1UEAxMN c3lzdGVtOnNlcnZlcjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALQr c0/b4UTE24QIP6ZvHgZ/uCL1UHlapx6BKJ/SCiMeX8r6kkOlIt8UopPZCauRUgGt ZknGoycHwdaOsnGkj+p/kPPD8uL+6uuZ9LLQh6CQh9Lj8DEMhIcF+5zYpVfQuFGU 8mdkrQ3QI5jAR4jfNRCQKh9neF7YK3fyCFoZY0l5vmj27kI0s/AbUf+/jLMKwnZS fMuoFotNtJLehkh45msSNY0rRrwccZLCvOr9yfe8HzcFmOxiYu9Lga7ztKZOW2HJ A81EynjdUBrwZ2fLg17O6T8rL37Sa/ORs19IhPWCzA8Tt6nPz5cAncTPB+oB005C z4sxmbaTBfWePe6VvWECAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0l BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYE FDNgivphLRqKzV8n29GJq6S2I+CQMB8GA1UdIwQYMBaAFHeA0xJSquoJxmAyWYCb wvuH5a2QMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsF AAOCAQEAnNG3nzycALGf+N8PuG4sUIkD+SYA1nOEgfD2KiGNyuTYHhGgFXTw8KzB olH05VidldBvC0+pl5EqZAp9qdzpw6Z5Mb0gdoZY6TeKDUo022G3BHLMUGLp8y+i KE6+awwgdJZ6vPbdnWAh7VM/HCUrGIIPmLFan13j/2RiMfaDxdMAowPmbVc8MLgA JHI6pPo8D1DacEvMM09qGtwQEUoREOWJ/SzTWl1nc/IAS1yOL1LCyKLcoj/HWqjG 3LXficQ7rf+Cpn1GnrKwMziT0OLDLxOs/+5d3nFSLxqF1lpykhPPkmHOHnuY8sMX Qdndn9QILdp5GNvqiVNQYcQa/gOb6g== -----END CERTIFICATE----- ================================================ FILE: examples/ha/tls/tls_config_node1.yml ================================================ tls_server_config: cert_file: "certs/node1.pem" key_file: "certs/node1-key.pem" client_ca_file: "certs/ca.pem" client_auth_type: "VerifyClientCertIfGiven" tls_client_config: cert_file: "certs/node1.pem" key_file: "certs/node1-key.pem" ca_file: "certs/ca.pem" ================================================ FILE: examples/ha/tls/tls_config_node2.yml ================================================ tls_server_config: cert_file: "certs/node2.pem" key_file: "certs/node2-key.pem" client_ca_file: "certs/ca.pem" client_auth_type: "VerifyClientCertIfGiven" tls_client_config: cert_file: "certs/node2.pem" key_file: "certs/node2-key.pem" ca_file: "certs/ca.pem" ================================================ FILE: examples/webhook/echo.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package main import ( "bytes" "encoding/json" "io" "log" "net/http" ) func main() { log.Fatal(http.ListenAndServe(":5001", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, err := io.ReadAll(r.Body) if err != nil { panic(err) } defer r.Body.Close() var buf bytes.Buffer if err := json.Indent(&buf, b, " >", " "); err != nil { panic(err) } log.Println(buf.String()) }))) } ================================================ FILE: examples/webhook/teams.tmpl ================================================ {{/* Copyright The Prometheus Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */}} {{/* This an example how to render custom templates for Microsoft Teams using the Alertmanager webhook receiver. Receiver is configured as: ```yaml - name: local_test webhook_configs: - url: 'http://localhost:8080' send_resolved: true payload: type: "message" attachments: - contentType: "application/vnd.microsoft.card.adaptive" contentUrl: null content: "$schema": "http://adaptivecards.io/schemas/adaptive-card.json" type: "AdaptiveCard" body: - type: "ColumnSet" columns: - type: "Column" items: - type: "Icon" name: '{{ template "wh_teams_icon" . }}' size: "xSmall" width: "25px" - type: "Column" items: - type: "TextBlock" text: '{{ .CommonLabels.alertname }}' weight: "bolder" color: '{{ template "wh_teams_color" . }}' width: "stretch" - type: "ColumnSet" columns: '{{ template "wh_teams_alertlist" . }}' - type: ActionSet actions: '{{ template "wh_teams_actions" . }}' ``` */}} {{- define "wh_teams_icon" }} {{- if eq .Status "firing" -}}AlertOn{{- else -}}Checkmark{{- end -}} {{- end -}} {{- define "wh_teams_color" }} {{- if eq .Status "firing" -}}Attention{{- else -}}Good{{- end -}} {{- end }} {{- define "wh_teams_alertlist_icon_column" }} {{- $length := len ( .Alerts ) }} [ {{- range $index, $element := .Alerts }} {{- if $index -}}, {{ end -}} {{- if gt 5 $index }} { "type": "Icon", "size": "xSmall", "name": "{{ template "wh_teams_icon" $element }}" } {{ else if eq 5 $index }} { "type": "Icon", "size": "xSmall", "name": "CommentNote" } {{- end }} {{- end }} ] {{- end }} {{- define "wh_teams_alertlist_instance_column" -}} {{- $length := len ( .Alerts ) -}} [ {{- range $index, $element := .Alerts -}} {{- if $index -}}, {{ end -}} {{- if gt 5 $index -}} { "type": "TextBlock", "size": "Medium", "text": "{{ .Labels.instance }}" } {{- else if eq 5 $index -}} { "type": "TextBlock", "size": "Medium", "text": "Only 5 out of {{ $length }} displayed!", } {{- end -}} {{- end }} ] {{- end }} {{ define "wh_teams_alertlist" }} [ { "type": "Column", "width": "25px", "items": {{ template "wh_teams_alertlist_icon_column" . }} }, { "type": "Column", "width": "stretch", "items": {{ template "wh_teams_alertlist_instance_column" . }} } ] {{- end }} {{ define "wh_teams_actions" }} [ {{- if ((index .Alerts 0).Annotations).dashboard_url }} { "type": "Action.OpenUrl", "title": "Dashboard", "url": "{{ ((index .Alerts 0).Annotations).dashboard_url }}", "iconUrl": "icon:ArrowTrendingLines" } {{- end }} {{- if ((index .Alerts 0).Annotations).runbook_url }} ,{ "type": "Action.OpenUrl", "title": "Runbook", "url": "{{ ((index .Alerts 0).Annotations).runbook_url }}", "iconUrl": "icon:PersonRunning" } {{- end }} ] {{ end }} ================================================ FILE: featurecontrol/featurecontrol.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package featurecontrol import ( "errors" "fmt" "log/slog" "strings" ) const ( FeatureAlertNamesInMetrics = "alert-names-in-metrics" FeatureReceiverNameInMetrics = "receiver-name-in-metrics" FeatureClassicMode = "classic-mode" FeatureUTF8StrictMode = "utf8-strict-mode" FeatureAutoGOMEMLIMIT = "auto-gomemlimit" FeatureAutoGOMAXPROCS = "auto-gomaxprocs" ) var AllowedFlags = []string{ FeatureAlertNamesInMetrics, FeatureReceiverNameInMetrics, FeatureClassicMode, FeatureUTF8StrictMode, FeatureAutoGOMEMLIMIT, FeatureAutoGOMAXPROCS, } type Flagger interface { EnableAlertNamesInMetrics() bool EnableReceiverNamesInMetrics() bool ClassicMode() bool UTF8StrictMode() bool EnableAutoGOMEMLIMIT() bool EnableAutoGOMAXPROCS() bool } type Flags struct { logger *slog.Logger enableAlertNamesInMetrics bool enableReceiverNamesInMetrics bool classicMode bool utf8StrictMode bool enableAutoGOMEMLIMIT bool enableAutoGOMAXPROCS bool } func (f *Flags) EnableAlertNamesInMetrics() bool { return f.enableAlertNamesInMetrics } func (f *Flags) EnableReceiverNamesInMetrics() bool { return f.enableReceiverNamesInMetrics } func (f *Flags) ClassicMode() bool { return f.classicMode } func (f *Flags) UTF8StrictMode() bool { return f.utf8StrictMode } func (f *Flags) EnableAutoGOMEMLIMIT() bool { return f.enableAutoGOMEMLIMIT } func (f *Flags) EnableAutoGOMAXPROCS() bool { return f.enableAutoGOMAXPROCS } type flagOption func(flags *Flags) func enableReceiverNameInMetrics() flagOption { return func(configs *Flags) { configs.enableReceiverNamesInMetrics = true } } func enableClassicMode() flagOption { return func(configs *Flags) { configs.classicMode = true } } func enableUTF8StrictMode() flagOption { return func(configs *Flags) { configs.utf8StrictMode = true } } func enableAutoGOMEMLIMIT() flagOption { return func(configs *Flags) { configs.enableAutoGOMEMLIMIT = true } } func enableAutoGOMAXPROCS() flagOption { return func(configs *Flags) { configs.enableAutoGOMAXPROCS = true } } func enableAlertNamesInMetrics() flagOption { return func(configs *Flags) { configs.enableAlertNamesInMetrics = true } } func NewFlags(logger *slog.Logger, features string) (Flagger, error) { fc := &Flags{logger: logger} opts := []flagOption{} if len(features) == 0 { return NoopFlags{}, nil } for feature := range strings.SplitSeq(features, ",") { switch feature { case FeatureAlertNamesInMetrics: opts = append(opts, enableAlertNamesInMetrics()) logger.Warn("Alert names in metrics enabled") case FeatureReceiverNameInMetrics: opts = append(opts, enableReceiverNameInMetrics()) logger.Warn("Experimental receiver name in metrics enabled") case FeatureClassicMode: opts = append(opts, enableClassicMode()) logger.Warn("Classic mode enabled") case FeatureUTF8StrictMode: opts = append(opts, enableUTF8StrictMode()) logger.Warn("UTF-8 strict mode enabled") case FeatureAutoGOMEMLIMIT: opts = append(opts, enableAutoGOMEMLIMIT()) logger.Warn("Automatically set GOMEMLIMIT to match the Linux container or system memory limit.") case FeatureAutoGOMAXPROCS: opts = append(opts, enableAutoGOMAXPROCS()) logger.Error("Deprecated: auto-gomaxprocs will be removed in v0.33. Removing this flag does not affect behavior, as Go 1.25+ natively handles container CPU quotas.") default: return nil, fmt.Errorf("unknown option '%s' for --enable-feature", feature) } } for _, opt := range opts { opt(fc) } if fc.classicMode && fc.utf8StrictMode { return nil, errors.New("cannot have both classic and UTF-8 modes enabled") } return fc, nil } type NoopFlags struct{} func (n NoopFlags) EnableAlertNamesInMetrics() bool { return false } func (n NoopFlags) EnableReceiverNamesInMetrics() bool { return false } func (n NoopFlags) ClassicMode() bool { return false } func (n NoopFlags) UTF8StrictMode() bool { return false } func (n NoopFlags) EnableAutoGOMEMLIMIT() bool { return false } func (n NoopFlags) EnableAutoGOMAXPROCS() bool { return false } ================================================ FILE: featurecontrol/featurecontrol_test.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package featurecontrol import ( "errors" "strings" "testing" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" ) func TestFlags(t *testing.T) { tc := []struct { name string featureFlags string err error }{ { name: "with only valid feature flags", featureFlags: FeatureReceiverNameInMetrics, }, { name: "with only invalid feature flags", featureFlags: "somethingsomething", err: errors.New("unknown option 'somethingsomething' for --enable-feature"), }, { name: "with both, valid and invalid feature flags", featureFlags: strings.Join([]string{FeatureReceiverNameInMetrics, "somethingbad"}, ","), err: errors.New("unknown option 'somethingbad' for --enable-feature"), }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { fc, err := NewFlags(promslog.NewNopLogger(), tt.featureFlags) if tt.err != nil { require.EqualError(t, err, tt.err.Error()) } else { require.NoError(t, err) require.NotNil(t, fc) } }) } } ================================================ FILE: go.mod ================================================ module github.com/prometheus/alertmanager go 1.25.0 require ( github.com/KimMachineGun/automemlimit v0.7.5 github.com/alecthomas/kingpin/v2 v2.4.0 github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b github.com/aws/aws-sdk-go-v2 v1.41.3 github.com/aws/aws-sdk-go-v2/config v1.32.11 github.com/aws/aws-sdk-go-v2/credentials v1.19.11 github.com/aws/aws-sdk-go-v2/service/sns v1.39.13 github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 github.com/aws/smithy-go v1.24.2 github.com/cenkalti/backoff/v4 v4.3.0 github.com/cespare/xxhash/v2 v2.3.0 github.com/coder/quartz v0.3.0 github.com/emersion/go-smtp v0.24.0 github.com/go-openapi/analysis v0.24.3 github.com/go-openapi/errors v0.22.7 github.com/go-openapi/loads v0.23.3 github.com/go-openapi/runtime v0.29.3 github.com/go-openapi/spec v0.22.4 github.com/go-openapi/strfmt v0.26.0 github.com/go-openapi/swag v0.25.5 github.com/go-openapi/validate v0.25.2 github.com/google/uuid v1.6.0 github.com/hashicorp/go-sockaddr v1.0.7 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hashicorp/memberlist v0.5.4 github.com/jessevdk/go-flags v1.6.1 github.com/oklog/run v1.2.0 github.com/oklog/ulid/v2 v2.1.1 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/common v0.67.5 github.com/prometheus/exporter-toolkit v0.15.1 github.com/prometheus/sigv4 v0.4.1 github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.11.1 github.com/xlab/treeprint v1.2.0 go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.66.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 go.opentelemetry.io/otel/sdk v1.41.0 go.opentelemetry.io/otel/trace v1.41.0 golang.org/x/mod v0.33.0 golang.org/x/net v0.51.0 golang.org/x/text v0.34.0 google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 gopkg.in/telebot.v3 v3.3.8 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/coreos/go-systemd/v22 v22.6.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.22.5 // indirect github.com/go-openapi/jsonreference v0.21.5 // indirect github.com/go-openapi/swag/cmdutils v0.25.5 // indirect github.com/go-openapi/swag/conv v0.25.5 // indirect github.com/go-openapi/swag/fileutils v0.25.5 // indirect github.com/go-openapi/swag/jsonname v0.25.5 // indirect github.com/go-openapi/swag/jsonutils v0.25.5 // indirect github.com/go-openapi/swag/loading v0.25.5 // indirect github.com/go-openapi/swag/mangling v0.25.5 // indirect github.com/go-openapi/swag/netutils v0.25.5 // indirect github.com/go-openapi/swag/stringutils v0.25.5 // indirect github.com/go-openapi/swag/typeutils v0.25.5 // indirect github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-metrics v0.5.4 // indirect github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/mdlayher/vsock v1.2.1 // indirect github.com/miekg/dns v1.1.68 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow= cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM= cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA= github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs= github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo= github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc= github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA= github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row= github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU= github.com/aws/aws-sdk-go-v2/service/sns v1.39.13 h1:8xP94tDzFpgwIOsusGiEFHPaqrpckDojoErk/ZFZTio= github.com/aws/aws-sdk-go-v2/service/sns v1.39.13/go.mod h1:RwF6Xnba8PlINxJUQq1IAWeon6IglvqsnhNqV8QsQjk= github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias= github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8= github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo= github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coder/quartz v0.3.0 h1:bUoSEJ77NBfKtUqv6CPSC0AS8dsjqAqqAv7bN02m1mg= github.com/coder/quartz v0.3.0/go.mod h1:BgE7DOj/8NfvRgvKw0jPLDQH/2Lya2kxcTaNJ8X0rZk= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk= github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/analysis v0.24.3 h1:a1hrvMr8X0Xt69KP5uVTu5jH62DscmDifrLzNglAayk= github.com/go-openapi/analysis v0.24.3/go.mod h1:Nc+dWJ/FxZbhSow5Yh3ozg5CLJioB+XXT6MdLvJUsUw= github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y= github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI= github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= github.com/go-openapi/strfmt v0.26.0 h1:SDdQLyOEqu8W96rO1FRG1fuCtVyzmukky0zcD6gMGLU= github.com/go-openapi/strfmt v0.26.0/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw= github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.9.5/go.mod h1:U/jl18uSupI5rdI2jmuCswEA2htH9eXfferR3KfscvA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY= github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcXRl656T44= github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/memberlist v0.5.4 h1:40YY+3qq2tAUhZIMEK8kqusKZBBjdwJ3NUjvYkcxh74= github.com/hashicorp/memberlist v0.5.4/go.mod h1:OgN6xiIo6RlHUWk+ALjP9e32xWCoQrsOCmHrWCm2MWA= github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= github.com/hashicorp/serf v0.9.7/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/exporter-toolkit v0.15.1 h1:XrGGr/qWl8Gd+pqJqTkNLww9eG8vR/CoRk0FubOKfLE= github.com/prometheus/exporter-toolkit v0.15.1/go.mod h1:P/NR9qFRGbCFgpklyhix9F6v6fFr/VQB/CVsrMDGKo4= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/sigv4 v0.4.1 h1:EIc3j+8NBea9u1iV6O5ZAN8uvPq2xOIUPcqCTivHuXs= github.com/prometheus/sigv4 v0.4.1/go.mod h1:eu+ZbRvsc5TPiHwqh77OWuCnWK73IdkETYY46P4dXOU= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.13.0/go.mod h1:Icm2xNL3/8uyh/wFuB1jI7TiTNKp8632Nwegu+zgdYw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU= go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.66.0 h1:U++6AfUpXXSILim4iH6Jb2oeK/mp7J4lNzzyO8Cx4Zw= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.66.0/go.mod h1:HVNUDNMGMeykut/2GZ++AZjglCqew/+Hf4lxRVqFFxQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8= go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo= google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g= google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA= google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8= google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs= google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= google.golang.org/api v0.81.0/go.mod h1:FA6Mb/bZxj706H2j+j2d6mHEEaHBmbbWnkfvmorOCko= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/telebot.v3 v3.3.8 h1:uVDGjak9l824FN9YARWUHMsiNZnlohAVwUycw21k6t8= gopkg.in/telebot.v3 v3.3.8/go.mod h1:1mlbqcLTVSfK9dx7fdp+Nb5HZsy4LLPtpZTKmwhwtzM= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= ================================================ FILE: inhibit/index.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package inhibit import ( "sync" "github.com/prometheus/common/model" ) // index contains map of fingerprints to fingerprints. // The keys are fingerprints of the equal labels of source alerts. // The values are fingerprints of the source alerts. // For more info see comments on inhibitor and InhibitRule. type index struct { mtx sync.RWMutex items map[model.Fingerprint]model.Fingerprint } func newIndex() *index { return &index{ items: make(map[model.Fingerprint]model.Fingerprint), } } func (c *index) Get(key model.Fingerprint) (model.Fingerprint, bool) { c.mtx.RLock() defer c.mtx.RUnlock() fp, ok := c.items[key] return fp, ok } func (c *index) Set(key, value model.Fingerprint) { c.mtx.Lock() defer c.mtx.Unlock() c.items[key] = value } func (c *index) Delete(key model.Fingerprint) { c.mtx.Lock() defer c.mtx.Unlock() delete(c.items, key) } func (c *index) Len() int { c.mtx.RLock() defer c.mtx.RUnlock() return len(c.items) } ================================================ FILE: inhibit/inhibit.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package inhibit import ( "context" "log/slog" "sync" "time" "github.com/oklog/run" "github.com/prometheus/common/model" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/alertmanager/provider" "github.com/prometheus/alertmanager/store" "github.com/prometheus/alertmanager/types" ) var tracer = otel.Tracer("github.com/prometheus/alertmanager/inhibit") // An Inhibitor determines whether a given label set is muted based on the // currently active alerts and a set of inhibition rules. It implements the // Muter interface. type Inhibitor struct { alerts provider.Alerts rules []*InhibitRule marker types.AlertMarker logger *slog.Logger propagator propagation.TextMapPropagator mtx sync.RWMutex loadingFinished sync.WaitGroup cancel func() } // NewInhibitor returns a new Inhibitor. func NewInhibitor(ap provider.Alerts, rs []amcommoncfg.InhibitRule, mk types.AlertMarker, logger *slog.Logger) *Inhibitor { ih := &Inhibitor{ alerts: ap, marker: mk, logger: logger, propagator: otel.GetTextMapPropagator(), } ih.loadingFinished.Add(1) ruleNames := make(map[string]struct{}) for i, cr := range rs { if _, ok := ruleNames[cr.Name]; ok { ih.logger.Debug("duplicate inhibition rule name", "index", i, "name", cr.Name) } r := NewInhibitRule(cr) ih.rules = append(ih.rules, r) if cr.Name != "" { ruleNames[cr.Name] = struct{}{} } } return ih } func (ih *Inhibitor) run(ctx context.Context) { initalAlerts, it := ih.alerts.SlurpAndSubscribe("inhibitor") defer it.Close() for _, a := range initalAlerts { ih.processAlert(ctx, a) } ih.loadingFinished.Done() for { select { case <-ctx.Done(): return case a := <-it.Next(): if err := it.Err(); err != nil { ih.logger.Error("Error iterating alerts", "err", err) continue } traceCtx := context.Background() if a.Header != nil { traceCtx = ih.propagator.Extract(traceCtx, propagation.MapCarrier(a.Header)) } ih.processAlert(traceCtx, a.Data) } } } func (ih *Inhibitor) processAlert(ctx context.Context, a *types.Alert) { _, span := tracer.Start(ctx, "inhibit.Inhibitor.processAlert", trace.WithAttributes( attribute.String("alerting.alert.name", a.Name()), attribute.String("alerting.alert.fingerprint", a.Fingerprint().String()), ), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() // Update the inhibition rules' cache. for _, r := range ih.rules { if r.SourceMatchers.Matches(a.Labels) { attr := attribute.String("alerting.inhibit_rule.name", r.Name) span.AddEvent("alert matched rule source", trace.WithAttributes(attr)) if err := r.scache.Set(a); err != nil { message := "error on set alert" ih.logger.Error(message, "err", err) span.SetStatus(codes.Error, message) span.RecordError(err) continue } span.SetAttributes(attr) r.updateIndex(a) } } } func (ih *Inhibitor) WaitForLoading() { ih.loadingFinished.Wait() } // Run the Inhibitor's background processing. func (ih *Inhibitor) Run() { var ( g run.Group ctx context.Context ) ih.mtx.Lock() ctx, ih.cancel = context.WithCancel(context.Background()) ih.mtx.Unlock() runCtx, runCancel := context.WithCancel(ctx) for _, rule := range ih.rules { go rule.scache.Run(runCtx, 15*time.Minute) } g.Add(func() error { ih.run(runCtx) return nil }, func(err error) { runCancel() }) if err := g.Run(); err != nil { ih.logger.Warn("error running inhibitor", "err", err) } } // Stop the Inhibitor's background processing. func (ih *Inhibitor) Stop() { if ih == nil { return } ih.mtx.RLock() defer ih.mtx.RUnlock() if ih.cancel != nil { ih.cancel() } } // Mutes returns true iff the given label set is muted. It implements the Muter // interface. func (ih *Inhibitor) Mutes(ctx context.Context, lset model.LabelSet) bool { fp := lset.Fingerprint() _, span := tracer.Start(ctx, "inhibit.Inhibitor.Mutes", trace.WithAttributes(attribute.String("alerting.alert.fingerprint", fp.String())), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() now := time.Now() for _, r := range ih.rules { if !r.TargetMatchers.Matches(lset) { // If target side of rule doesn't match, we don't need to look any further. continue } span.AddEvent("alert matched rule target", trace.WithAttributes( attribute.String("alerting.inhibit_rule.name", r.Name), ), ) // If we are here, the target side matches. If the source side matches, too, we // need to exclude inhibiting alerts for which the same is true. if inhibitedByFP, eq := r.hasEqual(lset, r.SourceMatchers.Matches(lset), now); eq { ih.marker.SetInhibited(fp, inhibitedByFP.String()) span.AddEvent("alert inhibited", trace.WithAttributes( attribute.String("alerting.inhibit_rule.source.fingerprint", inhibitedByFP.String()), ), ) return true } } ih.marker.SetInhibited(fp) span.AddEvent("alert not inhibited") return false } // An InhibitRule specifies that a class of (source) alerts should inhibit // notifications for another class of (target) alerts if all specified matching // labels are equal between the two alerts. This may be used to inhibit alerts // from sending notifications if their meaning is logically a subset of a // higher-level alert. type InhibitRule struct { // Name is an optional name for the inhibition rule. Name string // The set of Filters which define the group of source alerts (which inhibit // the target alerts). SourceMatchers labels.Matchers // The set of Filters which define the group of target alerts (which are // inhibited by the source alerts). TargetMatchers labels.Matchers // A set of label names whose label values need to be identical in source and // target alerts in order for the inhibition to take effect. Equal map[model.LabelName]struct{} // Cache of alerts matching source labels. scache *store.Alerts // Index of fingerprints of source alert equal labels to fingerprint of source alert. // The index helps speed up source alert lookups from scache significantely in scenarios with 100s of source alerts cached. // The index items might overwrite eachother if multiple source alerts have exact equal labels. // Overwrites only happen if the new source alert has bigger EndsAt value. sindex *index } // NewInhibitRule returns a new InhibitRule based on a configuration definition. func NewInhibitRule(cr amcommoncfg.InhibitRule) *InhibitRule { var ( sourcem labels.Matchers targetm labels.Matchers ) // cr.SourceMatch will be deprecated. This for loop appends regex matchers. for ln, lv := range cr.SourceMatch { matcher, err := labels.NewMatcher(labels.MatchEqual, ln, lv) if err != nil { // This error must not happen because the config already validates the yaml. panic(err) } sourcem = append(sourcem, matcher) } // cr.SourceMatchRE will be deprecated. This for loop appends regex matchers. for ln, lv := range cr.SourceMatchRE { matcher, err := labels.NewMatcher(labels.MatchRegexp, ln, lv.String()) if err != nil { // This error must not happen because the config already validates the yaml. panic(err) } sourcem = append(sourcem, matcher) } // We append the new-style matchers. This can be simplified once the deprecated matcher syntax is removed. sourcem = append(sourcem, cr.SourceMatchers...) // cr.TargetMatch will be deprecated. This for loop appends regex matchers. for ln, lv := range cr.TargetMatch { matcher, err := labels.NewMatcher(labels.MatchEqual, ln, lv) if err != nil { // This error must not happen because the config already validates the yaml. panic(err) } targetm = append(targetm, matcher) } // cr.TargetMatchRE will be deprecated. This for loop appends regex matchers. for ln, lv := range cr.TargetMatchRE { matcher, err := labels.NewMatcher(labels.MatchRegexp, ln, lv.String()) if err != nil { // This error must not happen because the config already validates the yaml. panic(err) } targetm = append(targetm, matcher) } // We append the new-style matchers. This can be simplified once the deprecated matcher syntax is removed. targetm = append(targetm, cr.TargetMatchers...) equal := map[model.LabelName]struct{}{} for _, ln := range cr.Equal { equal[model.LabelName(ln)] = struct{}{} } rule := &InhibitRule{ Name: cr.Name, SourceMatchers: sourcem, TargetMatchers: targetm, Equal: equal, scache: store.NewAlerts(), sindex: newIndex(), } rule.scache.SetGCCallback(rule.gcCallback) return rule } // fingerprintEquals returns the fingerprint of the equal labels of the given label set. func (r *InhibitRule) fingerprintEquals(lset model.LabelSet) model.Fingerprint { equalSet := make(model.LabelSet, len(r.Equal)) for n := range r.Equal { equalSet[n] = lset[n] } return equalSet.Fingerprint() } // updateIndex updates the source alert index if necessary. func (r *InhibitRule) updateIndex(alert *types.Alert) { fp := alert.Fingerprint() // Calculate source labelset subset which is in equals. eq := r.fingerprintEquals(alert.Labels) // Check if the equal labelset is already in the index. indexed, ok := r.sindex.Get(eq) if !ok { // If not, add it. r.sindex.Set(eq, fp) return } // If the indexed fingerprint is the same as the new fingerprint, do nothing. if indexed == fp { return } // New alert and existing index are not the same, compare them. existing, err := r.scache.Get(indexed) if err != nil { // failed to get the existing alert, overwrite the index. r.sindex.Set(eq, fp) return } // If the new alert resolves after the existing alert, replace the index. if existing.ResolvedAt(alert.EndsAt) { r.sindex.Set(eq, fp) return } // If the existing alert resolves after the new alert, do nothing. } // findEqualSourceAlert returns the source alert that matches the equal labels of the given label set. func (r *InhibitRule) findEqualSourceAlert(lset model.LabelSet, now time.Time) (*types.Alert, bool) { equalsFP := r.fingerprintEquals(lset) sourceFP, ok := r.sindex.Get(equalsFP) if ok { alert, err := r.scache.Get(sourceFP) if err != nil { return nil, false } if alert.ResolvedAt(now) { return nil, false } return alert, true } return nil, false } func (r *InhibitRule) gcCallback(alerts []*types.Alert) { for _, a := range alerts { fp := r.fingerprintEquals(a.Labels) r.sindex.Delete(fp) } } // hasEqual checks whether the source cache contains alerts matching the equal // labels for the given label set. If so, the fingerprint of one of those alerts // is returned. If excludeTwoSidedMatch is true, alerts that match both the // source and the target side of the rule are disregarded. func (r *InhibitRule) hasEqual(lset model.LabelSet, excludeTwoSidedMatch bool, now time.Time) (model.Fingerprint, bool) { equal, found := r.findEqualSourceAlert(lset, now) if found { if excludeTwoSidedMatch && r.TargetMatchers.Matches(equal.Labels) { return model.Fingerprint(0), false } return equal.Fingerprint(), found } return model.Fingerprint(0), false } ================================================ FILE: inhibit/inhibit_bench_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package inhibit import ( "context" "errors" "strconv" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/alertmanager/provider/mem" "github.com/prometheus/alertmanager/types" ) // BenchmarkMutes benchmarks the Mutes method for the Muter interface // for different numbers of inhibition rules. func BenchmarkMutes(b *testing.B) { b.Run("1 inhibition rule, 1 inhibiting alert", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 1, 1)) }) b.Run("10 inhibition rules, 1 inhibiting alert", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 10, 1)) }) b.Run("100 inhibition rules, 1 inhibiting alert", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 100, 1)) }) b.Run("1000 inhibition rules, 1 inhibiting alert", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 1000, 1)) }) b.Run("10000 inhibition rules, 1 inhibiting alert", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 10000, 1)) }) b.Run("1 inhibition rule, 10 inhibiting alerts", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 1, 10)) }) b.Run("1 inhibition rule, 100 inhibiting alerts", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 1, 100)) }) b.Run("1 inhibition rule, 1000 inhibiting alerts", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 1, 1000)) }) b.Run("1 inhibition rule, 10000 inhibiting alerts", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 1, 10000)) }) b.Run("100 inhibition rules, 1000 inhibiting alerts", func(b *testing.B) { benchmarkMutes(b, allRulesMatchBenchmark(b, 100, 1000)) }) b.Run("10 inhibition rules, last rule matches", func(b *testing.B) { benchmarkMutes(b, lastRuleMatchesBenchmark(b, 10)) }) b.Run("100 inhibition rules, last rule matches", func(b *testing.B) { benchmarkMutes(b, lastRuleMatchesBenchmark(b, 100)) }) b.Run("1000 inhibition rules, last rule matches", func(b *testing.B) { benchmarkMutes(b, lastRuleMatchesBenchmark(b, 1000)) }) b.Run("10000 inhibition rules, last rule matches", func(b *testing.B) { benchmarkMutes(b, lastRuleMatchesBenchmark(b, 10000)) }) } // benchmarkOptions allows the declaration of a wide range of benchmarks. type benchmarkOptions struct { // n is the total number of inhibition rules. n int // newRuleFunc creates the next inhibition rule. It is called n times. newRuleFunc func(idx int) amcommoncfg.InhibitRule // newAlertsFunc creates the inhibiting alerts for each inhibition rule. // It is called n times. newAlertsFunc func(idx int, r amcommoncfg.InhibitRule) []types.Alert // benchFunc runs the benchmark. benchFunc func(mutesFunc func(context.Context, model.LabelSet) bool) error } // allRulesMatchBenchmark returns a new benchmark where all inhibition rules // inhibit the label dst=0. It supports a number of variations, including // customization of the number of inhibition rules, and the number of // inhibiting alerts per inhibition rule. // // The source matchers are suffixed with the position of the inhibition rule // in the list (e.g. src=1, src=2, etc...). The target matchers are the same // across all inhibition rules (dst=0). // // Each inhibition rule can have zero or more alerts that match the source // matchers, and is determined with numInhibitingAlerts. // // It expects dst=0 to be muted and will fail if not. func allRulesMatchBenchmark(b *testing.B, numInhibitionRules, numInhibitingAlerts int) benchmarkOptions { return benchmarkOptions{ n: numInhibitionRules, newRuleFunc: func(idx int) amcommoncfg.InhibitRule { return amcommoncfg.InhibitRule{ SourceMatchers: amcommoncfg.Matchers{ mustNewMatcher(b, labels.MatchEqual, "src", strconv.Itoa(idx)), }, TargetMatchers: amcommoncfg.Matchers{ mustNewMatcher(b, labels.MatchEqual, "dst", "0"), }, } }, newAlertsFunc: func(idx int, _ amcommoncfg.InhibitRule) []types.Alert { var alerts []types.Alert for i := range numInhibitingAlerts { alerts = append(alerts, types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "src": model.LabelValue(strconv.Itoa(idx)), "idx": model.LabelValue(strconv.Itoa(i)), }, }, }) } return alerts }, benchFunc: func(mutesFunc func(context.Context, model.LabelSet) bool) error { if ok := mutesFunc(context.Background(), model.LabelSet{"dst": "0"}); !ok { return errors.New("expected dst=0 to be muted") } return nil }, } } // lastRuleMatchesBenchmark returns a new benchmark where the last inhibition // rule inhibits the label dst=0. All other inhibition rules are no-ops. // // The source matchers are suffixed with the position of the inhibition rule // in the list (e.g. src=1, src=2, etc...). The target matchers are the same // across all inhibition rules (dst=0). // // It expects dst=0 to be muted and will fail if not. func lastRuleMatchesBenchmark(b *testing.B, n int) benchmarkOptions { return benchmarkOptions{ n: n, newRuleFunc: func(idx int) amcommoncfg.InhibitRule { return amcommoncfg.InhibitRule{ SourceMatchers: amcommoncfg.Matchers{ mustNewMatcher(b, labels.MatchEqual, "src", strconv.Itoa(idx)), }, TargetMatchers: amcommoncfg.Matchers{ mustNewMatcher(b, labels.MatchEqual, "dst", "0"), }, } }, newAlertsFunc: func(idx int, _ amcommoncfg.InhibitRule) []types.Alert { // Do not create an alert unless it is the last inhibition rule. if idx < n-1 { return nil } return []types.Alert{{ Alert: model.Alert{ Labels: model.LabelSet{ "src": model.LabelValue(strconv.Itoa(idx)), }, }, }} }, benchFunc: func(mutesFunc func(context.Context, model.LabelSet) bool) error { if ok := mutesFunc(context.Background(), model.LabelSet{"dst": "0"}); !ok { return errors.New("expected dst=0 to be muted") } return nil }, } } func benchmarkMutes(b *testing.B, opts benchmarkOptions) { r := prometheus.NewRegistry() m := types.NewMarker(r) s, err := mem.NewAlerts(context.TODO(), m, time.Minute, 0, nil, promslog.NewNopLogger(), r, nil) if err != nil { b.Fatal(err) } defer s.Close() alerts, rules := benchmarkFromOptions(opts) for _, a := range alerts { tmp := a if err = s.Put(context.Background(), &tmp); err != nil { b.Fatal(err) } } ih := NewInhibitor(s, rules, m, promslog.NewNopLogger()) defer ih.Stop() go ih.Run() // Wait some time for the inhibitor to seed its cache. <-time.After(time.Second) for b.Loop() { require.NoError(b, opts.benchFunc(ih.Mutes)) } } func benchmarkFromOptions(opts benchmarkOptions) ([]types.Alert, []amcommoncfg.InhibitRule) { var ( alerts = make([]types.Alert, 0, opts.n) rules = make([]amcommoncfg.InhibitRule, 0, opts.n) ) for i := 0; i < opts.n; i++ { r := opts.newRuleFunc(i) alerts = append(alerts, opts.newAlertsFunc(i, r)...) rules = append(rules, r) } return alerts, rules } func mustNewMatcher(b *testing.B, op labels.MatchType, name, value string) *labels.Matcher { m, err := labels.NewMatcher(op, name, value) require.NoError(b, err) return m } ================================================ FILE: inhibit/inhibit_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package inhibit import ( "context" "fmt" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/alertmanager/provider" "github.com/prometheus/alertmanager/store" "github.com/prometheus/alertmanager/types" ) var nopLogger = promslog.NewNopLogger() func TestInhibitRuleHasEqual(t *testing.T) { t.Parallel() now := time.Now() cases := []struct { name string initial map[model.Fingerprint]*types.Alert equal model.LabelNames input model.LabelSet result bool }{ { name: "no source alerts", initial: map[model.Fingerprint]*types.Alert{}, input: model.LabelSet{"a": "b"}, result: false, }, { name: "no equal labels, any source alerts satisfies the requirement", initial: map[model.Fingerprint]*types.Alert{1: {}}, input: model.LabelSet{"a": "b"}, result: true, }, { name: "matching but already resolved", initial: map[model.Fingerprint]*types.Alert{ 1: { Alert: model.Alert{ Labels: model.LabelSet{"a": "b", "b": "f"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(-time.Second), }, }, 2: { Alert: model.Alert{ Labels: model.LabelSet{"a": "b", "b": "c"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(-time.Second), }, }, }, equal: model.LabelNames{"a", "b"}, input: model.LabelSet{"a": "b", "b": "c"}, result: false, }, { name: "matching and unresolved", initial: map[model.Fingerprint]*types.Alert{ 1: { Alert: model.Alert{ Labels: model.LabelSet{"a": "b", "c": "d"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(-time.Second), }, }, 2: { Alert: model.Alert{ Labels: model.LabelSet{"a": "b", "c": "f"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour), }, }, }, equal: model.LabelNames{"a"}, input: model.LabelSet{"a": "b"}, result: true, }, { name: "equal label does not match", initial: map[model.Fingerprint]*types.Alert{ 1: { Alert: model.Alert{ Labels: model.LabelSet{"a": "c", "c": "d"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(-time.Second), }, }, 2: { Alert: model.Alert{ Labels: model.LabelSet{"a": "c", "c": "f"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(-time.Second), }, }, }, equal: model.LabelNames{"a"}, input: model.LabelSet{"a": "b"}, result: false, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { r := &InhibitRule{ Equal: map[model.LabelName]struct{}{}, scache: store.NewAlerts(), sindex: newIndex(), } for _, ln := range c.equal { r.Equal[ln] = struct{}{} } for _, v := range c.initial { r.scache.Set(v) r.updateIndex(v) } if _, have := r.hasEqual(c.input, false, time.Now()); have != c.result { t.Errorf("Unexpected result %t, expected %t", have, c.result) } }) } } func TestInhibitRuleMatches(t *testing.T) { t.Parallel() rule1 := amcommoncfg.InhibitRule{ SourceMatch: map[string]string{"s1": "1"}, TargetMatch: map[string]string{"t1": "1"}, Equal: []string{"e"}, } rule2 := amcommoncfg.InhibitRule{ SourceMatch: map[string]string{"s2": "1"}, TargetMatch: map[string]string{"t2": "1"}, Equal: []string{"e"}, } m := types.NewMarker(prometheus.NewRegistry()) ih := NewInhibitor(nil, []amcommoncfg.InhibitRule{rule1, rule2}, m, nopLogger) now := time.Now() // Active alert that matches the source filter of rule1. sourceAlert1 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"s1": "1", "t1": "2", "e": "1"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour), }, } // Active alert that matches the source filter _and_ the target filter of rule2. sourceAlert2 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"s2": "1", "t2": "1", "e": "1"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour), }, } ih.rules[0].scache = store.NewAlerts() ih.rules[0].scache.Set(sourceAlert1) ih.rules[0].sindex = newIndex() ih.rules[0].updateIndex(sourceAlert1) ih.rules[1].scache = store.NewAlerts() ih.rules[1].scache.Set(sourceAlert2) ih.rules[1].sindex = newIndex() ih.rules[1].updateIndex(sourceAlert2) cases := []struct { target model.LabelSet expected bool }{ { // Matches target filter of rule1, inhibited. target: model.LabelSet{"t1": "1", "e": "1"}, expected: true, }, { // Matches target filter of rule2, inhibited. target: model.LabelSet{"t2": "1", "e": "1"}, expected: true, }, { // Matches target filter of rule1 (plus noise), inhibited. target: model.LabelSet{"t1": "1", "t3": "1", "e": "1"}, expected: true, }, { // Matches target filter of rule1 plus rule2, inhibited. target: model.LabelSet{"t1": "1", "t2": "1", "e": "1"}, expected: true, }, { // Doesn't match target filter, not inhibited. target: model.LabelSet{"t1": "0", "e": "1"}, expected: false, }, { // Matches both source and target filters of rule1, // inhibited because sourceAlert1 matches only the // source filter of rule1. target: model.LabelSet{"s1": "1", "t1": "1", "e": "1"}, expected: true, }, { // Matches both source and target filters of rule2, // not inhibited because sourceAlert2 matches also both the // source and target filter of rule2. target: model.LabelSet{"s2": "1", "t2": "1", "e": "1"}, expected: false, }, { // Matches target filter, equal label doesn't match, not inhibited target: model.LabelSet{"t1": "1", "e": "0"}, expected: false, }, } for _, c := range cases { if actual := ih.Mutes(context.Background(), c.target); actual != c.expected { t.Errorf("Expected (*Inhibitor).Mutes(%v) to return %t but got %t", c.target, c.expected, actual) } } } func TestInhibitRuleMatchers(t *testing.T) { t.Parallel() rule1 := amcommoncfg.InhibitRule{ SourceMatchers: amcommoncfg.Matchers{&labels.Matcher{Type: labels.MatchEqual, Name: "s1", Value: "1"}}, TargetMatchers: amcommoncfg.Matchers{&labels.Matcher{Type: labels.MatchNotEqual, Name: "t1", Value: "1"}}, Equal: []string{"e"}, } rule2 := amcommoncfg.InhibitRule{ SourceMatchers: amcommoncfg.Matchers{&labels.Matcher{Type: labels.MatchEqual, Name: "s2", Value: "1"}}, TargetMatchers: amcommoncfg.Matchers{&labels.Matcher{Type: labels.MatchEqual, Name: "t2", Value: "1"}}, Equal: []string{"e"}, } m := types.NewMarker(prometheus.NewRegistry()) ih := NewInhibitor(nil, []amcommoncfg.InhibitRule{rule1, rule2}, m, nopLogger) now := time.Now() // Active alert that matches the source filter of rule1. sourceAlert1 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"s1": "1", "t1": "2", "e": "1"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour), }, } // Active alert that matches the source filter _and_ the target filter of rule2. sourceAlert2 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"s2": "1", "t2": "1", "e": "1"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour), }, } ih.rules[0].scache = store.NewAlerts() ih.rules[0].scache.Set(sourceAlert1) ih.rules[0].sindex = newIndex() ih.rules[0].updateIndex(sourceAlert1) ih.rules[1].scache = store.NewAlerts() ih.rules[1].scache.Set(sourceAlert2) ih.rules[1].sindex = newIndex() ih.rules[1].updateIndex(sourceAlert2) cases := []struct { target model.LabelSet expected bool }{ { // Matches target filter of rule1, inhibited. target: model.LabelSet{"t1": "1", "e": "1"}, expected: false, }, { // Matches target filter of rule2, inhibited. target: model.LabelSet{"t2": "1", "e": "1"}, expected: true, }, { // Matches target filter of rule1 (plus noise), inhibited. target: model.LabelSet{"t1": "1", "t3": "1", "e": "1"}, expected: false, }, { // Matches target filter of rule1 plus rule2, inhibited. target: model.LabelSet{"t1": "1", "t2": "1", "e": "1"}, expected: true, }, { // Doesn't match target filter, not inhibited. target: model.LabelSet{"t1": "0", "e": "1"}, expected: true, }, { // Matches both source and target filters of rule1, // inhibited because sourceAlert1 matches only the // source filter of rule1. target: model.LabelSet{"s1": "1", "t1": "1", "e": "1"}, expected: false, }, { // Matches both source and target filters of rule2, // not inhibited because sourceAlert2 matches also both the // source and target filter of rule2. target: model.LabelSet{"s2": "1", "t2": "1", "e": "1"}, expected: true, }, { // Matches target filter, equal label doesn't match, not inhibited target: model.LabelSet{"t1": "1", "e": "0"}, expected: false, }, } for _, c := range cases { if actual := ih.Mutes(context.Background(), c.target); actual != c.expected { t.Errorf("Expected (*Inhibitor).Mutes(%v) to return %t but got %t", c.target, c.expected, actual) } } } func TestInhibitRuleName(t *testing.T) { t.Parallel() config1 := amcommoncfg.InhibitRule{ Name: "test-rule", SourceMatchers: []*labels.Matcher{ {Type: labels.MatchEqual, Name: "severity", Value: "critical"}, }, TargetMatchers: []*labels.Matcher{ {Type: labels.MatchEqual, Name: "severity", Value: "warning"}, }, Equal: []string{"instance"}, } config2 := amcommoncfg.InhibitRule{ SourceMatchers: []*labels.Matcher{ {Type: labels.MatchEqual, Name: "severity", Value: "critical"}, }, TargetMatchers: []*labels.Matcher{ {Type: labels.MatchEqual, Name: "severity", Value: "warning"}, }, Equal: []string{"instance"}, } rule1 := NewInhibitRule(config1) rule2 := NewInhibitRule(config2) require.Equal(t, "test-rule", rule1.Name, "Expected named rule to have adopt name from config") require.Empty(t, rule2.Name, "Expected unnamed rule to have empty name") } type fakeAlerts struct { alerts []*types.Alert finished chan struct{} } func newFakeAlerts(alerts []*types.Alert) *fakeAlerts { return &fakeAlerts{ alerts: alerts, finished: make(chan struct{}), } } func (f *fakeAlerts) GetPending() provider.AlertIterator { return nil } func (f *fakeAlerts) Get(model.Fingerprint) (*types.Alert, error) { return nil, nil } func (f *fakeAlerts) Put(context.Context, ...*types.Alert) error { return nil } func (f *fakeAlerts) Subscribe(name string) provider.AlertIterator { ch := make(chan *provider.Alert) done := make(chan struct{}) go func() { for _, a := range f.alerts { ch <- &provider.Alert{ Data: a, Header: map[string]string{}, } } // Send another (meaningless) alert to make sure that the inhibitor has // processed everything. ch <- &provider.Alert{ Data: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{}, StartsAt: time.Now(), }, }, Header: map[string]string{}, } close(f.finished) <-done }() return provider.NewAlertIterator(ch, done, nil) } func (f *fakeAlerts) SlurpAndSubscribe(name string) ([]*types.Alert, provider.AlertIterator) { ch := make(chan *provider.Alert) done := make(chan struct{}) go func() { for _, a := range f.alerts { ch <- &provider.Alert{ Data: a, Header: map[string]string{}, } } // Send another (meaningless) alert to make sure that the inhibitor has // processed everything. ch <- &provider.Alert{ Data: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{}, StartsAt: time.Now(), }, }, Header: map[string]string{}, } close(f.finished) <-done }() return []*types.Alert{}, provider.NewAlertIterator(ch, done, nil) } func TestInhibit(t *testing.T) { t.Parallel() now := time.Now() inhibitRule := func() amcommoncfg.InhibitRule { return amcommoncfg.InhibitRule{ SourceMatch: map[string]string{"s": "1"}, TargetMatch: map[string]string{"t": "1"}, Equal: []string{"e"}, } } // alertOne is muted by alertTwo when it is active. alertOne := func() *types.Alert { return &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"t": "1", "e": "f"}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour), }, } } alertTwo := func(resolved bool) *types.Alert { var end time.Time if resolved { end = now.Add(-time.Second) } else { end = now.Add(time.Hour) } return &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"s": "1", "e": "f"}, StartsAt: now.Add(-time.Minute), EndsAt: end, }, } } type exp struct { lbls model.LabelSet muted bool } for i, tc := range []struct { alerts []*types.Alert expected []exp }{ { // alertOne shouldn't be muted since alertTwo hasn't fired. alerts: []*types.Alert{alertOne()}, expected: []exp{ { lbls: model.LabelSet{"t": "1", "e": "f"}, muted: false, }, }, }, { // alertOne should be muted by alertTwo which is active. alerts: []*types.Alert{alertOne(), alertTwo(false)}, expected: []exp{ { lbls: model.LabelSet{"t": "1", "e": "f"}, muted: true, }, { lbls: model.LabelSet{"s": "1", "e": "f"}, muted: false, }, }, }, { // alertOne shouldn't be muted since alertTwo is resolved. alerts: []*types.Alert{alertOne(), alertTwo(false), alertTwo(true)}, expected: []exp{ { lbls: model.LabelSet{"t": "1", "e": "f"}, muted: false, }, { lbls: model.LabelSet{"s": "1", "e": "f"}, muted: false, }, }, }, } { ap := newFakeAlerts(tc.alerts) mk := types.NewMarker(prometheus.NewRegistry()) inhibitor := NewInhibitor(ap, []amcommoncfg.InhibitRule{inhibitRule()}, mk, nopLogger) go func() { for ap.finished != nil { select { case <-ap.finished: ap.finished = nil default: } } inhibitor.Stop() }() inhibitor.Run() for _, expected := range tc.expected { if inhibitor.Mutes(context.Background(), expected.lbls) != expected.muted { mute := "unmuted" if expected.muted { mute = "muted" } t.Errorf("tc: %d, expected alert with labels %q to be %s", i, expected.lbls, mute) } } } } func TestInhibitRule_fingerprintEquals(t *testing.T) { rule := &InhibitRule{ Equal: map[model.LabelName]struct{}{ "cluster": {}, "service": {}, }, } lset := model.LabelSet{ "cluster": "prod", "service": "api", "instance": "host1", } fp := rule.fingerprintEquals(lset) // Same equal labels should produce same fingerprint lset2 := model.LabelSet{ "cluster": "prod", "service": "api", "instance": "host2", // different non-equal label } require.Equal(t, fp, rule.fingerprintEquals(lset2)) // Different equal label value should produce different fingerprint lset3 := model.LabelSet{ "cluster": "staging", "service": "api", } require.NotEqual(t, fp, rule.fingerprintEquals(lset3)) } func BenchmarkFingerprintEquals(b *testing.B) { // Test fingerprintEquals with varying number of equal labels for _, numLabels := range []int{1, 3, 5, 10} { b.Run(fmt.Sprintf("%d_equal_labels", numLabels), func(b *testing.B) { equalLabels := make(map[model.LabelName]struct{}, numLabels) for i := range numLabels { equalLabels[model.LabelName(fmt.Sprintf("label_%d", i))] = struct{}{} } rule := &InhibitRule{Equal: equalLabels} // Create a label set with matching values lset := make(model.LabelSet, numLabels+2) lset["source"] = "true" lset["target"] = "true" for i := range numLabels { lset[model.LabelName(fmt.Sprintf("label_%d", i))] = model.LabelValue(fmt.Sprintf("value_%d", i)) } b.ResetTimer() b.ReportAllocs() for b.Loop() { _ = rule.fingerprintEquals(lset) } }) } } ================================================ FILE: internal/tools/go.mod ================================================ module github.com/prometheus/prometheus/internal/tools go 1.25.0 tool ( github.com/bufbuild/buf/cmd/buf github.com/go-swagger/go-swagger/cmd/swagger google.golang.org/protobuf/cmd/protoc-gen-go ) require ( buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20250718181942-e35f9b667443.1 // indirect buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2 // indirect buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1 // indirect buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 // indirect buf.build/go/app v0.2.0 // indirect buf.build/go/bufplugin v0.9.0 // indirect buf.build/go/bufprivateusage v0.1.0 // indirect buf.build/go/interrupt v1.1.0 // indirect buf.build/go/protovalidate v1.1.0 // indirect buf.build/go/protoyaml v0.6.0 // indirect buf.build/go/spdx v0.2.0 // indirect buf.build/go/standard v0.1.0 // indirect cel.dev/expr v0.25.1 // indirect connectrpc.com/connect v1.19.1 // indirect connectrpc.com/otelconnect v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/bufbuild/buf v1.65.0 // indirect github.com/bufbuild/protocompile v0.14.2-0.20260130195850-5c64bed4577e // indirect github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cli/browser v1.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/cli v29.2.0+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker v28.5.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-chi/chi/v5 v5.2.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.2 // indirect github.com/go-openapi/inflect v0.21.3 // indirect github.com/go-openapi/jsonpointer v0.21.2 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/runtime v0.28.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/go-swagger/go-swagger v0.33.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/go-containerregistry v0.20.7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jdx/go-netrc v1.0.0 // indirect github.com/jessevdk/go-flags v1.6.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.1.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/cors v1.11.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.10.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.3 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.9.2 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.20.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect github.com/tidwall/btree v1.8.1 // indirect github.com/toqueteos/webbrowser v1.2.1 // indirect github.com/vbatts/tar-split v0.12.2 // indirect go.lsp.dev/jsonrpc2 v0.10.0 // indirect go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect go.lsp.dev/protocol v0.12.0 // indirect go.lsp.dev/uri v0.3.0 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect google.golang.org/grpc v1.79.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/xurls/v2 v2.6.0 // indirect pluginrpc.com/pluginrpc v0.5.0 // indirect ) ================================================ FILE: internal/tools/go.sum ================================================ buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20250718181942-e35f9b667443.1 h1:zQ9C3e6FtwSZUFuKAQfpIKGFk5ZuRoGt5g35Bix55sI= buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20250718181942-e35f9b667443.1/go.mod h1:1Znr6gmYBhbxWUPRrrVnSLXQsz8bvFVw1HHJq2bI3VQ= buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1 h1:HwzzCRS4ZrEm1++rzSDxHnO0DOjiT1b8I/24e8a4exY= buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1/go.mod h1:8PRKXhgNes29Tjrnv8KdZzg3I1QceOkzibW1QK7EXv0= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 h1:j9yeqTWEFrtimt8Nng2MIeRrpoCvQzM9/g25XTvqUGg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2 h1:XPrWCd9ydEo5Ofv1aNJVJaxndMXLQjRO9vVzsJG3jL8= buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2/go.mod h1:mpsjeEaxOYPIJV2cz4IagLghZufRvx+NPVtInjEeoQ8= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1 h1:Yreby6Ypa58wdQUEm9Fnc5g8n/jP487Dq3aK5yBYwfk= buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 h1:iGPvEJltOXUMANWf0zajcRcbiOXLD90ZwPUFvbcuv6Q= buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1/go.mod h1:nWVKKRA29zdt4uvkjka3i/y4mkrswyWwiu0TbdX0zts= buf.build/go/app v0.2.0 h1:NYaH13A+RzPb7M5vO8uZYZ2maBZI5+MS9A9tQm66fy8= buf.build/go/app v0.2.0/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= buf.build/go/bufplugin v0.9.0 h1:ktZJNP3If7ldcWVqh46XKeiYJVPxHQxCfjzVQDzZ/lo= buf.build/go/bufplugin v0.9.0/go.mod h1:Z0CxA3sKQ6EPz/Os4kJJneeRO6CjPeidtP1ABh5jPPY= buf.build/go/bufprivateusage v0.1.0 h1:SzCoCcmzS3zyXHEXHeSQhGI7OTkgtljoknLzsUz9Gg4= buf.build/go/bufprivateusage v0.1.0/go.mod h1:GlCCJ3VVF7EqqU0CoRmo1FzAwwaKymEWSr+ty69xU5w= buf.build/go/interrupt v1.1.0 h1:olBuhgv9Sav4/9pkSLoxgiOsZDgM5VhRhvRpn3DL0lE= buf.build/go/interrupt v1.1.0/go.mod h1:ql56nXPG1oHlvZa6efNC7SKAQ/tUjS6z0mhJl0gyeRM= buf.build/go/protovalidate v1.1.0 h1:pQqEQRpOo4SqS60qkvmhLTTQU9JwzEvdyiqAtXa5SeY= buf.build/go/protovalidate v1.1.0/go.mod h1:bGZcPiAQDC3ErCHK3t74jSoJDFOs2JH3d7LWuTEIdss= buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w= buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw= buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= buf.build/go/standard v0.1.0 h1:g98T9IyvAl0vS3Pq8iVk6Cvj2ZiFvoUJRtfyGa0120U= buf.build/go/standard v0.1.0/go.mod h1:PiqpHz/7ZFq+kqvYhc/SK3lxFIB9N/aiH2CFC2JHIQg= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= connectrpc.com/otelconnect v0.9.0 h1:NggB3pzRC3pukQWaYbRHJulxuXvmCKCKkQ9hbrHAWoA= connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bufbuild/buf v1.65.0 h1:f2BzeCY9rRh9P5KD340ZoPAaFLTkssoUTHx7lpqozgg= github.com/bufbuild/buf v1.65.0/go.mod h1:7SAs2YqGpPXHqBBXBeYQbCzY0OQq4Jbg6XCqirEiYvQ= github.com/bufbuild/protocompile v0.14.2-0.20260130195850-5c64bed4577e h1:emH16Bf1w4C0cJ3ge4QtBAl4sIYJe23EfpWH0SpA9co= github.com/bufbuild/protocompile v0.14.2-0.20260130195850-5c64bed4577e/go.mod h1:cxhE8h+14t0Yxq2H9MV/UggzQ1L0gh0t2tJobITWsBE= github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 h1:V1xulAoqLqVg44rY97xOR+mQpD2N+GzhMHVwJ030WEU= github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM= github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg= github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= github.com/go-openapi/inflect v0.21.3 h1:TmQvw+9eLrsNp4X0BBQacEZZtAnzk2z1FaLdQQJsDiU= github.com/go-openapi/inflect v0.21.3/go.mod h1:INezMuUu7SJQc2AyR3WO0DqqYUJSj8Kb4hBd7WtjlAw= github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-swagger/go-swagger v0.33.1 h1:BdtmxCvMxkrYIGAyn/qXPi4j85mFTwG4c9ED/27jtq4= github.com/go-swagger/go-swagger v0.33.1/go.mod h1:wJK762cSroJbM7hgJtKtbZxfevB3RSF3sC8/hK+kRwc= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/jhump/protoreflect/v2 v2.0.0-beta.2 h1:qZU+rEZUOYTz1Bnhi3xbwn+VxdXkLVeEpAeZzVXLY88= github.com/jhump/protoreflect/v2 v2.0.0-beta.2/go.mod h1:4tnOYkB/mq7QTyS3YKtVtNrJv4Psqout8HA1U+hZtgM= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9 h1:arwj11zP0yJIxIRiDn22E0H8PxfF7TsTrc2wIPFIsf4= github.com/protocolbuffers/protoscope v0.0.0-20221109213918-8e7a6aafa2c9/go.mod h1:SKZx6stCn03JN3BOWTwvVIO2ajMkb/zQdTceXYhKw/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.10.0 h1:FM8Cv6j2KqIhM2ZK7HZjm4mpj9NBktLgowT1aN9q5Cc= github.com/sagikazarmark/locafero v0.10.0/go.mod h1:Ieo3EUsjifvQu4NZwV5sPd4dwvu0OCgEQV7vjc9yDjw= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= github.com/toqueteos/webbrowser v1.2.1 h1:O7IsnnU7XQyJ1nHMRfAktUUJOAZD3aQyUVnxzhWphCg= github.com/toqueteos/webbrowser v1.2.1/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI= go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 h1:hCzQgh6UcwbKgNSRurYWSqh8MufqRRPODRBblutn4TE= go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2/go.mod h1:gtSHRuYfbCT0qnbLnovpie/WEmqyJ7T4n6VXiFMBtcw= go.lsp.dev/protocol v0.12.0 h1:tNprUI9klQW5FAFVM4Sa+AbPFuVQByWhP1ttNUAjIWg= go.lsp.dev/protocol v0.12.0/go.mod h1:Qb11/HgZQ72qQbeyPfJbu3hZBH23s1sr4st8czGeDMQ= go.lsp.dev/uri v0.3.0 h1:KcZJmh6nFIBeJzTugn5JTU6OOyG0lDOo3R9KwTxTYbo= go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac h1:7zkz7BUtwNFFqcowJ+RIgu2MaV/MapERkDIy+mwPyjs= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= pluginrpc.com/pluginrpc v0.5.0 h1:tOQj2D35hOmvHyPu8e7ohW2/QvAnEtKscy2IJYWQ2yo= pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o= ================================================ FILE: limit/bucket.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package limit import ( "container/heap" "sync" "time" ) // item represents a value and its priority based on time. type item[V any] struct { value V priority time.Time index int } // expired returns true if the item is expired (priority is before the given time). func (i *item[V]) expired(at time.Time) bool { return i.priority.Before(at) } // sortedItems is a heap of items. type sortedItems[V any] []*item[V] // Len returns the number of items in the heap. func (s sortedItems[V]) Len() int { return len(s) } // Less reports whether the element with index i should sort before the element with index j. func (s sortedItems[V]) Less(i, j int) bool { return s[i].priority.Before(s[j].priority) } // Swap swaps the elements with indexes i and j. func (s sortedItems[V]) Swap(i, j int) { s[i], s[j] = s[j], s[i] s[i].index = i s[j].index = j } // Push adds an item to the heap. func (s *sortedItems[V]) Push(x any) { n := len(*s) item := x.(*item[V]) item.index = n *s = append(*s, item) } // Pop removes and returns the minimum element (according to Less). func (s *sortedItems[V]) Pop() any { old := *s n := len(old) item := old[n-1] old[n-1] = nil // don't stop the GC from reclaiming the item eventually item.index = -1 // for safety *s = old[0 : n-1] return item } // update modifies the priority and value of an item in the heap. func (s *sortedItems[V]) update(item *item[V], priority time.Time) { item.priority = priority heap.Fix(s, item.index) } // Bucket is a simple cache for values with priority(expiry). // It has: // - configurable capacity. // - a mutex for thread safety. // - a sorted heap of items for priority/expiry based eviction. // - an index of items for fast updates. type Bucket[V comparable] struct { mtx sync.Mutex index map[V]*item[V] items sortedItems[V] capacity int } // NewBucket creates a new bucket with the given capacity. // All internal data structures are initialized to the given capacity to avoid allocations during runtime. func NewBucket[V comparable](capacity int) *Bucket[V] { items := make(sortedItems[V], 0, capacity) heap.Init(&items) return &Bucket[V]{ index: make(map[V]*item[V], capacity), items: items, capacity: capacity, } } // IsStale returns true if the latest item in the bucket is expired. func (b *Bucket[V]) IsStale() (stale bool) { b.mtx.Lock() defer b.mtx.Unlock() if b.items.Len() == 0 { return true } latest := b.items[b.items.Len()-1] return latest.expired(time.Now()) } // Upsert tries to add a new value and its priority to the bucket. // If the value is already in the bucket, its priority is updated. // If the bucket is not full, the new value is added. // If the bucket is full, oldest expired item is evicted based on priority and the new value is added. // Otherwise the new value is ignored and the method returns false. func (b *Bucket[V]) Upsert(value V, priority time.Time) (ok bool) { if b.capacity < 1 { return false } b.mtx.Lock() defer b.mtx.Unlock() // If the value is already in the index, update it. if item, exists := b.index[value]; exists { b.items.update(item, priority) return true } // If the bucket is not full, add the new value to the heap and index. if b.items.Len() < b.capacity { item := &item[V]{ value: value, priority: priority, } b.index[value] = item heap.Push(&b.items, item) return true } // If the bucket is full, check the oldest item (at heap root) and evict it if expired oldest := b.items[0] if oldest.expired(time.Now()) { // Remove the expired item from both the heap and the index heap.Pop(&b.items) delete(b.index, oldest.value) // Add the new item item := &item[V]{ value: value, priority: priority, } b.index[value] = item heap.Push(&b.items, item) return true } return false } ================================================ FILE: limit/bucket_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package limit import ( "fmt" "testing" "time" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" ) func TestBucketUpsert(t *testing.T) { testCases := []struct { name string bucketCapacity int alerts []model.Alert alertTimings []time.Time // When each alert is added relative to now expectedResult []bool // Expected return value for each Add() call description string }{ { name: "Bucket with zero capacity should reject all alerts", bucketCapacity: 0, alerts: []model.Alert{ {Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour)}, {Labels: model.LabelSet{"alertname": "Alert2", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour)}, {Labels: model.LabelSet{"alertname": "Alert3", "instance": "server3"}, EndsAt: time.Now().Add(1 * time.Hour)}, }, alertTimings: []time.Time{time.Now(), time.Now(), time.Now()}, expectedResult: []bool{false, false, false}, // All should be rejected description: "Adding 3 alerts to a bucket with capacity 0 should fail", }, { name: "Empty bucket should add items while not full", bucketCapacity: 3, alerts: []model.Alert{ {Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour)}, {Labels: model.LabelSet{"alertname": "Alert2", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour)}, {Labels: model.LabelSet{"alertname": "Alert3", "instance": "server3"}, EndsAt: time.Now().Add(1 * time.Hour)}, }, alertTimings: []time.Time{time.Now(), time.Now(), time.Now()}, expectedResult: []bool{true, true, true}, // All should be added successfully description: "Adding 3 alerts to a bucket with capacity 3 should succeed", }, { name: "Full bucket must not add items if old items are not expired yet", bucketCapacity: 2, alerts: []model.Alert{ {Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour)}, {Labels: model.LabelSet{"alertname": "Alert2", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour)}, {Labels: model.LabelSet{"alertname": "Alert3", "instance": "server3"}, EndsAt: time.Now().Add(1 * time.Hour)}, }, alertTimings: []time.Time{time.Now(), time.Now(), time.Now()}, expectedResult: []bool{true, true, false}, // First two succeed, third fails description: "Adding third alert to full bucket with non-expired items should fail", }, { name: "Full bucket must add items if old items are expired", bucketCapacity: 2, alerts: []model.Alert{ {Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(-1 * time.Hour)}, // Expired 1 hour ago {Labels: model.LabelSet{"alertname": "Alert2", "instance": "server2"}, EndsAt: time.Now().Add(-30 * time.Minute)}, // Expired 30 minutes ago {Labels: model.LabelSet{"alertname": "Alert3", "instance": "server3"}, EndsAt: time.Now().Add(1 * time.Hour)}, // Will expire in 1 hour }, alertTimings: []time.Time{time.Now(), time.Now(), time.Now()}, expectedResult: []bool{true, true, true}, // All should succeed because older items get evicted description: "Adding new alerts when bucket is full but oldest items are expired should succeed", }, { name: "Update existing alert in bucket should not increase size", bucketCapacity: 2, alerts: []model.Alert{ {Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour)}, {Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(2 * time.Hour)}, // Same fingerprint, different EndsAt {Labels: model.LabelSet{"alertname": "Alert2", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour)}, }, alertTimings: []time.Time{time.Now(), time.Now(), time.Now()}, expectedResult: []bool{true, true, true}, // All should succeed - second is an update, not a new entry description: "Updating existing alert should not consume additional bucket space", }, { name: "Mixed scenario with expiration and updates", bucketCapacity: 2, alerts: []model.Alert{ {Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(-1 * time.Hour)}, // Expired {Labels: model.LabelSet{"alertname": "Alert2", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour)}, // Active {Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(2 * time.Hour)}, // Update of first alert {Labels: model.LabelSet{"alertname": "Alert3", "instance": "server3"}, EndsAt: time.Now().Add(1 * time.Hour)}, // New alert, bucket full but Alert2 not expired }, alertTimings: []time.Time{time.Now(), time.Now(), time.Now(), time.Now()}, expectedResult: []bool{true, true, true, false}, // Last one should fail because bucket is full with non-expired items description: "Complex scenario with expiration, updates, and eviction should work correctly", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { bucket := NewBucket[model.Fingerprint](tc.bucketCapacity) for i, alert := range tc.alerts { result := bucket.Upsert(alert.Fingerprint(), alert.EndsAt) require.Equal(t, tc.expectedResult[i], result, "Alert %d: expected %v, got %v. %s", i+1, tc.expectedResult[i], result, tc.description) } }) } } func TestBucketAddConcurrency(t *testing.T) { bucket := NewBucket[model.Fingerprint](2) // Test that concurrent access to bucket is safe alert1 := model.Alert{Labels: model.LabelSet{"alertname": "Alert1", "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour)} alert2 := model.Alert{Labels: model.LabelSet{"alertname": "Alert2", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour)} done := make(chan bool, 2) // Add alerts concurrently go func() { bucket.Upsert(alert1.Fingerprint(), alert1.EndsAt) done <- true }() go func() { bucket.Upsert(alert2.Fingerprint(), alert2.EndsAt) done <- true }() // Wait for both goroutines to complete <-done <-done // Verify that both alerts were added (bucket should contain 2 items) require.Len(t, bucket.index, 2, "Expected 2 alerts in bucket after concurrent adds") require.Len(t, bucket.items, 2, "Expected 2 items in bucket map after concurrent adds") } func TestBucketAddExpiredEviction(t *testing.T) { bucket := NewBucket[model.Fingerprint](2) // Add two alerts that are already expired expiredAlert1 := model.Alert{ Labels: model.LabelSet{"alertname": "ExpiredAlert1", "instance": "server1"}, EndsAt: time.Now().Add(-1 * time.Hour), } expiredFingerprint1 := expiredAlert1.Fingerprint() expiredAlert2 := model.Alert{ Labels: model.LabelSet{"alertname": "ExpiredAlert2", "instance": "server2"}, EndsAt: time.Now().Add(-30 * time.Minute), } expiredFingerprint2 := expiredAlert2.Fingerprint() // Fill the bucket with expired alerts result1 := bucket.Upsert(expiredFingerprint1, expiredAlert1.EndsAt) require.True(t, result1, "First expired alert should be added successfully") result2 := bucket.Upsert(expiredFingerprint2, expiredAlert2.EndsAt) require.True(t, result2, "Second expired alert should be added successfully") // Now add a fresh alert - it should evict the first expired alert freshAlert := model.Alert{ Labels: model.LabelSet{"alertname": "FreshAlert", "instance": "server3"}, EndsAt: time.Now().Add(1 * time.Hour), } freshFingerprint := freshAlert.Fingerprint() result3 := bucket.Upsert(freshFingerprint, freshAlert.EndsAt) require.True(t, result3, "Fresh alert should be added successfully, evicting expired alert") // Verify the bucket state require.Len(t, bucket.index, 2, "Bucket should still contain 2 items after eviction") require.Len(t, bucket.items, 2, "Bucket map should still contain 2 items after eviction") // The fresh alert should be in the bucket _, exists := bucket.index[freshFingerprint] require.True(t, exists, "Fresh alert should exist in bucket after eviction") // The first expired alert should have been evicted _, exists = bucket.index[expiredFingerprint1] require.False(t, exists, "First expired alert should have been evicted from bucket, fingerprint: %d", expiredFingerprint1) } func TestBucketAddEdgeCases(t *testing.T) { t.Run("Single capacity bucket with replacement", func(t *testing.T) { bucket := NewBucket[model.Fingerprint](1) // Add expired alert expiredAlert := model.Alert{Labels: model.LabelSet{"alertname": "Expired"}, EndsAt: time.Now().Add(-1 * time.Hour)} result1 := bucket.Upsert(expiredAlert.Fingerprint(), expiredAlert.EndsAt) require.True(t, result1, "Adding expired alert to single-capacity bucket should succeed") // Add fresh alert (should replace expired one) freshAlert := model.Alert{Labels: model.LabelSet{"alertname": "Fresh"}, EndsAt: time.Now().Add(1 * time.Hour)} result2 := bucket.Upsert(freshAlert.Fingerprint(), freshAlert.EndsAt) require.True(t, result2, "Adding fresh alert should succeed by replacing expired one") // Verify only the fresh alert remains require.Len(t, bucket.index, 1, "Bucket should contain exactly 1 item") freshFingerprint := freshAlert.Fingerprint() _, exists := bucket.index[freshFingerprint] require.True(t, exists, "Fresh alert should exist in bucket") }) t.Run("Alert with same fingerprint but different EndsAt", func(t *testing.T) { bucket := NewBucket[model.Fingerprint](2) // Add initial alert originalTime := time.Now().Add(1 * time.Hour) alert1 := model.Alert{Labels: model.LabelSet{"alertname": "Test"}, EndsAt: originalTime} result1 := bucket.Upsert(alert1.Fingerprint(), alert1.EndsAt) require.True(t, result1, "Initial alert should be added successfully") // Add same alert with different EndsAt (should update, not add new) updatedTime := time.Now().Add(2 * time.Hour) alert2 := model.Alert{Labels: model.LabelSet{"alertname": "Test"}, EndsAt: updatedTime} result2 := bucket.Upsert(alert2.Fingerprint(), alert2.EndsAt) require.True(t, result2, "Updated alert should not fill bucket") // Verify bucket still has only one entry with updated time require.Len(t, bucket.index, 1, "Bucket should contain exactly 1 item after update") fingerprint := alert1.Fingerprint() storedTime, exists := bucket.index[fingerprint] require.True(t, exists, "Alert should exist in bucket") require.Equal(t, updatedTime, storedTime.priority, "Alert should have updated EndsAt time") }) } // Benchmark tests for Bucket.Upsert() performance. func BenchmarkBucketUpsert(b *testing.B) { b.Run("EmptyBucket", func(b *testing.B) { bucket := NewBucket[model.Fingerprint](1000) alert := model.Alert{ Labels: model.LabelSet{"alertname": "TestAlert", "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour), } b.ResetTimer() for i := 0; i < b.N; i++ { bucket.Upsert(alert.Fingerprint(), alert.EndsAt) } }) b.Run("AddToFullBucketWithExpiredItems", func(b *testing.B) { bucketSize := 100 bucket := NewBucket[model.Fingerprint](bucketSize) // Fill bucket with expired alerts for i := range bucketSize { expiredAlert := model.Alert{ Labels: model.LabelSet{"alertname": model.LabelValue("ExpiredAlert" + string(rune(i))), "instance": "server1"}, EndsAt: time.Now().Add(-1 * time.Hour), // Expired 1 hour ago } bucket.Upsert(expiredAlert.Fingerprint(), expiredAlert.EndsAt) } newAlert := model.Alert{ Labels: model.LabelSet{"alertname": "NewAlert", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour), } b.ResetTimer() for i := 0; i < b.N; i++ { bucket.Upsert(newAlert.Fingerprint(), newAlert.EndsAt) } }) b.Run("AddToFullBucketWithActiveItems", func(b *testing.B) { bucketSize := 100 bucket := NewBucket[model.Fingerprint](bucketSize) // Fill bucket with active alerts for i := range bucketSize { activeAlert := model.Alert{ Labels: model.LabelSet{"alertname": model.LabelValue("ActiveAlert" + string(rune(i))), "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour), // Active for 1 hour } bucket.Upsert(activeAlert.Fingerprint(), activeAlert.EndsAt) } newAlert := model.Alert{ Labels: model.LabelSet{"alertname": "NewAlert", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour), } b.ResetTimer() for i := 0; i < b.N; i++ { bucket.Upsert(newAlert.Fingerprint(), newAlert.EndsAt) } }) b.Run("UpdateExistingItem", func(b *testing.B) { bucket := NewBucket[model.Fingerprint](100) // Add initial alert alert := model.Alert{ Labels: model.LabelSet{"alertname": "TestAlert", "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour), } bucket.Upsert(alert.Fingerprint(), alert.EndsAt) // Create update with same fingerprint but different EndsAt updatedAlert := model.Alert{ Labels: model.LabelSet{"alertname": "TestAlert", "instance": "server1"}, EndsAt: time.Now().Add(2 * time.Hour), } b.ResetTimer() for i := 0; i < b.N; i++ { bucket.Upsert(updatedAlert.Fingerprint(), updatedAlert.EndsAt) } }) b.Run("MixedWorkload", func(b *testing.B) { bucketSize := 50 bucket := NewBucket[model.Fingerprint](bucketSize) // Pre-populate with mix of expired and active alerts for i := 0; i < bucketSize/2; i++ { expiredAlert := model.Alert{ Labels: model.LabelSet{"alertname": model.LabelValue("ExpiredAlert" + string(rune(i))), "instance": "server1"}, EndsAt: time.Now().Add(-1 * time.Hour), } bucket.Upsert(expiredAlert.Fingerprint(), expiredAlert.EndsAt) } for i := 0; i < bucketSize/2; i++ { activeAlert := model.Alert{ Labels: model.LabelSet{"alertname": model.LabelValue("ActiveAlert" + string(rune(i))), "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour), } bucket.Upsert(activeAlert.Fingerprint(), activeAlert.EndsAt) } // Create different types of alerts for the benchmark alerts := []*model.Alert{ {Labels: model.LabelSet{"alertname": "NewAlert1", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour)}, {Labels: model.LabelSet{"alertname": "ExpiredAlert0", "instance": "server1"}, EndsAt: time.Now().Add(2 * time.Hour)}, // Update existing {Labels: model.LabelSet{"alertname": "NewAlert2", "instance": "server3"}, EndsAt: time.Now().Add(1 * time.Hour)}, } b.ResetTimer() for i := 0; i < b.N; i++ { alertIndex := i % len(alerts) bucket.Upsert(alerts[alertIndex].Fingerprint(), alerts[alertIndex].EndsAt) } }) } // Benchmark different bucket sizes to understand scaling behavior. func BenchmarkBucketUpsertScaling(b *testing.B) { sizes := []int{10, 50, 100, 500, 1000} for _, size := range sizes { b.Run(fmt.Sprintf("BucketSize_%d", size), func(b *testing.B) { bucket := NewBucket[model.Fingerprint](size) // Fill bucket to capacity with expired items for i := range size { alert := model.Alert{ Labels: model.LabelSet{"alertname": model.LabelValue(fmt.Sprintf("Alert%d", i)), "instance": "server1"}, EndsAt: time.Now().Add(-1 * time.Hour), } bucket.Upsert(alert.Fingerprint(), alert.EndsAt) } newAlert := model.Alert{ Labels: model.LabelSet{"alertname": "NewAlert", "instance": "server2"}, EndsAt: time.Now().Add(1 * time.Hour), } b.ResetTimer() for range b.N { bucket.Upsert(newAlert.Fingerprint(), newAlert.EndsAt) } }) } } func TestBucketIsStale(t *testing.T) { t.Run("IsStale on empty bucket should return true", func(t *testing.T) { bucket := NewBucket[model.Fingerprint](5) // Should not panic when bucket is empty and return true require.NotPanics(t, func() { stale := bucket.IsStale() require.True(t, stale, "IsStale on empty bucket should return true") }, "IsStale on empty bucket should not panic") }) t.Run("IsStale returns true when latest item is expired", func(t *testing.T) { bucket := NewBucket[model.Fingerprint](3) // Add three alerts that are all expired expiredTime := time.Now().Add(-1 * time.Hour) alert1 := model.Alert{Labels: model.LabelSet{"alertname": "Alert1"}, EndsAt: expiredTime} alert2 := model.Alert{Labels: model.LabelSet{"alertname": "Alert2"}, EndsAt: expiredTime.Add(-10 * time.Minute)} alert3 := model.Alert{Labels: model.LabelSet{"alertname": "Alert3"}, EndsAt: expiredTime.Add(-20 * time.Minute)} bucket.Upsert(alert1.Fingerprint(), alert1.EndsAt) bucket.Upsert(alert2.Fingerprint(), alert2.EndsAt) bucket.Upsert(alert3.Fingerprint(), alert3.EndsAt) require.Len(t, bucket.items, 3, "Bucket should have 3 items before IsStale check") require.Len(t, bucket.index, 3, "Bucket index should have 3 items before IsStale check") // IsStale should return true when all items are expired stale := bucket.IsStale() require.True(t, stale, "IsStale should return true when all items are expired") // IsStale doesn't remove items, so bucket should still contain them require.Len(t, bucket.items, 3, "Bucket should still have 3 items after IsStale check") require.Len(t, bucket.index, 3, "Bucket index should still have 3 items after IsStale check") }) t.Run("IsStale returns false when latest item is not expired", func(t *testing.T) { bucket := NewBucket[model.Fingerprint](3) // Add mix of expired and non-expired alerts expiredTime := time.Now().Add(-1 * time.Hour) futureTime := time.Now().Add(1 * time.Hour) alert1 := model.Alert{Labels: model.LabelSet{"alertname": "Expired1"}, EndsAt: expiredTime} alert2 := model.Alert{Labels: model.LabelSet{"alertname": "Expired2"}, EndsAt: expiredTime.Add(-10 * time.Minute)} alert3 := model.Alert{Labels: model.LabelSet{"alertname": "Active"}, EndsAt: futureTime} bucket.Upsert(alert1.Fingerprint(), alert1.EndsAt) bucket.Upsert(alert2.Fingerprint(), alert2.EndsAt) bucket.Upsert(alert3.Fingerprint(), alert3.EndsAt) require.Len(t, bucket.items, 3, "Bucket should have 3 items before IsStale check") // IsStale should return false since the latest item (alert3) is not expired stale := bucket.IsStale() require.False(t, stale, "IsStale should return false when latest item is not expired") require.Len(t, bucket.items, 3, "Bucket should still have 3 items after IsStale check") require.Len(t, bucket.index, 3, "Bucket index should still have 3 items after IsStale check") }) } // Benchmark concurrent access to Bucket.Upsert(). func BenchmarkBucketUpsertConcurrent(b *testing.B) { bucket := NewBucket[model.Fingerprint](100) b.RunParallel(func(pb *testing.PB) { alertCounter := 0 for pb.Next() { alert := model.Alert{ Labels: model.LabelSet{"alertname": model.LabelValue("Alert" + string(rune(alertCounter))), "instance": "server1"}, EndsAt: time.Now().Add(1 * time.Hour), } bucket.Upsert(alert.Fingerprint(), alert.EndsAt) alertCounter++ } }) } ================================================ FILE: matcher/compat/parse.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compat import ( "fmt" "log/slog" "reflect" "strings" "unicode/utf8" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/matcher/parse" "github.com/prometheus/alertmanager/pkg/labels" ) var ( isValidLabelName = isValidClassicLabelName(promslog.NewNopLogger()) parseMatcher = ClassicMatcherParser(promslog.NewNopLogger()) parseMatchers = ClassicMatchersParser(promslog.NewNopLogger()) ) // IsValidLabelName returns true if the string is a valid label name. func IsValidLabelName(name model.LabelName) bool { return isValidLabelName(name) } type ParseMatcher func(input, origin string) (*labels.Matcher, error) type ParseMatchers func(input, origin string) (labels.Matchers, error) // Matcher parses the matcher in the input string. It returns an error // if the input is invalid or contains two or more matchers. func Matcher(input, origin string) (*labels.Matcher, error) { return parseMatcher(input, origin) } // Matchers parses one or more matchers in the input string. It returns // an error if the input is invalid. func Matchers(input, origin string) (labels.Matchers, error) { return parseMatchers(input, origin) } // InitFromFlags initializes the compat package from the flagger. func InitFromFlags(l *slog.Logger, f featurecontrol.Flagger) { if f.ClassicMode() { isValidLabelName = isValidClassicLabelName(l) parseMatcher = ClassicMatcherParser(l) parseMatchers = ClassicMatchersParser(l) } else if f.UTF8StrictMode() { isValidLabelName = isValidUTF8LabelName(l) parseMatcher = UTF8MatcherParser(l) parseMatchers = UTF8MatchersParser(l) } else { isValidLabelName = isValidUTF8LabelName(l) parseMatcher = FallbackMatcherParser(l) parseMatchers = FallbackMatchersParser(l) } } // ClassicMatcherParser uses the pkg/labels parser to parse the matcher in // the input string. func ClassicMatcherParser(l *slog.Logger) ParseMatcher { return func(input, origin string) (matcher *labels.Matcher, err error) { l.Debug("Parsing with classic matchers parser", "input", input, "origin", origin) return labels.ParseMatcher(input) } } // ClassicMatchersParser uses the pkg/labels parser to parse zero or more // matchers in the input string. It returns an error if the input is invalid. func ClassicMatchersParser(l *slog.Logger) ParseMatchers { return func(input, origin string) (matchers labels.Matchers, err error) { l.Debug("Parsing with classic matchers parser", "input", input, "origin", origin) return labels.ParseMatchers(input) } } // UTF8MatcherParser uses the new matcher/parse parser to parse the matcher // in the input string. If this fails it does not revert to the pkg/labels parser. func UTF8MatcherParser(l *slog.Logger) ParseMatcher { return func(input, origin string) (matcher *labels.Matcher, err error) { l.Debug("Parsing with UTF-8 matchers parser", "input", input, "origin", origin) if strings.HasPrefix(input, "{") || strings.HasSuffix(input, "}") { return nil, fmt.Errorf("unexpected open or close brace: %s", input) } return parse.Matcher(input) } } // UTF8MatchersParser uses the new matcher/parse parser to parse zero or more // matchers in the input string. If this fails it does not revert to the // pkg/labels parser. func UTF8MatchersParser(l *slog.Logger) ParseMatchers { return func(input, origin string) (matchers labels.Matchers, err error) { l.Debug("Parsing with UTF-8 matchers parser", "input", input, "origin", origin) return parse.Matchers(input) } } // FallbackMatcherParser uses the new matcher/parse parser to parse zero or more // matchers in the string. If this fails it reverts to the pkg/labels parser and // emits a warning log line. func FallbackMatcherParser(l *slog.Logger) ParseMatcher { return func(input, origin string) (matcher *labels.Matcher, err error) { l.Debug("Parsing with UTF-8 matchers parser, with fallback to classic matchers parser", "input", input, "origin", origin) if strings.HasPrefix(input, "{") || strings.HasSuffix(input, "}") { return nil, fmt.Errorf("unexpected open or close brace: %s", input) } // Parse the input in both parsers to look for disagreement and incompatible // inputs. nMatcher, nErr := parse.Matcher(input) cMatcher, cErr := labels.ParseMatcher(input) if nErr != nil { // If the input is invalid in both parsers, return the error. if cErr != nil { return nil, cErr } // The input is valid in the pkg/labels parser, but not the matcher/parse // parser. This means the input is not forwards compatible. suggestion := cMatcher.String() l.Warn("Alertmanager is moving to a new parser for labels and matchers, and this input is incompatible. Alertmanager has instead parsed the input using the classic matchers parser as a fallback. To make this input compatible with the UTF-8 matchers parser please make sure all regular expressions and values are double-quoted and backslashes are escaped. If you are still seeing this message please open an issue.", "input", input, "origin", origin, "err", nErr, "suggestion", suggestion) return cMatcher, nil } // If the input is valid in both parsers, but produces different results, // then there is disagreement. if cErr == nil && !reflect.DeepEqual(nMatcher, cMatcher) { l.Warn("Matchers input has disagreement", "input", input, "origin", origin) return cMatcher, nil } return nMatcher, nil } } // FallbackMatchersParser uses the new matcher/parse parser to parse the // matcher in the input string. If this fails it falls back to the pkg/labels // parser and emits a warning log line. func FallbackMatchersParser(l *slog.Logger) ParseMatchers { return func(input, origin string) (matchers labels.Matchers, err error) { l.Debug("Parsing with UTF-8 matchers parser, with fallback to classic matchers parser", "input", input, "origin", origin) // Parse the input in both parsers to look for disagreement and incompatible // inputs. nMatchers, nErr := parse.Matchers(input) cMatchers, cErr := labels.ParseMatchers(input) if nErr != nil { // If the input is invalid in both parsers, return the error. if cErr != nil { return nil, cErr } // The input is valid in the pkg/labels parser, but not the matcher/parse // parser. This means the input is not forwards compatible. var sb strings.Builder for i, n := range cMatchers { sb.WriteString(n.String()) if i < len(cMatchers)-1 { sb.WriteRune(',') } } suggestion := sb.String() // The input is valid in the pkg/labels parser, but not the // new matcher/parse parser. l.Warn("Alertmanager is moving to a new parser for labels and matchers, and this input is incompatible. Alertmanager has instead parsed the input using the classic matchers parser as a fallback. To make this input compatible with the UTF-8 matchers parser please make sure all regular expressions and values are double-quoted and backslashes are escaped. If you are still seeing this message please open an issue.", "input", input, "origin", origin, "err", nErr, "suggestion", suggestion) return cMatchers, nil } // If the input is valid in both parsers, but produces different results, // then there is disagreement. We need to compare to labels.Matchers(cMatchers) // as cMatchers is a []*labels.Matcher not labels.Matchers. if cErr == nil && !reflect.DeepEqual(nMatchers, labels.Matchers(cMatchers)) { l.Warn("Matchers input has disagreement", "input", input, "origin", origin) return cMatchers, nil } return nMatchers, nil } } // isValidClassicLabelName returns true if the string is a valid classic label name. func isValidClassicLabelName(_ *slog.Logger) func(model.LabelName) bool { return func(name model.LabelName) bool { return model.LegacyValidation.IsValidLabelName(string(name)) } } // isValidUTF8LabelName returns true if the string is a valid UTF-8 label name. func isValidUTF8LabelName(_ *slog.Logger) func(model.LabelName) bool { return func(name model.LabelName) bool { if len(name) == 0 { return false } return utf8.ValidString(string(name)) } } ================================================ FILE: matcher/compat/parse_test.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compat import ( "testing" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/pkg/labels" ) func TestFallbackMatcherParser(t *testing.T) { tests := []struct { name string input string expected *labels.Matcher err string }{{ name: "input is accepted", input: "foo=bar", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "input is accepted in neither", input: "foo!bar", err: "bad matcher format: foo!bar", }, { name: "input is accepted in matchers/parse but not pkg/labels", input: "foo🙂=bar", expected: mustNewMatcher(t, labels.MatchEqual, "foo🙂", "bar"), }, { name: "input is accepted in pkg/labels but not matchers/parse", input: "foo=!bar\\n", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "!bar\n"), }, { // This input causes disagreement because \xf0\x9f\x99\x82 is the byte sequence for 🙂, // which is not understood by pkg/labels but is understood by matchers/parse. In such cases, // the fallback parser returns the result from pkg/labels. name: "input causes disagreement", input: "foo=\"\\xf0\\x9f\\x99\\x82\"", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "\\xf0\\x9f\\x99\\x82"), }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { f := FallbackMatcherParser(promslog.NewNopLogger()) matcher, err := f(test.input, "test") if test.err != "" { require.EqualError(t, err, test.err) } else { require.NoError(t, err) require.Equal(t, test.expected, matcher) } }) } } func TestFallbackMatchersParser(t *testing.T) { tests := []struct { name string input string expected labels.Matchers err string }{{ name: "input is accepted", input: "{foo=bar,bar=baz}", expected: labels.Matchers{ mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), mustNewMatcher(t, labels.MatchEqual, "bar", "baz"), }, }, { name: "input is accepted in neither", input: "{foo!bar}", err: "bad matcher format: foo!bar", }, { name: "input is accepted in matchers/parse but not pkg/labels", input: "{foo🙂=bar,bar=baz🙂}", expected: labels.Matchers{ mustNewMatcher(t, labels.MatchEqual, "foo🙂", "bar"), mustNewMatcher(t, labels.MatchEqual, "bar", "baz🙂"), }, }, { name: "is accepted in pkg/labels but not matchers/parse", input: "{foo=!bar,bar=$baz\\n}", expected: labels.Matchers{ mustNewMatcher(t, labels.MatchEqual, "foo", "!bar"), mustNewMatcher(t, labels.MatchEqual, "bar", "$baz\n"), }, }, { // This input causes disagreement because \xf0\x9f\x99\x82 is the byte sequence for 🙂, // which is not understood by pkg/labels but is understood by matchers/parse. In such cases, // the fallback parser returns the result from pkg/labels. name: "input causes disagreement", input: "{foo=\"\\xf0\\x9f\\x99\\x82\"}", expected: labels.Matchers{ mustNewMatcher(t, labels.MatchEqual, "foo", "\\xf0\\x9f\\x99\\x82"), }, }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { f := FallbackMatchersParser(promslog.NewNopLogger()) matchers, err := f(test.input, "test") if test.err != "" { require.EqualError(t, err, test.err) } else { require.NoError(t, err) require.Equal(t, test.expected, matchers) } }) } } func mustNewMatcher(t *testing.T, op labels.MatchType, name, value string) *labels.Matcher { m, err := labels.NewMatcher(op, name, value) require.NoError(t, err) return m } func TestIsValidClassicLabelName(t *testing.T) { tests := []struct { name string input model.LabelName expected bool }{{ name: "foo is accepted", input: "foo", expected: true, }, { name: "starts with underscore and ends with number is accepted", input: "_foo1", expected: true, }, { name: "empty is not accepted", input: "", expected: false, }, { name: "starts with number is not accepted", input: "0foo", expected: false, }, { name: "contains emoji is not accepted", input: "foo🙂", expected: false, }} for _, test := range tests { fn := isValidClassicLabelName(promslog.NewNopLogger()) t.Run(test.name, func(t *testing.T) { require.Equal(t, test.expected, fn(test.input)) }) } } func TestIsValidUTF8LabelName(t *testing.T) { tests := []struct { name string input model.LabelName expected bool }{{ name: "foo is accepted", input: "foo", expected: true, }, { name: "starts with underscore and ends with number is accepted", input: "_foo1", expected: true, }, { name: "starts with number is accepted", input: "0foo", expected: true, }, { name: "contains emoji is accepted", input: "foo🙂", expected: true, }, { name: "empty is not accepted", input: "", expected: false, }} for _, test := range tests { fn := isValidUTF8LabelName(promslog.NewNopLogger()) t.Run(test.name, func(t *testing.T) { require.Equal(t, test.expected, fn(test.input)) }) } } ================================================ FILE: matcher/compliance/compliance_test.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package compliance import ( "reflect" "testing" "github.com/prometheus/alertmanager/matcher/parse" "github.com/prometheus/alertmanager/pkg/labels" ) func TestCompliance(t *testing.T) { for _, tc := range []struct { input string want labels.Matchers err string skip bool }{ { input: `{}`, want: labels.Matchers{}, skip: true, }, { input: `{foo='}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "'") return append(ms, m) }(), skip: true, }, { input: "{foo=`}", want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "`") return append(ms, m) }(), skip: true, }, { input: `{foo=\n}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "\n") return append(ms, m) }(), skip: true, }, { input: `{foo=bar\n}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar\n") return append(ms, m) }(), skip: true, }, { input: `{foo=\t}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "\\t") return append(ms, m) }(), skip: true, }, { input: `{foo=bar\t}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar\\t") return append(ms, m) }(), skip: true, }, { input: `{foo=bar\}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar\\") return append(ms, m) }(), skip: true, }, { input: `{foo=bar\\}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar\\") return append(ms, m) }(), skip: true, }, { input: `{foo=\"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "\"") return append(ms, m) }(), skip: true, }, { input: `{foo=bar\"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar\"") return append(ms, m) }(), skip: true, }, { input: `{foo=bar}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") return append(ms, m) }(), }, { input: `{foo="bar"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") return append(ms, m) }(), }, { input: `{foo=~bar.*}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchRegexp, "foo", "bar.*") return append(ms, m) }(), }, { input: `{foo=~"bar.*"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchRegexp, "foo", "bar.*") return append(ms, m) }(), }, { input: `{foo!=bar}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchNotEqual, "foo", "bar") return append(ms, m) }(), }, { input: `{foo!="bar"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchNotEqual, "foo", "bar") return append(ms, m) }(), }, { input: `{foo!~bar.*}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchNotRegexp, "foo", "bar.*") return append(ms, m) }(), }, { input: `{foo!~"bar.*"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchNotRegexp, "foo", "bar.*") return append(ms, m) }(), }, { input: `{foo="bar", baz!="quux"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") m2, _ := labels.NewMatcher(labels.MatchNotEqual, "baz", "quux") return append(ms, m, m2) }(), }, { input: `{foo="bar", baz!~"quux.*"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") m2, _ := labels.NewMatcher(labels.MatchNotRegexp, "baz", "quux.*") return append(ms, m, m2) }(), }, { input: `{foo="bar",baz!~".*quux", derp="wat"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") m2, _ := labels.NewMatcher(labels.MatchNotRegexp, "baz", ".*quux") m3, _ := labels.NewMatcher(labels.MatchEqual, "derp", "wat") return append(ms, m, m2, m3) }(), }, { input: `{foo="bar", baz!="quux", derp="wat"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") m2, _ := labels.NewMatcher(labels.MatchNotEqual, "baz", "quux") m3, _ := labels.NewMatcher(labels.MatchEqual, "derp", "wat") return append(ms, m, m2, m3) }(), }, { input: `{foo="bar", baz!~".*quux.*", derp="wat"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") m2, _ := labels.NewMatcher(labels.MatchNotRegexp, "baz", ".*quux.*") m3, _ := labels.NewMatcher(labels.MatchEqual, "derp", "wat") return append(ms, m, m2, m3) }(), }, { input: `{foo="bar", instance=~"some-api.*"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") m2, _ := labels.NewMatcher(labels.MatchRegexp, "instance", "some-api.*") return append(ms, m, m2) }(), }, { input: `{foo=""}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "") return append(ms, m) }(), }, { input: `{foo="bar,quux", job="job1"}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar,quux") m2, _ := labels.NewMatcher(labels.MatchEqual, "job", "job1") return append(ms, m, m2) }(), }, { input: `{foo = "bar", dings != "bums", }`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") m2, _ := labels.NewMatcher(labels.MatchNotEqual, "dings", "bums") return append(ms, m, m2) }(), }, { input: `foo=bar,dings!=bums`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar") m2, _ := labels.NewMatcher(labels.MatchNotEqual, "dings", "bums") return append(ms, m, m2) }(), }, { input: `{quote="She said: \"Hi, ladies! That's gender-neutral…\""}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "quote", `She said: "Hi, ladies! That's gender-neutral…"`) return append(ms, m) }(), }, { input: `statuscode=~"5.."`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchRegexp, "statuscode", "5..") return append(ms, m) }(), }, { input: `tricky=~~~`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchRegexp, "tricky", "~~") return append(ms, m) }(), skip: true, }, { input: `trickier==\\=\=\"`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "trickier", `=\=\="`) return append(ms, m) }(), skip: true, }, { input: `contains_quote != "\"" , contains_comma !~ "foo,bar" , `, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchNotEqual, "contains_quote", `"`) m2, _ := labels.NewMatcher(labels.MatchNotRegexp, "contains_comma", "foo,bar") return append(ms, m, m2) }(), }, { input: `{foo=bar}}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar}") return append(ms, m) }(), skip: true, }, { input: `{foo=bar}},}`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "bar}}") return append(ms, m) }(), skip: true, }, { input: `{foo=,bar=}}`, want: func() labels.Matchers { ms := labels.Matchers{} m1, _ := labels.NewMatcher(labels.MatchEqual, "foo", "") m2, _ := labels.NewMatcher(labels.MatchEqual, "bar", "}") return append(ms, m1, m2) }(), skip: true, }, { input: `job=`, want: func() labels.Matchers { m, _ := labels.NewMatcher(labels.MatchEqual, "job", "") return labels.Matchers{m} }(), skip: true, }, { input: `{name-with-dashes = "bar"}`, want: func() labels.Matchers { m, _ := labels.NewMatcher(labels.MatchEqual, "name-with-dashes", "bar") return labels.Matchers{m} }(), }, { input: `{,}`, err: "bad matcher format: ", }, { input: `job="value`, err: `matcher value contains unescaped double quote: "value`, }, { input: `job=value"`, err: `matcher value contains unescaped double quote: value"`, }, { input: `trickier==\\=\=\""`, err: `matcher value contains unescaped double quote: =\\=\=\""`, }, { input: `contains_unescaped_quote = foo"bar`, err: `matcher value contains unescaped double quote: foo"bar`, }, { input: `{foo=~"invalid[regexp"}`, err: "error parsing regexp: missing closing ]: `[regexp)$`", }, // Double escaped strings. { input: `"{foo=\"bar"}`, err: `bad matcher format: "{foo=\"bar"`, }, { input: `"foo=\"bar"`, err: `bad matcher format: "foo=\"bar"`, }, { input: `"foo=\"bar\""`, err: `bad matcher format: "foo=\"bar\""`, }, { input: `"foo=\"bar\"`, err: `bad matcher format: "foo=\"bar\"`, }, { input: `"{foo=\"bar\"}"`, err: `bad matcher format: "{foo=\"bar\"}"`, }, { input: `"foo="bar""`, err: `bad matcher format: "foo="bar""`, }, { input: `{{foo=`, err: `bad matcher format: {foo=`, }, { input: `{foo=`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "") return append(ms, m) }(), skip: true, }, { input: `{foo=}b`, want: func() labels.Matchers { ms := labels.Matchers{} m, _ := labels.NewMatcher(labels.MatchEqual, "foo", "}b") return append(ms, m) }(), skip: true, }, } { t.Run(tc.input, func(t *testing.T) { if tc.skip { t.Skip() } got, err := parse.Matchers(tc.input) if err != nil && tc.err == "" { t.Fatalf("got error where none expected: %v", err) } if err == nil && tc.err != "" { t.Fatalf("expected error but got none: %v", tc.err) } if !reflect.DeepEqual(got, tc.want) { t.Fatalf("matchers not equal:\ngot %s\nwant %s", got, tc.want) } }) } } ================================================ FILE: matcher/parse/bench_test.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package parse import ( "testing" ) const ( simpleExample = "{foo=\"bar\"}" complexExample = "{foo=\"bar\",bar=~\"[a-zA-Z0-9+]\"}" ) func BenchmarkParseSimple(b *testing.B) { for b.Loop() { if _, err := Matchers(simpleExample); err != nil { b.Fatal(err) } } } func BenchmarkParseComplex(b *testing.B) { for b.Loop() { if _, err := Matchers(complexExample); err != nil { b.Fatal(err) } } } ================================================ FILE: matcher/parse/fuzz_test.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package parse import ( "testing" ) // FuzzParse fuzz tests the parser to see if we can make it panic. func FuzzParse(f *testing.F) { f.Add("{foo=bar,bar=~[a-zA-Z]+,baz!=qux,qux!~[0-9]+") f.Fuzz(func(t *testing.T, s string) { matchers, err := Matchers(s) if matchers != nil && err != nil { t.Errorf("Unexpected matchers and err: %v %s", matchers, err) } }) } ================================================ FILE: matcher/parse/lexer.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package parse import ( "fmt" "strings" "unicode" "unicode/utf8" ) const ( eof rune = -1 ) func isReserved(r rune) bool { return unicode.IsSpace(r) || strings.ContainsRune("{}!=~,\\\"'`", r) } // expectedError is returned when the next rune does not match what is expected. type expectedError struct { position input string expected string } func (e expectedError) Error() string { if e.offsetEnd >= len(e.input) { return fmt.Sprintf("%d:%d: unexpected end of input, expected one of '%s'", e.columnStart, e.columnEnd, e.expected, ) } return fmt.Sprintf("%d:%d: %s: expected one of '%s'", e.columnStart, e.columnEnd, e.input[e.offsetStart:e.offsetEnd], e.expected, ) } // invalidInputError is returned when the next rune in the input does not match // the grammar of Prometheus-like matchers. type invalidInputError struct { position input string } func (e invalidInputError) Error() string { return fmt.Sprintf("%d:%d: %s: invalid input", e.columnStart, e.columnEnd, e.input[e.offsetStart:e.offsetEnd], ) } // unterminatedError is returned when text in quotes does not have a closing quote. type unterminatedError struct { position input string quote rune } func (e unterminatedError) Error() string { return fmt.Sprintf("%d:%d: %s: missing end %c", e.columnStart, e.columnEnd, e.input[e.offsetStart:e.offsetEnd], e.quote, ) } // lexer scans a sequence of tokens that match the grammar of Prometheus-like // matchers. A token is emitted for each call to scan() which returns the // next token in the input or an error if the input does not conform to the // grammar. A token can be one of a number of kinds and corresponds to a // subslice of the input. Once the input has been consumed successive calls to // scan() return a tokenEOF token. type lexer struct { input string err error start int // The offset of the current token. pos int // The position of the cursor in the input. width int // The width of the last rune. column int // The column offset of the current token. cols int // The number of columns (runes) decoded from the input. } // Scans the next token in the input or an error if the input does not // conform to the grammar. Once the input has been consumed successive // calls scan() return a tokenEOF token. func (l *lexer) scan() (token, error) { t := token{} // Do not attempt to emit more tokens if the input is invalid. if l.err != nil { return t, l.err } // Iterate over each rune in the input and either emit a token or an error. for r := l.next(); r != eof; r = l.next() { switch { case r == '{': t = l.emit(tokenOpenBrace) return t, l.err case r == '}': t = l.emit(tokenCloseBrace) return t, l.err case r == ',': t = l.emit(tokenComma) return t, l.err case r == '=' || r == '!': l.rewind() t, l.err = l.scanOperator() return t, l.err case r == '"': l.rewind() t, l.err = l.scanQuoted() return t, l.err case !isReserved(r): l.rewind() t, l.err = l.scanUnquoted() return t, l.err case unicode.IsSpace(r): l.skip() default: l.err = invalidInputError{ position: l.position(), input: l.input, } return t, l.err } } return t, l.err } func (l *lexer) scanOperator() (token, error) { // If the first rune is an '!' then it must be followed with either an // '=' or '~' to not match a string or regex. if l.accept("!") { if l.accept("=") { return l.emit(tokenNotEquals), nil } if l.accept("~") { return l.emit(tokenNotMatches), nil } return token{}, expectedError{ position: l.position(), input: l.input, expected: "=~", } } // If the first rune is an '=' then it can be followed with an optional // '~' to match a regex. if l.accept("=") { if l.accept("~") { return l.emit(tokenMatches), nil } return l.emit(tokenEquals), nil } return token{}, expectedError{ position: l.position(), input: l.input, expected: "!=", } } func (l *lexer) scanQuoted() (token, error) { if err := l.expect("\""); err != nil { return token{}, err } var isEscaped bool for r := l.next(); r != eof; r = l.next() { if isEscaped { isEscaped = false } else if r == '\\' { isEscaped = true } else if r == '"' { l.rewind() break } } if err := l.expect("\""); err != nil { return token{}, unterminatedError{ position: l.position(), input: l.input, quote: '"', } } return l.emit(tokenQuoted), nil } func (l *lexer) scanUnquoted() (token, error) { for r := l.next(); r != eof; r = l.next() { if isReserved(r) { l.rewind() break } } return l.emit(tokenUnquoted), nil } // peek the next token in the input or an error if the input does not // conform to the grammar. Once the input has been consumed successive // calls peek() return a tokenEOF token. func (l *lexer) peek() (token, error) { start := l.start pos := l.pos width := l.width column := l.column cols := l.cols // Do not reset l.err because we can return it on the next call to scan(). defer func() { l.start = start l.pos = pos l.width = width l.column = column l.cols = cols }() return l.scan() } // position returns the position of the last emitted token. func (l *lexer) position() position { return position{ offsetStart: l.start, offsetEnd: l.pos, columnStart: l.column, columnEnd: l.cols, } } // accept consumes the next if its one of the valid runes. // It returns true if the next rune was accepted, otherwise false. func (l *lexer) accept(valid string) bool { if strings.ContainsRune(valid, l.next()) { return true } l.rewind() return false } // expect consumes the next rune if its one of the valid runes. // It returns nil if the next rune is valid, otherwise an expectedError // error. func (l *lexer) expect(valid string) error { if strings.ContainsRune(valid, l.next()) { return nil } l.rewind() return expectedError{ position: l.position(), input: l.input, expected: valid, } } // emits returns the scanned input as a token. func (l *lexer) emit(kind tokenKind) token { t := token{ kind: kind, value: l.input[l.start:l.pos], position: l.position(), } l.start = l.pos l.column = l.cols return t } // next returns the next rune in the input or eof. func (l *lexer) next() rune { if l.pos >= len(l.input) { l.width = 0 return eof } r, width := utf8.DecodeRuneInString(l.input[l.pos:]) l.width = width l.pos += width l.cols++ return r } // rewind the last rune in the input. It should not be called more than once // between consecutive calls of next. func (l *lexer) rewind() { l.pos -= l.width // When the next rune in the input is eof the width is zero. This check // prevents cols from being decremented when the next rune being accepted // is instead eof. if l.width > 0 { l.cols-- } } // skip the scanned input between start and pos. func (l *lexer) skip() { l.start = l.pos l.column = l.cols } ================================================ FILE: matcher/parse/lexer_test.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package parse import ( "testing" "github.com/stretchr/testify/require" ) func TestLexer_Scan(t *testing.T) { tests := []struct { name string input string expected []token err string }{{ name: "no input", input: "", }, { name: "open brace", input: "{", expected: []token{{ kind: tokenOpenBrace, value: "{", position: position{ offsetStart: 0, offsetEnd: 1, columnStart: 0, columnEnd: 1, }, }}, }, { name: "open brace with leading space", input: " {", expected: []token{{ kind: tokenOpenBrace, value: "{", position: position{ offsetStart: 1, offsetEnd: 2, columnStart: 1, columnEnd: 2, }, }}, }, { name: "close brace", input: "}", expected: []token{{ kind: tokenCloseBrace, value: "}", position: position{ offsetStart: 0, offsetEnd: 1, columnStart: 0, columnEnd: 1, }, }}, }, { name: "close brace with leading space", input: " }", expected: []token{{ kind: tokenCloseBrace, value: "}", position: position{ offsetStart: 1, offsetEnd: 2, columnStart: 1, columnEnd: 2, }, }}, }, { name: "open and closing braces", input: "{}", expected: []token{{ kind: tokenOpenBrace, value: "{", position: position{ offsetStart: 0, offsetEnd: 1, columnStart: 0, columnEnd: 1, }, }, { kind: tokenCloseBrace, value: "}", position: position{ offsetStart: 1, offsetEnd: 2, columnStart: 1, columnEnd: 2, }, }}, }, { name: "open and closing braces with space", input: "{ }", expected: []token{{ kind: tokenOpenBrace, value: "{", position: position{ offsetStart: 0, offsetEnd: 1, columnStart: 0, columnEnd: 1, }, }, { kind: tokenCloseBrace, value: "}", position: position{ offsetStart: 2, offsetEnd: 3, columnStart: 2, columnEnd: 3, }, }}, }, { name: "unquoted", input: "hello", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }}, }, { name: "unquoted with underscore", input: "hello_world", expected: []token{{ kind: tokenUnquoted, value: "hello_world", position: position{ offsetStart: 0, offsetEnd: 11, columnStart: 0, columnEnd: 11, }, }}, }, { name: "unquoted with colon", input: "hello:world", expected: []token{{ kind: tokenUnquoted, value: "hello:world", position: position{ offsetStart: 0, offsetEnd: 11, columnStart: 0, columnEnd: 11, }, }}, }, { name: "unquoted with numbers", input: "hello0123456789", expected: []token{{ kind: tokenUnquoted, value: "hello0123456789", position: position{ offsetStart: 0, offsetEnd: 15, columnStart: 0, columnEnd: 15, }, }}, }, { name: "unquoted can start with underscore", input: "_hello", expected: []token{{ kind: tokenUnquoted, value: "_hello", position: position{ offsetStart: 0, offsetEnd: 6, columnStart: 0, columnEnd: 6, }, }}, }, { name: "unquoted separated with space", input: "hello world", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }, { kind: tokenUnquoted, value: "world", position: position{ offsetStart: 6, offsetEnd: 11, columnStart: 6, columnEnd: 11, }, }}, }, { name: "newline before unquoted is skipped", input: "\nhello", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 1, offsetEnd: 6, columnStart: 1, columnEnd: 6, }, }}, }, { name: "newline after unquoted is skipped", input: "hello\n", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }}, }, { name: "carriage return before unquoted is skipped", input: "\rhello", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 1, offsetEnd: 6, columnStart: 1, columnEnd: 6, }, }}, }, { name: "space before unquoted is skipped", input: " hello", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 1, offsetEnd: 6, columnStart: 1, columnEnd: 6, }, }}, }, { name: "space after unquoted is skipped", input: "hello ", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }}, }, { name: "newline between two unquoted is skipped", input: "hello\nworld", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }, { kind: tokenUnquoted, value: "world", position: position{ offsetStart: 6, offsetEnd: 11, columnStart: 6, columnEnd: 11, }, }}, }, { name: "unquoted $", input: "$", expected: []token{{ kind: tokenUnquoted, value: "$", position: position{ offsetStart: 0, offsetEnd: 1, columnStart: 0, columnEnd: 1, }, }}, }, { name: "unquoted emoji", input: "🙂", expected: []token{{ kind: tokenUnquoted, value: "🙂", position: position{ offsetStart: 0, offsetEnd: 4, columnStart: 0, columnEnd: 1, }, }}, }, { name: "unquoted unicode", input: "Σ", expected: []token{{ kind: tokenUnquoted, value: "Σ", position: position{ offsetStart: 0, offsetEnd: 2, columnStart: 0, columnEnd: 1, }, }}, }, { name: "unquoted unicode sentence", input: "hello🙂Σ world", expected: []token{{ kind: tokenUnquoted, value: "hello🙂Σ", position: position{ offsetStart: 0, offsetEnd: 11, columnStart: 0, columnEnd: 7, }, }, { kind: tokenUnquoted, value: "world", position: position{ offsetStart: 12, offsetEnd: 17, columnStart: 8, columnEnd: 13, }, }}, }, { name: "unquoted unicode sentence with unicode space", input: "hello🙂Σ\u202fworld", expected: []token{{ kind: tokenUnquoted, value: "hello🙂Σ", position: position{ offsetStart: 0, offsetEnd: 11, columnStart: 0, columnEnd: 7, }, }, { kind: tokenUnquoted, value: "world", position: position{ offsetStart: 14, offsetEnd: 19, columnStart: 8, columnEnd: 13, }, }}, }, { name: "quoted", input: "\"hello\"", expected: []token{{ kind: tokenQuoted, value: "\"hello\"", position: position{ offsetStart: 0, offsetEnd: 7, columnStart: 0, columnEnd: 7, }, }}, }, { name: "quoted with unicode", input: "\"hello 🙂\"", expected: []token{{ kind: tokenQuoted, value: "\"hello 🙂\"", position: position{ offsetStart: 0, offsetEnd: 12, columnStart: 0, columnEnd: 9, }, }}, }, { name: "quoted with space", input: "\"hello world\"", expected: []token{{ kind: tokenQuoted, value: "\"hello world\"", position: position{ offsetStart: 0, offsetEnd: 13, columnStart: 0, columnEnd: 13, }, }}, }, { name: "quoted with unicode space", input: "\"hello\u202fworld\"", expected: []token{{ kind: tokenQuoted, value: "\"hello\u202fworld\"", position: position{ offsetStart: 0, offsetEnd: 15, columnStart: 0, columnEnd: 13, }, }}, }, { name: "quoted with newline", input: "\"hello\nworld\"", expected: []token{{ kind: tokenQuoted, value: "\"hello\nworld\"", position: position{ offsetStart: 0, offsetEnd: 13, columnStart: 0, columnEnd: 13, }, }}, }, { name: "quoted with tab", input: "\"hello\tworld\"", expected: []token{{ kind: tokenQuoted, value: "\"hello\tworld\"", position: position{ offsetStart: 0, offsetEnd: 13, columnStart: 0, columnEnd: 13, }, }}, }, { name: "quoted with regex digit character class", input: "\"\\d+\"", expected: []token{{ kind: tokenQuoted, value: "\"\\d+\"", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }}, }, { name: "quoted with escaped regex digit character class", input: "\"\\\\d+\"", expected: []token{{ kind: tokenQuoted, value: "\"\\\\d+\"", position: position{ offsetStart: 0, offsetEnd: 6, columnStart: 0, columnEnd: 6, }, }}, }, { name: "quoted with escaped quotes", input: "\"hello \\\"world\\\"\"", expected: []token{{ kind: tokenQuoted, value: "\"hello \\\"world\\\"\"", position: position{ offsetStart: 0, offsetEnd: 17, columnStart: 0, columnEnd: 17, }, }}, }, { name: "quoted with escaped backslash", input: "\"hello world\\\\\"", expected: []token{{ kind: tokenQuoted, value: "\"hello world\\\\\"", position: position{ offsetStart: 0, offsetEnd: 15, columnStart: 0, columnEnd: 15, }, }}, }, { name: "quoted escape sequence", input: "\"\\n\"", expected: []token{{ kind: tokenQuoted, value: "\"\\n\"", position: position{ offsetStart: 0, offsetEnd: 4, columnStart: 0, columnEnd: 4, }, }}, }, { name: "equals operator", input: "=", expected: []token{{ kind: tokenEquals, value: "=", position: position{ offsetStart: 0, offsetEnd: 1, columnStart: 0, columnEnd: 1, }, }}, }, { name: "not equals operator", input: "!=", expected: []token{{ kind: tokenNotEquals, value: "!=", position: position{ offsetStart: 0, offsetEnd: 2, columnStart: 0, columnEnd: 2, }, }}, }, { name: "matches regex operator", input: "=~", expected: []token{{ kind: tokenMatches, value: "=~", position: position{ offsetStart: 0, offsetEnd: 2, columnStart: 0, columnEnd: 2, }, }}, }, { name: "not matches regex operator", input: "!~", expected: []token{{ kind: tokenNotMatches, value: "!~", position: position{ offsetStart: 0, offsetEnd: 2, columnStart: 0, columnEnd: 2, }, }}, }, { name: "invalid operator", input: "!", err: "0:1: unexpected end of input, expected one of '=~'", }, { name: "another invalid operator", input: "~", err: "0:1: ~: invalid input", }, { name: "unexpected ! after operator", input: "=!", expected: []token{{ kind: tokenEquals, value: "=", position: position{ offsetStart: 0, offsetEnd: 1, columnStart: 0, columnEnd: 1, }, }}, err: "1:2: unexpected end of input, expected one of '=~'", }, { name: "unexpected !! after operator", input: "!=!!", expected: []token{{ kind: tokenNotEquals, value: "!=", position: position{ offsetStart: 0, offsetEnd: 2, columnStart: 0, columnEnd: 2, }, }}, err: "2:3: !: expected one of '=~'", }, { name: "unexpected ! after unquoted", input: "hello!", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }}, err: "5:6: unexpected end of input, expected one of '=~'", }, { name: "invalid escape sequence", input: "\\n", err: "0:1: \\: invalid input", }, { name: "invalid escape sequence before unquoted", input: "\\nhello", err: "0:1: \\: invalid input", }, { name: "invalid escape sequence after unquoted", input: "hello\\n", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }}, err: "5:6: \\: invalid input", }, { name: "another invalid escape sequence after unquoted", input: "hello\\r", expected: []token{{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, }}, err: "5:6: \\: invalid input", }, { name: "unterminated quoted", input: "\"hello", err: "0:6: \"hello: missing end \"", }, { name: "unterminated quoted with escaped quote", input: "\"hello\\\"", err: "0:8: \"hello\\\": missing end \"", }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { l := lexer{input: test.input} // scan all expected tokens. for i := 0; i < len(test.expected); i++ { tok, err := l.scan() require.NoError(t, err) require.Equal(t, test.expected[i], tok) } if test.err == "" { // Check there are no more tokens. tok, err := l.scan() require.NoError(t, err) require.Equal(t, token{}, tok) } else { // Check if expected error is returned. tok, err := l.scan() require.Equal(t, token{}, tok) require.EqualError(t, err, test.err) } }) } } // This test asserts that the lexer does not emit more tokens after an // error has occurred. func TestLexer_ScanError(t *testing.T) { l := lexer{input: "\"hello"} for range 10 { tok, err := l.scan() require.Equal(t, token{}, tok) require.EqualError(t, err, "0:6: \"hello: missing end \"") } } func TestLexer_Peek(t *testing.T) { l := lexer{input: "hello world"} expected1 := token{ kind: tokenUnquoted, value: "hello", position: position{ offsetStart: 0, offsetEnd: 5, columnStart: 0, columnEnd: 5, }, } expected2 := token{ kind: tokenUnquoted, value: "world", position: position{ offsetStart: 6, offsetEnd: 11, columnStart: 6, columnEnd: 11, }, } // Check that peek() returns the first token. tok, err := l.peek() require.NoError(t, err) require.Equal(t, expected1, tok) // Check that scan() returns the peeked token. tok, err = l.scan() require.NoError(t, err) require.Equal(t, expected1, tok) // Check that peek() returns the second token until the next scan(). for range 10 { tok, err = l.peek() require.NoError(t, err) require.Equal(t, expected2, tok) } // Check that scan() returns the last token. tok, err = l.scan() require.NoError(t, err) require.Equal(t, expected2, tok) // Should not be able to peek() further tokens. for range 10 { tok, err = l.peek() require.NoError(t, err) require.Equal(t, token{}, tok) } } // This test asserts that the lexer does not emit more tokens after an // error has occurred. func TestLexer_PeekError(t *testing.T) { l := lexer{input: "\"hello"} for range 10 { tok, err := l.peek() require.Equal(t, token{}, tok) require.EqualError(t, err, "0:6: \"hello: missing end \"") } } func TestLexer_Pos(t *testing.T) { l := lexer{input: "hello🙂"} // The start position should be the zero-value. require.Equal(t, position{}, l.position()) _, err := l.scan() require.NoError(t, err) // The position should contain the offset and column of the end. expected := position{ offsetStart: 9, offsetEnd: 9, columnStart: 6, columnEnd: 6, } require.Equal(t, expected, l.position()) // The position should not change once the input has been consumed. tok, err := l.scan() require.NoError(t, err) require.True(t, tok.isEOF()) require.Equal(t, expected, l.position()) } ================================================ FILE: matcher/parse/parse.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package parse import ( "errors" "fmt" "os" "runtime/debug" "github.com/prometheus/alertmanager/pkg/labels" ) var ( errEOF = errors.New("end of input") errExpectedEOF = errors.New("expected end of input") errNoOpenBrace = errors.New("expected opening brace") errNoCloseBrace = errors.New("expected close brace") errNoLabelName = errors.New("expected label name") errNoLabelValue = errors.New("expected label value") errNoOperator = errors.New("expected an operator such as '=', '!=', '=~' or '!~'") errExpectedComma = errors.New("expected a comma") errExpectedCommaOrCloseBrace = errors.New("expected a comma or close brace") errExpectedMatcherOrCloseBrace = errors.New("expected a matcher or close brace after comma") ) // Matchers parses one or more matchers in the input string. It returns an error // if the input is invalid. func Matchers(input string) (matchers labels.Matchers, err error) { defer func() { if r := recover(); r != nil { fmt.Fprintf(os.Stderr, "parser panic: %s, %s", r, debug.Stack()) err = errors.New("parser panic: this should never happen, check stderr for the stack trace") } }() p := parser{lexer: lexer{input: input}} return p.parse() } // Matcher parses the matcher in the input string. It returns an error // if the input is invalid or contains two or more matchers. func Matcher(input string) (*labels.Matcher, error) { m, err := Matchers(input) if err != nil { return nil, err } switch len(m) { case 1: return m[0], nil case 0: return nil, fmt.Errorf("no matchers") default: return nil, fmt.Errorf("expected 1 matcher, found %d", len(m)) } } // parseFunc is state in the finite state automata. type parseFunc func(l *lexer) (parseFunc, error) // parser reads the sequence of tokens from the lexer and returns either a // series of matchers or an error. It works as a finite state automata, where // each state in the automata is a parseFunc. The finite state automata can move // from one state to another by returning the next parseFunc. It terminates when // a parseFunc returns nil as the next parseFunc, if the lexer attempts to scan // input that does not match the expected grammar, or if the tokens returned from // the lexer cannot be parsed into a complete series of matchers. type parser struct { matchers labels.Matchers // Tracks if the input starts with an open brace and if we should expect to // parse a close brace at the end of the input. hasOpenBrace bool lexer lexer } func (p *parser) parse() (labels.Matchers, error) { var ( err error fn = p.parseOpenBrace l = &p.lexer ) for { if fn, err = fn(l); err != nil { return nil, err } else if fn == nil { break } } return p.matchers, nil } func (p *parser) parseOpenBrace(l *lexer) (parseFunc, error) { var ( hasCloseBrace bool err error ) // Can start with an optional open brace. p.hasOpenBrace, err = p.accept(l, tokenOpenBrace) if err != nil { if errors.Is(err, errEOF) { return p.parseEOF, nil } return nil, err } // If the next token is a close brace there are no matchers in the input. hasCloseBrace, err = p.acceptPeek(l, tokenCloseBrace) if err != nil { // If there is no more input after the open brace then parse the close brace // so the error message contains ErrNoCloseBrace. if errors.Is(err, errEOF) { return p.parseCloseBrace, nil } return nil, err } if hasCloseBrace { return p.parseCloseBrace, nil } return p.parseMatcher, nil } func (p *parser) parseCloseBrace(l *lexer) (parseFunc, error) { if p.hasOpenBrace { // If there was an open brace there must be a matching close brace. if _, err := p.expect(l, tokenCloseBrace); err != nil { return nil, fmt.Errorf("0:%d: %w: %w", l.position().columnEnd, err, errNoCloseBrace) } } else { // If there was no open brace there must not be a close brace either. if _, err := p.expect(l, tokenCloseBrace); err == nil { return nil, fmt.Errorf("0:%d: }: %w", l.position().columnEnd, errNoOpenBrace) } } return p.parseEOF, nil } func (p *parser) parseMatcher(l *lexer) (parseFunc, error) { var ( err error t token matchName, matchValue string matchTy labels.MatchType ) // The first token should be the label name. if t, err = p.expect(l, tokenQuoted, tokenUnquoted); err != nil { return nil, fmt.Errorf("%w: %w", err, errNoLabelName) } matchName, err = t.unquote() if err != nil { return nil, fmt.Errorf("%d:%d: %s: invalid input", t.columnStart, t.columnEnd, t.value) } // The next token should be the operator. if t, err = p.expect(l, tokenEquals, tokenNotEquals, tokenMatches, tokenNotMatches); err != nil { return nil, fmt.Errorf("%w: %w", err, errNoOperator) } switch t.kind { case tokenEquals: matchTy = labels.MatchEqual case tokenNotEquals: matchTy = labels.MatchNotEqual case tokenMatches: matchTy = labels.MatchRegexp case tokenNotMatches: matchTy = labels.MatchNotRegexp default: panic(fmt.Sprintf("bad operator %s", t)) } // The next token should be the match value. Like the match name, this too // can be either double-quoted UTF-8 or unquoted UTF-8 without reserved characters. if t, err = p.expect(l, tokenUnquoted, tokenQuoted); err != nil { return nil, fmt.Errorf("%w: %w", err, errNoLabelValue) } matchValue, err = t.unquote() if err != nil { return nil, fmt.Errorf("%d:%d: %s: invalid input", t.columnStart, t.columnEnd, t.value) } m, err := labels.NewMatcher(matchTy, matchName, matchValue) if err != nil { return nil, fmt.Errorf("failed to create matcher: %w", err) } p.matchers = append(p.matchers, m) return p.parseEndOfMatcher, nil } func (p *parser) parseEndOfMatcher(l *lexer) (parseFunc, error) { t, err := p.expectPeek(l, tokenComma, tokenCloseBrace) if err != nil { if errors.Is(err, errEOF) { // If this is the end of input we still need to check if the optional // open brace has a matching close brace. return p.parseCloseBrace, nil } return nil, fmt.Errorf("%w: %w", err, errExpectedCommaOrCloseBrace) } switch t.kind { case tokenComma: return p.parseComma, nil case tokenCloseBrace: return p.parseCloseBrace, nil default: panic(fmt.Sprintf("bad token %s", t)) } } func (p *parser) parseComma(l *lexer) (parseFunc, error) { if _, err := p.expect(l, tokenComma); err != nil { return nil, fmt.Errorf("%w: %w", err, errExpectedComma) } // The token after the comma can be another matcher, a close brace or end of input. t, err := p.expectPeek(l, tokenCloseBrace, tokenUnquoted, tokenQuoted) if err != nil { if errors.Is(err, errEOF) { // If this is the end of input we still need to check if the optional // open brace has a matching close brace. return p.parseCloseBrace, nil } return nil, fmt.Errorf("%w: %w", err, errExpectedMatcherOrCloseBrace) } if t.kind == tokenCloseBrace { return p.parseCloseBrace, nil } return p.parseMatcher, nil } func (p *parser) parseEOF(l *lexer) (parseFunc, error) { t, err := l.scan() if err != nil { return nil, fmt.Errorf("%w: %w", err, errExpectedEOF) } if !t.isEOF() { return nil, fmt.Errorf("%d:%d: %s: %w", t.columnStart, t.columnEnd, t.value, errExpectedEOF) } return nil, nil } // nolint:godot // accept returns true if the next token is one of the specified kinds, // otherwise false. If the token is accepted it is consumed. tokenEOF is // not an accepted kind and instead accept returns ErrEOF if there is no // more input. func (p *parser) accept(l *lexer, kinds ...tokenKind) (ok bool, err error) { ok, err = p.acceptPeek(l, kinds...) if ok { if _, err = l.scan(); err != nil { panic("failed to scan peeked token") } } return ok, err } // nolint:godot // acceptPeek returns true if the next token is one of the specified kinds, // otherwise false. However, unlike accept, acceptPeek does not consume accepted // tokens. tokenEOF is not an accepted kind and instead accept returns ErrEOF // if there is no more input. func (p *parser) acceptPeek(l *lexer, kinds ...tokenKind) (bool, error) { t, err := l.peek() if err != nil { return false, err } if t.isEOF() { return false, errEOF } return t.isOneOf(kinds...), nil } // nolint:godot // expect returns the next token if it is one of the specified kinds, otherwise // it returns an error. If the token is expected it is consumed. tokenEOF is not // an accepted kind and instead expect returns ErrEOF if there is no more input. func (p *parser) expect(l *lexer, kind ...tokenKind) (token, error) { t, err := p.expectPeek(l, kind...) if err != nil { return t, err } if _, err = l.scan(); err != nil { panic("failed to scan peeked token") } return t, nil } // nolint:godot // expect returns the next token if it is one of the specified kinds, otherwise // it returns an error. However, unlike expect, expectPeek does not consume tokens. // tokenEOF is not an accepted kind and instead expect returns ErrEOF if there is no // more input. func (p *parser) expectPeek(l *lexer, kind ...tokenKind) (token, error) { t, err := l.peek() if err != nil { return t, err } if t.isEOF() { return t, errEOF } if !t.isOneOf(kind...) { return t, fmt.Errorf("%d:%d: unexpected %s", t.columnStart, t.columnEnd, t.value) } return t, nil } ================================================ FILE: matcher/parse/parse_test.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package parse import ( "testing" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/pkg/labels" ) func TestMatchers(t *testing.T) { tests := []struct { name string input string expected labels.Matchers error string }{{ name: "no braces", input: "", expected: nil, }, { name: "open and closing braces", input: "{}", expected: nil, }, { name: "equals", input: "{foo=bar}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, }, { name: "equals with trailing comma", input: "{foo=bar,}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, }, { name: "not equals", input: "{foo!=bar}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchNotEqual, "foo", "bar")}, }, { name: "match regex", input: "{foo=~[a-z]+}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchRegexp, "foo", "[a-z]+")}, }, { name: "doesn't match regex", input: "{foo!~[a-z]+}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchNotRegexp, "foo", "[a-z]+")}, }, { name: "equals unicode emoji", input: "{foo=🙂}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "🙂")}, }, { name: "equals unicode sentence", input: "{foo=🙂bar}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "🙂bar")}, }, { name: "equals without braces", input: "foo=bar", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, }, { name: "equals without braces but with trailing comma", input: "foo=bar,", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, }, { name: "not equals without braces", input: "foo!=bar", expected: labels.Matchers{mustNewMatcher(t, labels.MatchNotEqual, "foo", "bar")}, }, { name: "match regex without braces", input: "foo=~[a-z]+", expected: labels.Matchers{mustNewMatcher(t, labels.MatchRegexp, "foo", "[a-z]+")}, }, { name: "doesn't match regex without braces", input: "foo!~[a-z]+", expected: labels.Matchers{mustNewMatcher(t, labels.MatchNotRegexp, "foo", "[a-z]+")}, }, { name: "equals in quotes", input: "{\"foo\"=\"bar\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, }, { name: "equals in quotes and with trailing comma", input: "{\"foo\"=\"bar\",}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, }, { name: "not equals in quotes", input: "{\"foo\"!=\"bar\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchNotEqual, "foo", "bar")}, }, { name: "match regex in quotes", input: "{\"foo\"=~\"[a-z]+\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchRegexp, "foo", "[a-z]+")}, }, { name: "match regex digit in quotes", input: "{\"foo\"=~\"\\\\d+\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchRegexp, "foo", "\\d+")}, }, { name: "doesn't match regex in quotes", input: "{\"foo\"!~\"[a-z]+\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchNotRegexp, "foo", "[a-z]+")}, }, { name: "equals unicode emoji in quotes", input: "{\"foo\"=\"🙂\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "🙂")}, }, { name: "equals unicode emoji as bytes in quotes", input: "{\"foo\"=\"\\xf0\\x9f\\x99\\x82\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "🙂")}, }, { name: "equals unicode emoji as code points in quotes", input: "{\"foo\"=\"\\U0001f642\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "🙂")}, }, { name: "equals unicode sentence in quotes", input: "{\"foo\"=\"🙂bar\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "🙂bar")}, }, { name: "equals with newline in quotes", input: "{\"foo\"=\"bar\\n\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar\n")}, }, { name: "equals with tab in quotes", input: "{\"foo\"=\"bar\\t\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar\t")}, }, { name: "equals with escaped quotes in quotes", input: "{\"foo\"=\"\\\"bar\\\"\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "\"bar\"")}, }, { name: "equals with escaped backslash in quotes", input: "{\"foo\"=\"bar\\\\\"}", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar\\")}, }, { name: "equals without braces in quotes", input: "\"foo\"=\"bar\"", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, }, { name: "equals without braces in quotes with trailing comma", input: "\"foo\"=\"bar\",", expected: labels.Matchers{mustNewMatcher(t, labels.MatchEqual, "foo", "bar")}, }, { name: "complex", input: "{foo=bar,bar!=baz}", expected: labels.Matchers{ mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), mustNewMatcher(t, labels.MatchNotEqual, "bar", "baz"), }, }, { name: "complex in quotes", input: "{foo=\"bar\",bar!=\"baz\"}", expected: labels.Matchers{ mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), mustNewMatcher(t, labels.MatchNotEqual, "bar", "baz"), }, }, { name: "complex without braces", input: "foo=bar,bar!=baz", expected: labels.Matchers{ mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), mustNewMatcher(t, labels.MatchNotEqual, "bar", "baz"), }, }, { name: "complex without braces in quotes", input: "foo=\"bar\",bar!=\"baz\"", expected: labels.Matchers{ mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), mustNewMatcher(t, labels.MatchNotEqual, "bar", "baz"), }, }, { name: "comma", input: ",", error: "0:1: unexpected ,: expected label name", }, { name: "comma in braces", input: "{,}", error: "1:2: unexpected ,: expected label name", }, { name: "open brace", input: "{", error: "0:1: end of input: expected close brace", }, { name: "close brace", input: "}", error: "0:1: }: expected opening brace", }, { name: "no open brace", input: "foo=bar}", error: "0:8: }: expected opening brace", }, { name: "no close brace", input: "{foo=bar", error: "0:8: end of input: expected close brace", }, { name: "invalid input after operator and before quotes", input: "{foo=:\"bar\"}", error: "6:11: unexpected \"bar\": expected a comma or close brace", }, { name: "invalid escape sequence", input: "{foo=\"bar\\w\"}", error: "5:12: \"bar\\w\": invalid input", }, { name: "invalid escape sequence regex digits", input: "{\"foo\"=~\"\\d+\"}", error: "8:13: \"\\d+\": invalid input", }, { name: "no unquoted escape sequences", input: "{foo=bar\\n}", error: "8:9: \\: invalid input: expected a comma or close brace", }, { name: "invalid unicode", input: "{\"foo\"=\"\\xf0\\x9f\"}", error: "7:17: \"\\xf0\\x9f\": invalid input", }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { matchers, err := Matchers(test.input) if test.error != "" { require.EqualError(t, err, test.error) } else { require.NoError(t, err) require.Equal(t, test.expected, matchers) } }) } } func TestMatcher(t *testing.T) { tests := []struct { name string input string expected *labels.Matcher error string }{{ name: "equals", input: "{foo=bar}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "equals with trailing comma", input: "{foo=bar,}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "not equals", input: "{foo!=bar}", expected: mustNewMatcher(t, labels.MatchNotEqual, "foo", "bar"), }, { name: "match regex", input: "{foo=~[a-z]+}", expected: mustNewMatcher(t, labels.MatchRegexp, "foo", "[a-z]+"), }, { name: "doesn't match regex", input: "{foo!~[a-z]+}", expected: mustNewMatcher(t, labels.MatchNotRegexp, "foo", "[a-z]+"), }, { name: "equals unicode emoji", input: "{foo=🙂}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "🙂"), }, { name: "equals unicode emoji as bytes in quotes", input: "{\"foo\"=\"\\xf0\\x9f\\x99\\x82\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "🙂"), }, { name: "equals unicode emoji as code points in quotes", input: "{\"foo\"=\"\\U0001f642\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "🙂"), }, { name: "equals unicode sentence", input: "{foo=🙂bar}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "🙂bar"), }, { name: "equals without braces", input: "foo=bar", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "equals without braces but with trailing comma", input: "foo=bar,", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "not equals without braces", input: "foo!=bar", expected: mustNewMatcher(t, labels.MatchNotEqual, "foo", "bar"), }, { name: "match regex without braces", input: "foo=~[a-z]+", expected: mustNewMatcher(t, labels.MatchRegexp, "foo", "[a-z]+"), }, { name: "doesn't match regex without braces", input: "foo!~[a-z]+", expected: mustNewMatcher(t, labels.MatchNotRegexp, "foo", "[a-z]+"), }, { name: "equals in quotes", input: "{\"foo\"=\"bar\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "equals in quotes and with trailing comma", input: "{\"foo\"=\"bar\",}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "not equals in quotes", input: "{\"foo\"!=\"bar\"}", expected: mustNewMatcher(t, labels.MatchNotEqual, "foo", "bar"), }, { name: "match regex in quotes", input: "{\"foo\"=~\"[a-z]+\"}", expected: mustNewMatcher(t, labels.MatchRegexp, "foo", "[a-z]+"), }, { name: "doesn't match regex in quotes", input: "{\"foo\"!~\"[a-z]+\"}", expected: mustNewMatcher(t, labels.MatchNotRegexp, "foo", "[a-z]+"), }, { name: "equals unicode emoji in quotes", input: "{\"foo\"=\"🙂\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "🙂"), }, { name: "equals unicode sentence in quotes", input: "{\"foo\"=\"🙂bar\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "🙂bar"), }, { name: "equals with newline in quotes", input: "{\"foo\"=\"bar\\n\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar\n"), }, { name: "equals with tab in quotes", input: "{\"foo\"=\"bar\\t\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar\t"), }, { name: "equals with escaped quotes in quotes", input: "{\"foo\"=\"\\\"bar\\\"\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "\"bar\""), }, { name: "equals with escaped backslash in quotes", input: "{\"foo\"=\"bar\\\\\"}", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar\\"), }, { name: "equals without braces in quotes", input: "\"foo\"=\"bar\"", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "equals without braces in quotes with trailing comma", input: "\"foo\"=\"bar\",", expected: mustNewMatcher(t, labels.MatchEqual, "foo", "bar"), }, { name: "no input", error: "no matchers", }, { name: "open and closing braces", input: "{}", error: "no matchers", }, { name: "two or more returns error", input: "foo=bar,bar=baz", error: "expected 1 matcher, found 2", }, { name: "invalid unicode", input: "foo=\"\\xf0\\x9f\"", error: "4:14: \"\\xf0\\x9f\": invalid input", }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { matcher, err := Matcher(test.input) if test.error != "" { require.EqualError(t, err, test.error) } else { require.NoError(t, err) require.Equal(t, test.expected, matcher) } }) } } func mustNewMatcher(t *testing.T, op labels.MatchType, name, value string) *labels.Matcher { m, err := labels.NewMatcher(op, name, value) require.NoError(t, err) return m } ================================================ FILE: matcher/parse/token.go ================================================ // Copyright 2023 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package parse import ( "errors" "fmt" "slices" "strconv" "unicode/utf8" ) type tokenKind int const ( tokenEOF tokenKind = iota tokenOpenBrace tokenCloseBrace tokenComma tokenEquals tokenNotEquals tokenMatches tokenNotMatches tokenQuoted tokenUnquoted ) func (k tokenKind) String() string { switch k { case tokenOpenBrace: return "OpenBrace" case tokenCloseBrace: return "CloseBrace" case tokenComma: return "Comma" case tokenEquals: return "Equals" case tokenNotEquals: return "NotEquals" case tokenMatches: return "Matches" case tokenNotMatches: return "NotMatches" case tokenQuoted: return "Quoted" case tokenUnquoted: return "Unquoted" default: return "EOF" } } type token struct { kind tokenKind value string position } // isEOF returns true if the token is an end of file token. func (t token) isEOF() bool { return t.kind == tokenEOF } // isOneOf returns true if the token is one of the specified kinds. func (t token) isOneOf(kinds ...tokenKind) bool { return slices.Contains(kinds, t.kind) } // unquote the value in token. If unquoted returns it unmodified. func (t token) unquote() (string, error) { if t.kind == tokenQuoted { unquoted, err := strconv.Unquote(t.value) if err != nil { return "", err } if !utf8.ValidString(unquoted) { return "", errors.New("quoted string contains invalid UTF-8 code points") } return unquoted, nil } return t.value, nil } func (t token) String() string { return fmt.Sprintf("(%s) '%s'", t.kind, t.value) } type position struct { offsetStart int // The start position in the input. offsetEnd int // The end position in the input. columnStart int // The column number. columnEnd int // The end of the column. } ================================================ FILE: nflog/nflog.go ================================================ // Copyright 2016 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package nflog implements a garbage-collected and snapshottable append-only log of // active/resolved notifications. Each log entry stores the active/resolved state, // the notified receiver, and a hash digest of the notification's identifying contents. // The log can be queried along different parameters. package nflog import ( "bufio" "bytes" "errors" "fmt" "io" "log/slog" "maps" "math/rand" "os" "sync" "time" "github.com/coder/quartz" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/common/promslog" "google.golang.org/protobuf/encoding/protodelim" "google.golang.org/protobuf/types/known/timestamppb" "github.com/prometheus/alertmanager/cluster" pb "github.com/prometheus/alertmanager/nflog/nflogpb" ) // ErrNotFound is returned for empty query results. var ErrNotFound = errors.New("not found") // ErrInvalidState is returned if the state isn't valid. var ErrInvalidState = errors.New("invalid state") // query currently allows filtering by and/or receiver group key. // It is configured via QueryParameter functions. // // TODO(fabxc): Future versions could allow querying a certain receiver, // group or a given time interval. type query struct { recv *pb.Receiver groupKey string } // QueryParam is a function that modifies a query to incorporate // a set of parameters. Returns an error for invalid or conflicting // parameters. type QueryParam func(*query) error // QReceiver adds a receiver parameter to a query. func QReceiver(r *pb.Receiver) QueryParam { return func(q *query) error { q.recv = r return nil } } // QGroupKey adds a group key as querying argument. func QGroupKey(gk string) QueryParam { return func(q *query) error { q.groupKey = gk return nil } } // Store abstracts the NFLog's receiver data storage as a mutable key/value store. A store // can be generated from a nflogpb.Entry and then written via the call to Log. // // Every key in the Store is associated with either an int, float, or string value. type Store struct { data map[string]*pb.ReceiverDataValue } // NewStore creates a Store from the entry's receiver data. If entry is nil, the resulting // Store is empty. func NewStore(entry *pb.Entry) *Store { var receiverData map[string]*pb.ReceiverDataValue if entry != nil { receiverData = maps.Clone(entry.ReceiverData) } if receiverData == nil { receiverData = make(map[string]*pb.ReceiverDataValue) } return &Store{ data: receiverData, } } // GetInt finds the integer value associated with the key, if any, and returns it. func (s *Store) GetInt(key string) (int64, bool) { dataValue, ok := s.data[key] if !ok { return 0, false } intVal, ok := dataValue.Value.(*pb.ReceiverDataValue_IntVal) if !ok { return 0, false } return intVal.IntVal, true } // GetFloat finds the float value associated with the key, if any, and returns it. func (s *Store) GetFloat(key string) (float64, bool) { dataValue, ok := s.data[key] if !ok { return 0, false } floatVal, ok := dataValue.Value.(*pb.ReceiverDataValue_DoubleVal) if !ok { return 0, false } return floatVal.DoubleVal, true } // GetFloat finds the string value associated with the key, if any, and returns it. func (s *Store) GetStr(key string) (string, bool) { dataValue, ok := s.data[key] if !ok { return "", false } strVal, ok := dataValue.Value.(*pb.ReceiverDataValue_StrVal) if !ok { return "", false } return strVal.StrVal, true } // SetInt associates an integer value with the provided key, overwriting any existing value. func (s *Store) SetInt(key string, v int64) { s.data[key] = &pb.ReceiverDataValue{ Value: &pb.ReceiverDataValue_IntVal{ IntVal: v, }, } } // SetFloat associates a float value with the provided key, overwriting any existing value. func (s *Store) SetFloat(key string, v float64) { s.data[key] = &pb.ReceiverDataValue{ Value: &pb.ReceiverDataValue_DoubleVal{ DoubleVal: v, }, } } // SetStr associates a string value with the provided key, overwriting any existing value. func (s *Store) SetStr(key, v string) { s.data[key] = &pb.ReceiverDataValue{ Value: &pb.ReceiverDataValue_StrVal{ StrVal: v, }, } } // Delete deletes any value associated with the key. func (s *Store) Delete(key string) { delete(s.data, key) } // Log holds the notification log state for alerts that have been notified. type Log struct { clock quartz.Clock logger *slog.Logger metrics *metrics retention time.Duration // For now we only store the most recently added log entry. // The key is a serialized concatenation of group key and receiver. mtx sync.RWMutex st state broadcast func([]byte) } // MaintenanceFunc represents the function to run as part of the periodic maintenance for the nflog. // It returns the size of the snapshot taken or an error if it failed. type MaintenanceFunc func() (int64, error) type metrics struct { gcDuration prometheus.Summary snapshotDuration prometheus.Summary snapshotSize prometheus.Gauge queriesTotal prometheus.Counter queryErrorsTotal prometheus.Counter queryDuration prometheus.Histogram propagatedMessagesTotal prometheus.Counter maintenanceTotal prometheus.Counter maintenanceErrorsTotal prometheus.Counter } func newMetrics(r prometheus.Registerer) *metrics { m := &metrics{} m.gcDuration = promauto.With(r).NewSummary(prometheus.SummaryOpts{ Name: "alertmanager_nflog_gc_duration_seconds", Help: "Duration of the last notification log garbage collection cycle.", Objectives: map[float64]float64{}, }) m.snapshotDuration = promauto.With(r).NewSummary(prometheus.SummaryOpts{ Name: "alertmanager_nflog_snapshot_duration_seconds", Help: "Duration of the last notification log snapshot.", Objectives: map[float64]float64{}, }) m.snapshotSize = promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_nflog_snapshot_size_bytes", Help: "Size of the last notification log snapshot in bytes.", }) m.maintenanceTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_nflog_maintenance_total", Help: "How many maintenances were executed for the notification log.", }) m.maintenanceErrorsTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_nflog_maintenance_errors_total", Help: "How many maintenances were executed for the notification log that failed.", }) m.queriesTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_nflog_queries_total", Help: "Number of notification log queries were received.", }) m.queryErrorsTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_nflog_query_errors_total", Help: "Number notification log received queries that failed.", }) m.queryDuration = promauto.With(r).NewHistogram(prometheus.HistogramOpts{ Name: "alertmanager_nflog_query_duration_seconds", Help: "Duration of notification log query evaluation.", Buckets: prometheus.DefBuckets, NativeHistogramBucketFactor: 1.1, NativeHistogramMaxBucketNumber: 100, NativeHistogramMinResetDuration: 1 * time.Hour, }) m.propagatedMessagesTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_nflog_gossip_messages_propagated_total", Help: "Number of received gossip messages that have been further gossiped.", }) return m } type state map[string]*pb.MeshEntry func (s state) clone() state { c := make(state, len(s)) maps.Copy(c, s) return c } // merge returns true or false whether the MeshEntry was merged or // not. This information is used to decide to gossip the message further. func (s state) merge(e *pb.MeshEntry, now time.Time) bool { if e.ExpiresAt.AsTime().Before(now) { return false } k := stateKey(string(e.Entry.GroupKey), e.Entry.Receiver) prev, ok := s[k] if !ok || prev.Entry.Timestamp.AsTime().Before(e.Entry.Timestamp.AsTime()) { s[k] = e return true } return false } func (s state) MarshalBinary() ([]byte, error) { var buf bytes.Buffer for _, e := range s { if _, err := protodelim.MarshalTo(&buf, e); err != nil { return nil, err } } return buf.Bytes(), nil } func decodeState(r io.Reader) (state, error) { st := state{} br := bufio.NewReader(r) for { var e pb.MeshEntry err := protodelim.UnmarshalFrom(br, &e) if err == nil { if e.Entry == nil || e.Entry.Receiver == nil { return nil, ErrInvalidState } st[stateKey(string(e.Entry.GroupKey), e.Entry.Receiver)] = &e continue } if errors.Is(err, io.EOF) { break } return nil, err } return st, nil } func marshalMeshEntry(e *pb.MeshEntry) ([]byte, error) { var buf bytes.Buffer if _, err := protodelim.MarshalTo(&buf, e); err != nil { return nil, err } return buf.Bytes(), nil } // Options configures a new Log implementation. type Options struct { SnapshotReader io.Reader SnapshotFile string Retention time.Duration Logger *slog.Logger Metrics prometheus.Registerer } func (o *Options) validate() error { if o.SnapshotFile != "" && o.SnapshotReader != nil { return errors.New("only one of SnapshotFile and SnapshotReader must be set") } if o.Metrics == nil { return errors.New("missing prometheus.Registerer") } return nil } // New creates a new notification log based on the provided options. // The snapshot is loaded into the Log if it is set. func New(o Options) (*Log, error) { if err := o.validate(); err != nil { return nil, err } l := &Log{ clock: quartz.NewReal(), retention: o.Retention, logger: promslog.NewNopLogger(), st: state{}, broadcast: func([]byte) {}, metrics: newMetrics(o.Metrics), } if o.Logger != nil { l.logger = o.Logger } if o.SnapshotFile != "" { if r, err := os.Open(o.SnapshotFile); err != nil { if !os.IsNotExist(err) { return nil, err } l.logger.Debug("notification log snapshot file doesn't exist", "err", err) } else { o.SnapshotReader = r defer r.Close() } } if o.SnapshotReader != nil { if err := l.loadSnapshot(o.SnapshotReader); err != nil { return l, err } } return l, nil } func (l *Log) now() time.Time { return l.clock.Now() } // Maintenance garbage collects the notification log state at the given interval. If the snapshot // file is set, a snapshot is written to it afterwards. // Terminates on receiving from stopc. // If not nil, the last argument is an override for what to do as part of the maintenance - for advanced usage. func (l *Log) Maintenance(interval time.Duration, snapf string, stopc <-chan struct{}, override MaintenanceFunc) { if interval == 0 || stopc == nil { l.logger.Error("interval or stop signal are missing - not running maintenance") return } t := l.clock.NewTicker(interval) defer t.Stop() var doMaintenance MaintenanceFunc doMaintenance = func() (int64, error) { var size int64 if _, err := l.GC(); err != nil { return size, err } if snapf == "" { return size, nil } f, err := openReplace(snapf) if err != nil { return size, err } if size, err = l.Snapshot(f); err != nil { f.Close() return size, err } return size, f.Close() } if override != nil { doMaintenance = override } runMaintenance := func(do func() (int64, error)) error { l.metrics.maintenanceTotal.Inc() start := l.now().UTC() l.logger.Debug("Running maintenance") size, err := do() l.metrics.snapshotSize.Set(float64(size)) if err != nil { l.metrics.maintenanceErrorsTotal.Inc() return err } l.logger.Debug("Maintenance done", "duration", l.now().Sub(start), "size", size) return nil } Loop: for { select { case <-stopc: break Loop case <-t.C: if err := runMaintenance(doMaintenance); err != nil { l.logger.Error("Running maintenance failed", "err", err) } } } // No need to run final maintenance if we don't want to snapshot. if snapf == "" { return } if err := runMaintenance(doMaintenance); err != nil { l.logger.Error("Creating shutdown snapshot failed", "err", err) } } func receiverKey(r *pb.Receiver) string { return fmt.Sprintf("%s/%s/%d", r.GroupName, r.Integration, r.Idx) } // stateKey returns a string key for a log entry consisting of the group key // and receiver. func stateKey(k string, r *pb.Receiver) string { return fmt.Sprintf("%s:%s", k, receiverKey(r)) } func (l *Log) Log(r *pb.Receiver, gkey string, firingAlerts, resolvedAlerts []uint64, store *Store, expiry time.Duration) error { // Write all st with the same timestamp. now := l.now() key := stateKey(gkey, r) l.mtx.Lock() defer l.mtx.Unlock() if prevle, ok := l.st[key]; ok { // Entry already exists, only overwrite if timestamp is newer. // This may happen with raciness or clock-drift across AM nodes. if prevle.Entry.Timestamp.AsTime().After(now) { return nil } } expiresAt := now.Add(l.retention) if expiry > 0 && l.retention > expiry { expiresAt = now.Add(expiry) } var receiverData map[string]*pb.ReceiverDataValue if store != nil { receiverData = store.data } e := &pb.MeshEntry{ Entry: &pb.Entry{ Receiver: r, GroupKey: []byte(gkey), Timestamp: timestamppb.New(now), FiringAlerts: firingAlerts, ResolvedAlerts: resolvedAlerts, ReceiverData: receiverData, }, ExpiresAt: timestamppb.New(expiresAt), } b, err := marshalMeshEntry(e) if err != nil { return err } l.st.merge(e, l.now()) l.broadcast(b) return nil } // GC implements the Log interface. func (l *Log) GC() (int, error) { start := time.Now() defer func() { l.metrics.gcDuration.Observe(time.Since(start).Seconds()) }() now := l.now() var n int l.mtx.Lock() defer l.mtx.Unlock() for k, le := range l.st { if le.ExpiresAt.AsTime().IsZero() { return n, errors.New("unexpected zero expiration timestamp") } if !le.ExpiresAt.AsTime().After(now) { delete(l.st, k) n++ } } return n, nil } // Query implements the Log interface. func (l *Log) Query(params ...QueryParam) ([]*pb.Entry, error) { start := time.Now() l.metrics.queriesTotal.Inc() entries, err := func() ([]*pb.Entry, error) { q := &query{} for _, p := range params { if err := p(q); err != nil { return nil, err } } // TODO(fabxc): For now our only query mode is the most recent entry for a // receiver/group_key combination. if q.recv == nil || q.groupKey == "" { // TODO(fabxc): allow more complex queries in the future. // How to enable pagination? return nil, errors.New("no query parameters specified") } l.mtx.RLock() defer l.mtx.RUnlock() if le, ok := l.st[stateKey(q.groupKey, q.recv)]; ok { return []*pb.Entry{le.Entry}, nil } return nil, ErrNotFound }() if err != nil { l.metrics.queryErrorsTotal.Inc() } l.metrics.queryDuration.Observe(time.Since(start).Seconds()) return entries, err } // loadSnapshot loads a snapshot generated by Snapshot() into the state. func (l *Log) loadSnapshot(r io.Reader) error { st, err := decodeState(r) if err != nil { return err } l.mtx.Lock() l.st = st l.mtx.Unlock() return nil } // Snapshot implements the Log interface. func (l *Log) Snapshot(w io.Writer) (int64, error) { start := time.Now() defer func() { l.metrics.snapshotDuration.Observe(time.Since(start).Seconds()) }() l.mtx.RLock() defer l.mtx.RUnlock() b, err := l.st.MarshalBinary() if err != nil { return 0, err } return io.Copy(w, bytes.NewReader(b)) } // MarshalBinary serializes all contents of the notification log. func (l *Log) MarshalBinary() ([]byte, error) { l.mtx.Lock() defer l.mtx.Unlock() return l.st.MarshalBinary() } // Merge merges notification log state received from the cluster with the local state. func (l *Log) Merge(b []byte) error { st, err := decodeState(bytes.NewReader(b)) if err != nil { return err } l.mtx.Lock() defer l.mtx.Unlock() now := l.now() for _, e := range st { if merged := l.st.merge(e, now); merged && !cluster.OversizedMessage(b) { // If this is the first we've seen the message and it's // not oversized, gossip it to other nodes. We don't // propagate oversized messages because they're sent to // all nodes already. l.broadcast(b) l.metrics.propagatedMessagesTotal.Inc() l.logger.Debug("gossiping new entry", "entry", e) } } return nil } // SetBroadcast sets a broadcast callback that will be invoked with serialized state // on updates. func (l *Log) SetBroadcast(f func([]byte)) { l.mtx.Lock() l.broadcast = f l.mtx.Unlock() } // replaceFile wraps a file that is moved to another filename on closing. type replaceFile struct { *os.File filename string } func (f *replaceFile) Close() error { if err := f.Sync(); err != nil { return err } if err := f.File.Close(); err != nil { return err } return os.Rename(f.Name(), f.filename) } // openReplace opens a new temporary file that is moved to filename on closing. func openReplace(filename string) (*replaceFile, error) { tmpFilename := fmt.Sprintf("%s.%x", filename, uint64(rand.Int63())) f, err := os.Create(tmpFilename) if err != nil { return nil, err } rf := &replaceFile{ File: f, filename: filename, } return rf, nil } ================================================ FILE: nflog/nflog_test.go ================================================ // Copyright 2016 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nflog import ( "bytes" "io" "os" "path/filepath" "sync" "sync/atomic" "testing" "time" pb "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/coder/quartz" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) func TestLogGC(t *testing.T) { mockClock := quartz.NewMock(t) now := mockClock.Now() // We only care about key names and expiration timestamps. newEntry := func(ts time.Time) *pb.MeshEntry { return &pb.MeshEntry{ ExpiresAt: timestamppb.New(ts), } } l := &Log{ st: state{ "a1": newEntry(now), "a2": newEntry(now.Add(time.Second)), "a3": newEntry(now.Add(-time.Second)), }, clock: mockClock, metrics: newMetrics(prometheus.NewRegistry()), } n, err := l.GC() require.NoError(t, err, "unexpected error in garbage collection") require.Equal(t, 2, n, "unexpected number of removed entries") expected := state{ "a2": newEntry(now.Add(time.Second)), } require.Equal(t, expected, l.st, "unexpected state after garbage collection") } func TestLogSnapshot(t *testing.T) { // Check whether storing and loading the snapshot is symmetric. mockClock := quartz.NewMock(t) now := mockClock.Now().UTC() cases := []struct { entries []*pb.MeshEntry }{ { entries: []*pb.MeshEntry{ { Entry: &pb.Entry{ GroupKey: []byte("d8e8fca2dc0f896fd7cb4cb0031ba249"), Receiver: &pb.Receiver{GroupName: "abc", Integration: "test1", Idx: 1}, GroupHash: []byte("126a8a51b9d1bbd07fddc65819a542c3"), Resolved: false, Timestamp: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, { Entry: &pb.Entry{ GroupKey: []byte("d8e8fca2dc0f8abce7cb4cb0031ba249"), Receiver: &pb.Receiver{GroupName: "def", Integration: "test2", Idx: 29}, GroupHash: []byte("122c2331b9d1bbd07fddc65819a542c3"), Resolved: true, Timestamp: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, { Entry: &pb.Entry{ GroupKey: []byte("aaaaaca2dc0f896fd7cb4cb0031ba249"), Receiver: &pb.Receiver{GroupName: "ghi", Integration: "test3", Idx: 0}, GroupHash: []byte("126a8a51b9d1bbd07fddc6e3e3e542c3"), Resolved: false, Timestamp: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, }, }, } for _, c := range cases { f, err := os.CreateTemp(t.TempDir(), "snapshot") require.NoError(t, err, "creating temp file failed") l1 := &Log{ st: state{}, metrics: newMetrics(nil), } // Setup internal state manually. for _, e := range c.entries { l1.st[stateKey(string(e.Entry.GroupKey), e.Entry.Receiver)] = e } _, err = l1.Snapshot(f) require.NoError(t, err, "creating snapshot failed") require.NoError(t, f.Close(), "closing snapshot file failed") f, err = os.Open(f.Name()) require.NoError(t, err, "opening snapshot file failed") // Check again against new nlog instance. l2 := &Log{} err = l2.loadSnapshot(f) require.NoError(t, err, "error loading snapshot") for id, expected := range l1.st { actual, ok := l2.st[id] require.True(t, ok, "silence %s missing from decoded state", id) require.True(t, proto.Equal(expected, actual), "silence %s mismatch after decoding", id) } require.NoError(t, f.Close(), "closing snapshot file failed") } } func TestWithMaintenance_SupportsCustomCallback(t *testing.T) { f, err := os.CreateTemp(t.TempDir(), "snapshot") require.NoError(t, err, "creating temp file failed") stopc := make(chan struct{}) reg := prometheus.NewPedanticRegistry() opts := Options{ Metrics: reg, SnapshotFile: f.Name(), } l, err := New(opts) clock := quartz.NewMock(t) l.clock = clock require.NoError(t, err) var calls atomic.Int32 var wg sync.WaitGroup wg.Go(func() { l.Maintenance(100*time.Millisecond, f.Name(), stopc, func() (int64, error) { calls.Add(1) return 0, nil }) }) gosched() // Before the first tick, no maintenance executed. clock.Advance(99 * time.Millisecond) require.EqualValues(t, 0, calls.Load()) // Tick once. clock.Advance(1 * time.Millisecond) require.Eventually(t, func() bool { return calls.Load() == 1 }, 5*time.Second, time.Second) // Stop the maintenance loop. We should get exactly one more execution of the maintenance func. close(stopc) wg.Wait() require.EqualValues(t, 2, calls.Load()) // Check the maintenance metrics. require.NoError(t, testutil.GatherAndCompare(reg, bytes.NewBufferString(` # HELP alertmanager_nflog_maintenance_errors_total How many maintenances were executed for the notification log that failed. # TYPE alertmanager_nflog_maintenance_errors_total counter alertmanager_nflog_maintenance_errors_total 0 # HELP alertmanager_nflog_maintenance_total How many maintenances were executed for the notification log. # TYPE alertmanager_nflog_maintenance_total counter alertmanager_nflog_maintenance_total 2 `), "alertmanager_nflog_maintenance_total", "alertmanager_nflog_maintenance_errors_total")) } func TestReplaceFile(t *testing.T) { dir, err := os.MkdirTemp("", "replace_file") require.NoError(t, err, "creating temp dir failed") origFilename := filepath.Join(dir, "testfile") of, err := os.Create(origFilename) require.NoError(t, err, "creating file failed") nf, err := openReplace(origFilename) require.NoError(t, err, "opening replacement file failed") _, err = nf.Write([]byte("test")) require.NoError(t, err, "writing replace file failed") require.NotEqual(t, nf.Name(), of.Name(), "replacement file must have different name while editing") require.NoError(t, nf.Close(), "closing replacement file failed") require.NoError(t, of.Close(), "closing original file failed") ofr, err := os.Open(origFilename) require.NoError(t, err, "opening original file failed") defer ofr.Close() res, err := io.ReadAll(ofr) require.NoError(t, err, "reading original file failed") require.Equal(t, "test", string(res), "unexpected file contents") } func TestStateMerge(t *testing.T) { mockClock := quartz.NewMock(t) now := mockClock.Now() // We only care about key names and timestamps for the // merging logic. newEntry := func(name string, ts, exp time.Time) *pb.MeshEntry { return &pb.MeshEntry{ Entry: &pb.Entry{ Timestamp: timestamppb.New(ts), GroupKey: []byte("key"), Receiver: &pb.Receiver{ GroupName: name, Idx: 1, Integration: "integr", }, }, ExpiresAt: timestamppb.New(exp), } } exp := now.Add(time.Minute) cases := []struct { a, b state final state }{ { a: state{ "key:a1/integr/1": newEntry("a1", now, exp), "key:a2/integr/1": newEntry("a2", now, exp), "key:a3/integr/1": newEntry("a3", now, exp), }, b: state{ "key:b1/integr/1": newEntry("b1", now, exp), // new key, should be added "key:b2/integr/1": newEntry("b2", now.Add(-time.Minute), now.Add(-time.Millisecond)), // new key, expired, should not be added "key:a2/integr/1": newEntry("a2", now.Add(-time.Minute), exp), // older timestamp, should be dropped "key:a3/integr/1": newEntry("a3", now.Add(time.Minute), exp), // newer timestamp, should overwrite }, final: state{ "key:a1/integr/1": newEntry("a1", now, exp), "key:a2/integr/1": newEntry("a2", now, exp), "key:a3/integr/1": newEntry("a3", now.Add(time.Minute), exp), "key:b1/integr/1": newEntry("b1", now, exp), }, }, } for _, c := range cases { ca, cb := c.a.clone(), c.b.clone() res := c.a.clone() for _, e := range cb { res.merge(e, now) } require.Equal(t, c.final, res, "Merge result should match expectation") require.Equal(t, c.b, cb, "Merged state should remain unmodified") require.NotEqual(t, c.final, ca, "Merge should not change original state") } } func TestStateDataCoding(t *testing.T) { // Check whether encoding and decoding the data is symmetric. mockClock := quartz.NewMock(t) now := mockClock.Now().UTC() cases := []struct { entries []*pb.MeshEntry }{ { entries: []*pb.MeshEntry{ { Entry: &pb.Entry{ GroupKey: []byte("d8e8fca2dc0f896fd7cb4cb0031ba249"), Receiver: &pb.Receiver{GroupName: "abc", Integration: "test1", Idx: 1}, GroupHash: []byte("126a8a51b9d1bbd07fddc65819a542c3"), Resolved: false, Timestamp: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, { Entry: &pb.Entry{ GroupKey: []byte("d8e8fca2dc0f8abce7cb4cb0031ba249"), Receiver: &pb.Receiver{GroupName: "def", Integration: "test2", Idx: 29}, GroupHash: []byte("122c2331b9d1bbd07fddc65819a542c3"), Resolved: true, Timestamp: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, { Entry: &pb.Entry{ GroupKey: []byte("aaaaaca2dc0f896fd7cb4cb0031ba249"), Receiver: &pb.Receiver{GroupName: "ghi", Integration: "test3", Idx: 0}, GroupHash: []byte("126a8a51b9d1bbd07fddc6e3e3e542c3"), Resolved: false, Timestamp: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, }, }, } for _, c := range cases { // Create gossip data from input. in := state{} for _, e := range c.entries { in[stateKey(string(e.Entry.GroupKey), e.Entry.Receiver)] = e } msg, err := in.MarshalBinary() require.NoError(t, err) out, err := decodeState(bytes.NewReader(msg)) require.NoError(t, err, "decoding message failed") for id, expected := range in { actual, ok := out[id] require.True(t, ok, "silence %s missing from decoded state", id) require.True(t, proto.Equal(expected, actual), "silence %s mismatch after decoding", id) } } } func TestQuery(t *testing.T) { opts := Options{Metrics: prometheus.NewRegistry(), Retention: time.Second} nl, err := New(opts) if err != nil { require.NoError(t, err, "constructing nflog failed") } recv := new(pb.Receiver) // no key param _, err = nl.Query(QGroupKey("key")) require.EqualError(t, err, "no query parameters specified") // no recv param _, err = nl.Query(QReceiver(recv)) require.EqualError(t, err, "no query parameters specified") // no entry _, err = nl.Query(QGroupKey("nonexistentkey"), QReceiver(recv)) require.EqualError(t, err, "not found") // existing entry firingAlerts := []uint64{1, 2, 3} resolvedAlerts := []uint64{4, 5} err = nl.Log(recv, "key", firingAlerts, resolvedAlerts, nil, 0) require.NoError(t, err, "logging notification failed") entries, err := nl.Query(QGroupKey("key"), QReceiver(recv)) require.NoError(t, err, "querying nflog failed") entry := entries[0] require.Equal(t, firingAlerts, entry.FiringAlerts) require.Equal(t, resolvedAlerts, entry.ResolvedAlerts) } func TestStateDecodingError(t *testing.T) { // Check whether decoding copes with erroneous data. s := state{"": &pb.MeshEntry{}} msg, err := s.MarshalBinary() require.NoError(t, err) _, err = decodeState(bytes.NewReader(msg)) require.Equal(t, ErrInvalidState, err) } // runtime.Gosched() does not "suspend" the current goroutine so there's no guarantee that the main goroutine won't // be able to continue. For more see https://pkg.go.dev/runtime#Gosched. func gosched() { time.Sleep(1 * time.Millisecond) } ================================================ FILE: nflog/nflogpb/nflog.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: nflog.proto package nflogpb import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Receiver struct { state protoimpl.MessageState `protogen:"open.v1"` // Configured name of the receiver group. GroupName string `protobuf:"bytes,1,opt,name=group_name,json=groupName,proto3" json:"group_name,omitempty"` // Name of the integration of the receiver. Integration string `protobuf:"bytes,2,opt,name=integration,proto3" json:"integration,omitempty"` // Index of the receiver with respect to the integration. // Every integration in a group may have 0..N configurations. Idx uint32 `protobuf:"varint,3,opt,name=idx,proto3" json:"idx,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Receiver) Reset() { *x = Receiver{} mi := &file_nflog_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Receiver) String() string { return protoimpl.X.MessageStringOf(x) } func (*Receiver) ProtoMessage() {} func (x *Receiver) ProtoReflect() protoreflect.Message { mi := &file_nflog_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Receiver.ProtoReflect.Descriptor instead. func (*Receiver) Descriptor() ([]byte, []int) { return file_nflog_proto_rawDescGZIP(), []int{0} } func (x *Receiver) GetGroupName() string { if x != nil { return x.GroupName } return "" } func (x *Receiver) GetIntegration() string { if x != nil { return x.Integration } return "" } func (x *Receiver) GetIdx() uint32 { if x != nil { return x.Idx } return 0 } // Entry holds information about a successful notification // sent to a receiver. type Entry struct { state protoimpl.MessageState `protogen:"open.v1"` // The key identifying the dispatching group. GroupKey []byte `protobuf:"bytes,1,opt,name=group_key,json=groupKey,proto3" json:"group_key,omitempty"` // The receiver that was notified. Receiver *Receiver `protobuf:"bytes,2,opt,name=receiver,proto3" json:"receiver,omitempty"` // Hash over the state of the group at notification time. // Deprecated in favor of FiringAlerts field, but kept for compatibility. GroupHash []byte `protobuf:"bytes,3,opt,name=group_hash,json=groupHash,proto3" json:"group_hash,omitempty"` // Whether the notification was about a resolved alert. // Deprecated in favor of ResolvedAlerts field, but kept for compatibility. Resolved bool `protobuf:"varint,4,opt,name=resolved,proto3" json:"resolved,omitempty"` // Timestamp of the succeeding notification. Timestamp *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"` // FiringAlerts list of hashes of firing alerts at the last notification time. FiringAlerts []uint64 `protobuf:"varint,6,rep,packed,name=firing_alerts,json=firingAlerts,proto3" json:"firing_alerts,omitempty"` // ResolvedAlerts list of hashes of resolved alerts at the last notification time. ResolvedAlerts []uint64 `protobuf:"varint,7,rep,packed,name=resolved_alerts,json=resolvedAlerts,proto3" json:"resolved_alerts,omitempty"` // Data specific to the receiver which sent the notification ReceiverData map[string]*ReceiverDataValue `protobuf:"bytes,8,rep,name=receiver_data,json=receiverData,proto3" json:"receiver_data,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Entry) Reset() { *x = Entry{} mi := &file_nflog_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Entry) String() string { return protoimpl.X.MessageStringOf(x) } func (*Entry) ProtoMessage() {} func (x *Entry) ProtoReflect() protoreflect.Message { mi := &file_nflog_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Entry.ProtoReflect.Descriptor instead. func (*Entry) Descriptor() ([]byte, []int) { return file_nflog_proto_rawDescGZIP(), []int{1} } func (x *Entry) GetGroupKey() []byte { if x != nil { return x.GroupKey } return nil } func (x *Entry) GetReceiver() *Receiver { if x != nil { return x.Receiver } return nil } func (x *Entry) GetGroupHash() []byte { if x != nil { return x.GroupHash } return nil } func (x *Entry) GetResolved() bool { if x != nil { return x.Resolved } return false } func (x *Entry) GetTimestamp() *timestamppb.Timestamp { if x != nil { return x.Timestamp } return nil } func (x *Entry) GetFiringAlerts() []uint64 { if x != nil { return x.FiringAlerts } return nil } func (x *Entry) GetResolvedAlerts() []uint64 { if x != nil { return x.ResolvedAlerts } return nil } func (x *Entry) GetReceiverData() map[string]*ReceiverDataValue { if x != nil { return x.ReceiverData } return nil } // MeshEntry is a wrapper message to communicate a notify log // entry through a mesh network. type MeshEntry struct { state protoimpl.MessageState `protogen:"open.v1"` // The original raw notify log entry. Entry *Entry `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` // A timestamp indicating when the mesh peer should evict // the log entry from its state. ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MeshEntry) Reset() { *x = MeshEntry{} mi := &file_nflog_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MeshEntry) String() string { return protoimpl.X.MessageStringOf(x) } func (*MeshEntry) ProtoMessage() {} func (x *MeshEntry) ProtoReflect() protoreflect.Message { mi := &file_nflog_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MeshEntry.ProtoReflect.Descriptor instead. func (*MeshEntry) Descriptor() ([]byte, []int) { return file_nflog_proto_rawDescGZIP(), []int{2} } func (x *MeshEntry) GetEntry() *Entry { if x != nil { return x.Entry } return nil } func (x *MeshEntry) GetExpiresAt() *timestamppb.Timestamp { if x != nil { return x.ExpiresAt } return nil } type ReceiverDataValue struct { state protoimpl.MessageState `protogen:"open.v1"` // Types that are valid to be assigned to Value: // // *ReceiverDataValue_StrVal // *ReceiverDataValue_IntVal // *ReceiverDataValue_DoubleVal Value isReceiverDataValue_Value `protobuf_oneof:"value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *ReceiverDataValue) Reset() { *x = ReceiverDataValue{} mi := &file_nflog_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *ReceiverDataValue) String() string { return protoimpl.X.MessageStringOf(x) } func (*ReceiverDataValue) ProtoMessage() {} func (x *ReceiverDataValue) ProtoReflect() protoreflect.Message { mi := &file_nflog_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use ReceiverDataValue.ProtoReflect.Descriptor instead. func (*ReceiverDataValue) Descriptor() ([]byte, []int) { return file_nflog_proto_rawDescGZIP(), []int{3} } func (x *ReceiverDataValue) GetValue() isReceiverDataValue_Value { if x != nil { return x.Value } return nil } func (x *ReceiverDataValue) GetStrVal() string { if x != nil { if x, ok := x.Value.(*ReceiverDataValue_StrVal); ok { return x.StrVal } } return "" } func (x *ReceiverDataValue) GetIntVal() int64 { if x != nil { if x, ok := x.Value.(*ReceiverDataValue_IntVal); ok { return x.IntVal } } return 0 } func (x *ReceiverDataValue) GetDoubleVal() float64 { if x != nil { if x, ok := x.Value.(*ReceiverDataValue_DoubleVal); ok { return x.DoubleVal } } return 0 } type isReceiverDataValue_Value interface { isReceiverDataValue_Value() } type ReceiverDataValue_StrVal struct { StrVal string `protobuf:"bytes,1,opt,name=str_val,json=strVal,proto3,oneof"` } type ReceiverDataValue_IntVal struct { IntVal int64 `protobuf:"varint,2,opt,name=int_val,json=intVal,proto3,oneof"` } type ReceiverDataValue_DoubleVal struct { DoubleVal float64 `protobuf:"fixed64,3,opt,name=double_val,json=doubleVal,proto3,oneof"` } func (*ReceiverDataValue_StrVal) isReceiverDataValue_Value() {} func (*ReceiverDataValue_IntVal) isReceiverDataValue_Value() {} func (*ReceiverDataValue_DoubleVal) isReceiverDataValue_Value() {} var File_nflog_proto protoreflect.FileDescriptor const file_nflog_proto_rawDesc = "" + "\n" + "\vnflog.proto\x12\anflogpb\x1a\x1fgoogle/protobuf/timestamp.proto\"]\n" + "\bReceiver\x12\x1d\n" + "\n" + "group_name\x18\x01 \x01(\tR\tgroupName\x12 \n" + "\vintegration\x18\x02 \x01(\tR\vintegration\x12\x10\n" + "\x03idx\x18\x03 \x01(\rR\x03idx\"\xba\x03\n" + "\x05Entry\x12\x1b\n" + "\tgroup_key\x18\x01 \x01(\fR\bgroupKey\x12-\n" + "\breceiver\x18\x02 \x01(\v2\x11.nflogpb.ReceiverR\breceiver\x12\x1d\n" + "\n" + "group_hash\x18\x03 \x01(\fR\tgroupHash\x12\x1a\n" + "\bresolved\x18\x04 \x01(\bR\bresolved\x128\n" + "\ttimestamp\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12#\n" + "\rfiring_alerts\x18\x06 \x03(\x04R\ffiringAlerts\x12'\n" + "\x0fresolved_alerts\x18\a \x03(\x04R\x0eresolvedAlerts\x12E\n" + "\rreceiver_data\x18\b \x03(\v2 .nflogpb.Entry.ReceiverDataEntryR\freceiverData\x1a[\n" + "\x11ReceiverDataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x120\n" + "\x05value\x18\x02 \x01(\v2\x1a.nflogpb.ReceiverDataValueR\x05value:\x028\x01\"l\n" + "\tMeshEntry\x12$\n" + "\x05entry\x18\x01 \x01(\v2\x0e.nflogpb.EntryR\x05entry\x129\n" + "\n" + "expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"s\n" + "\x11ReceiverDataValue\x12\x19\n" + "\astr_val\x18\x01 \x01(\tH\x00R\x06strVal\x12\x19\n" + "\aint_val\x18\x02 \x01(\x03H\x00R\x06intVal\x12\x1f\n" + "\n" + "double_val\x18\x03 \x01(\x01H\x00R\tdoubleValB\a\n" + "\x05valueB2Z0github.com/prometheus/alertmanager/nflog/nflogpbb\x06proto3" var ( file_nflog_proto_rawDescOnce sync.Once file_nflog_proto_rawDescData []byte ) func file_nflog_proto_rawDescGZIP() []byte { file_nflog_proto_rawDescOnce.Do(func() { file_nflog_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_nflog_proto_rawDesc), len(file_nflog_proto_rawDesc))) }) return file_nflog_proto_rawDescData } var file_nflog_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_nflog_proto_goTypes = []any{ (*Receiver)(nil), // 0: nflogpb.Receiver (*Entry)(nil), // 1: nflogpb.Entry (*MeshEntry)(nil), // 2: nflogpb.MeshEntry (*ReceiverDataValue)(nil), // 3: nflogpb.ReceiverDataValue nil, // 4: nflogpb.Entry.ReceiverDataEntry (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp } var file_nflog_proto_depIdxs = []int32{ 0, // 0: nflogpb.Entry.receiver:type_name -> nflogpb.Receiver 5, // 1: nflogpb.Entry.timestamp:type_name -> google.protobuf.Timestamp 4, // 2: nflogpb.Entry.receiver_data:type_name -> nflogpb.Entry.ReceiverDataEntry 1, // 3: nflogpb.MeshEntry.entry:type_name -> nflogpb.Entry 5, // 4: nflogpb.MeshEntry.expires_at:type_name -> google.protobuf.Timestamp 3, // 5: nflogpb.Entry.ReceiverDataEntry.value:type_name -> nflogpb.ReceiverDataValue 6, // [6:6] is the sub-list for method output_type 6, // [6:6] is the sub-list for method input_type 6, // [6:6] is the sub-list for extension type_name 6, // [6:6] is the sub-list for extension extendee 0, // [0:6] is the sub-list for field type_name } func init() { file_nflog_proto_init() } func file_nflog_proto_init() { if File_nflog_proto != nil { return } file_nflog_proto_msgTypes[3].OneofWrappers = []any{ (*ReceiverDataValue_StrVal)(nil), (*ReceiverDataValue_IntVal)(nil), (*ReceiverDataValue_DoubleVal)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_nflog_proto_rawDesc), len(file_nflog_proto_rawDesc)), NumEnums: 0, NumMessages: 5, NumExtensions: 0, NumServices: 0, }, GoTypes: file_nflog_proto_goTypes, DependencyIndexes: file_nflog_proto_depIdxs, MessageInfos: file_nflog_proto_msgTypes, }.Build() File_nflog_proto = out.File file_nflog_proto_goTypes = nil file_nflog_proto_depIdxs = nil } ================================================ FILE: nflog/nflogpb/nflog.proto ================================================ syntax = "proto3"; package nflogpb; option go_package = "github.com/prometheus/alertmanager/nflog/nflogpb"; import "google/protobuf/timestamp.proto"; message Receiver { // Configured name of the receiver group. string group_name = 1; // Name of the integration of the receiver. string integration = 2; // Index of the receiver with respect to the integration. // Every integration in a group may have 0..N configurations. uint32 idx = 3; } // Entry holds information about a successful notification // sent to a receiver. message Entry { // The key identifying the dispatching group. bytes group_key = 1; // The receiver that was notified. Receiver receiver = 2; // Hash over the state of the group at notification time. // Deprecated in favor of FiringAlerts field, but kept for compatibility. bytes group_hash = 3; // Whether the notification was about a resolved alert. // Deprecated in favor of ResolvedAlerts field, but kept for compatibility. bool resolved = 4; // Timestamp of the succeeding notification. google.protobuf.Timestamp timestamp = 5; // FiringAlerts list of hashes of firing alerts at the last notification time. repeated uint64 firing_alerts = 6; // ResolvedAlerts list of hashes of resolved alerts at the last notification time. repeated uint64 resolved_alerts = 7; // Data specific to the receiver which sent the notification map receiver_data = 8; } // MeshEntry is a wrapper message to communicate a notify log // entry through a mesh network. message MeshEntry { // The original raw notify log entry. Entry entry = 1; // A timestamp indicating when the mesh peer should evict // the log entry from its state. google.protobuf.Timestamp expires_at = 2; } message ReceiverDataValue { oneof value { string str_val = 1; int64 int_val = 2; double double_val = 3; } } ================================================ FILE: nflog/nflogpb/set.go ================================================ // Copyright 2017 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nflogpb // IsFiringSubset returns whether the given subset is a subset of the alerts // that were firing at the time of the last notification. func (m *Entry) IsFiringSubset(subset map[uint64]struct{}) bool { set := map[uint64]struct{}{} for i := range m.FiringAlerts { set[m.FiringAlerts[i]] = struct{}{} } return isSubset(set, subset) } // IsResolvedSubset returns whether the given subset is a subset of the alerts // that were resolved at the time of the last notification. func (m *Entry) IsResolvedSubset(subset map[uint64]struct{}) bool { set := map[uint64]struct{}{} for i := range m.ResolvedAlerts { set[m.ResolvedAlerts[i]] = struct{}{} } return isSubset(set, subset) } func isSubset(set, subset map[uint64]struct{}) bool { for k := range subset { _, exists := set[k] if !exists { return false } } return true } ================================================ FILE: nflog/nflogpb/set_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package nflogpb import ( "testing" ) func TestIsFiringSubset(t *testing.T) { e := &Entry{ FiringAlerts: []uint64{1, 2, 3}, } tests := []struct { subset map[uint64]struct{} expected bool }{ {newSubset(), true}, // empty subset {newSubset(1), true}, {newSubset(2), true}, {newSubset(3), true}, {newSubset(1, 2), true}, {newSubset(1, 2), true}, {newSubset(1, 2, 3), true}, {newSubset(4), false}, {newSubset(1, 5), false}, {newSubset(1, 2, 3, 6), false}, } for _, test := range tests { if result := e.IsFiringSubset(test.subset); result != test.expected { t.Errorf("Expected %t, got %t for subset %v", test.expected, result, elements(test.subset)) } } } func TestIsResolvedSubset(t *testing.T) { e := &Entry{ ResolvedAlerts: []uint64{1, 2, 3}, } tests := []struct { subset map[uint64]struct{} expected bool }{ {newSubset(), true}, // empty subset {newSubset(1), true}, {newSubset(2), true}, {newSubset(3), true}, {newSubset(1, 2), true}, {newSubset(1, 2), true}, {newSubset(1, 2, 3), true}, {newSubset(4), false}, {newSubset(1, 5), false}, {newSubset(1, 2, 3, 6), false}, } for _, test := range tests { if result := e.IsResolvedSubset(test.subset); result != test.expected { t.Errorf("Expected %t, got %t for subset %v", test.expected, result, elements(test.subset)) } } } func newSubset(elements ...uint64) map[uint64]struct{} { subset := make(map[uint64]struct{}) for _, el := range elements { subset[el] = struct{}{} } return subset } func elements(m map[uint64]struct{}) []uint64 { els := make([]uint64, 0, len(m)) for k := range m { els = append(els, k) } return els } ================================================ FILE: notify/discord/discord.go ================================================ // Copyright 2021 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package discord import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "net/http" netUrl "net/url" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( // https://discord.com/developers/docs/resources/channel#embed-object-embed-limits - 256 characters or runes. maxTitleLenRunes = 256 // https://discord.com/developers/docs/resources/channel#embed-object-embed-limits - 4096 characters or runes. maxDescriptionLenRunes = 4096 maxContentLenRunes = 2000 ) const ( colorRed = 0x992D22 colorGreen = 0x2ECC71 colorGrey = 0x95A5A6 ) // Notifier implements a Notifier for Discord notifications. type Notifier struct { conf *config.DiscordConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier webhookURL *amcommoncfg.SecretURL } // New returns a new Discord notifier. func New(c *config.DiscordConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "discord", httpOpts...) if err != nil { return nil, err } n := &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, webhookURL: c.WebhookURL, } return n, nil } type webhook struct { Content string `json:"content"` Embeds []webhookEmbed `json:"embeds"` Username string `json:"username,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` } type webhookEmbed struct { Title string `json:"title"` Description string `json:"description"` Color int `json:"color"` } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") alerts := types.Alerts(as...) data := notify.GetTemplateData(ctx, n.tmpl, as, logger) tmpl := notify.TmplText(n.tmpl, data, &err) if err != nil { return false, err } title, truncated := notify.TruncateInRunes(tmpl(n.conf.Title), maxTitleLenRunes) if err != nil { return false, err } if truncated { logger.Warn("Truncated title", "max_runes", maxTitleLenRunes) } description, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxDescriptionLenRunes) if err != nil { return false, err } if truncated { logger.Warn("Truncated message", "max_runes", maxDescriptionLenRunes) } content, truncated := notify.TruncateInRunes(tmpl(n.conf.Content), maxContentLenRunes) if err != nil { return false, err } if truncated { logger.Warn("Truncated message", "max_runes", maxContentLenRunes) } color := colorGrey if alerts.Status() == model.AlertFiring { color = colorRed } if alerts.Status() == model.AlertResolved { color = colorGreen } var url string if n.conf.WebhookURL != nil { url = n.conf.WebhookURL.String() } else { b, err := os.ReadFile(n.conf.WebhookURLFile) if err != nil { return false, fmt.Errorf("read webhook_url_file: %w", err) } url = strings.TrimSpace(string(b)) } w := webhook{ Content: content, Username: n.conf.Username, Embeds: []webhookEmbed{{ Title: title, Description: description, Color: color, }}, } if len(n.conf.AvatarURL) != 0 { if _, err := netUrl.Parse(n.conf.AvatarURL); err == nil { w.AvatarURL = n.conf.AvatarURL } else { logger.Warn("Bad avatar url", "key", key) } } var payload bytes.Buffer if err = json.NewEncoder(&payload).Encode(w); err != nil { return false, err } resp, err := notify.PostJSON(ctx, n.client, url, &payload) if err != nil { return true, notify.RedactURL(err) } shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return shouldRetry, err } return false, nil } ================================================ FILE: notify/discord/discord_test.go ================================================ // Copyright 2021 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package discord import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) // This is a test URL that has been modified to not be valid. var testWebhookURL, _ = url.Parse("https://discord.com/api/webhooks/971139602272503183/78ZWZ4V3xwZUBKRFF-G9m1nRtDtNTChl_WzW6Q4kxShjSB02oLSiPTPa8TS2tTGO9EYf") func TestDiscordRetry(t *testing.T) { notifier, err := New( &config.DiscordConfig{ WebhookURL: &amcommoncfg.SecretURL{URL: testWebhookURL}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "retry - error on status %d", statusCode) } } func TestDiscordTemplating(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(r.Body) out := make(map[string]any) err := dec.Decode(&out) if err != nil { panic(err) } })) defer srv.Close() u, _ := url.Parse(srv.URL) for _, tc := range []struct { title string cfg *config.DiscordConfig retry bool errMsg string }{ { title: "full-blown message", cfg: &config.DiscordConfig{ Title: `{{ template "discord.default.title" . }}`, Message: `{{ template "discord.default.message" . }}`, }, retry: false, }, { title: "title with templating errors", cfg: &config.DiscordConfig{ Title: "{{ ", }, errMsg: "template: :1: unclosed action", }, { title: "message with templating errors", cfg: &config.DiscordConfig{ Title: `{{ template "discord.default.title" . }}`, Message: "{{ ", }, errMsg: "template: :1: unclosed action", }, } { t.Run(tc.title, func(t *testing.T) { tc.cfg.WebhookURL = &amcommoncfg.SecretURL{URL: u} tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") ok, err := pd.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) if tc.errMsg == "" { require.NoError(t, err) } else { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) } require.Equal(t, tc.retry, ok) }) } } func TestDiscordRedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() secret := "secret" notifier, err := New( &config.DiscordConfig{ WebhookURL: &amcommoncfg.SecretURL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret) } func TestDiscordReadingURLFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() f, err := os.CreateTemp(t.TempDir(), "webhook_url") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(u.String() + "\n") require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.DiscordConfig{ WebhookURLFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } func TestDiscord_Notify(t *testing.T) { // Create a fake HTTP server to simulate the Discord webhook var resp string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Read the request as a string body, err := io.ReadAll(r.Body) require.NoError(t, err, "reading request body failed") // Store the request body in the response resp = string(body) w.WriteHeader(http.StatusOK) })) // Create a temporary file to simulate the WebhookURLFile tempFile, err := os.CreateTemp(t.TempDir(), "webhook_url") require.NoError(t, err) // Write the fake webhook URL to the temp file _, err = tempFile.WriteString(srv.URL) require.NoError(t, err) // Create a DiscordConfig with the WebhookURLFile set cfg := &config.DiscordConfig{ WebhookURLFile: tempFile.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, Title: "Test Title", Message: "Test Message", Content: "Test Content", Username: "Test Username", AvatarURL: "http://example.com/avatar.png", } // Create a new Discord notifier notifier, err := New(cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) // Create a context and alerts ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, } // Call the Notify method ok, err := notifier.Notify(ctx, alerts...) require.NoError(t, err) require.False(t, ok) require.JSONEq(t, `{"content":"Test Content","embeds":[{"title":"Test Title","description":"Test Message","color":10038562}],"username":"Test Username","avatar_url":"http://example.com/avatar.png"}`, resp) } ================================================ FILE: notify/email/email.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package email import ( "bytes" "context" "crypto/tls" "errors" "fmt" "log/slog" "math/rand" "mime" "mime/multipart" "mime/quotedprintable" "net" "net/mail" "net/smtp" "net/textproto" "os" "strings" "sync" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // Email implements a Notifier for email notifications. type Email struct { conf *config.EmailConfig tmpl *template.Template logger *slog.Logger hostname string } // New returns a new Email notifier. func New(c *config.EmailConfig, t *template.Template, l *slog.Logger) *Email { if _, ok := c.Headers["Subject"]; !ok { c.Headers["Subject"] = config.DefaultEmailSubject } if _, ok := c.Headers["To"]; !ok { c.Headers["To"] = c.To } if _, ok := c.Headers["From"]; !ok { c.Headers["From"] = c.From } h, err := os.Hostname() // If we can't get the hostname, we'll use localhost if err != nil { h = "localhost.localdomain" } return &Email{conf: c, tmpl: t, logger: l, hostname: h} } // auth resolves a string of authentication mechanisms. func (n *Email) auth(mechs string) (smtp.Auth, error) { username := n.conf.AuthUsername // If no username is set, keep going without authentication. if n.conf.AuthUsername == "" { n.logger.Debug("smtp_auth_username is not configured. Attempting to send email without authenticating") return nil, nil } var errs error for mech := range strings.SplitSeq(mechs, " ") { switch mech { case "CRAM-MD5": secret, secretErr := n.getAuthSecret() if secretErr != nil { errs = errors.Join(errs, secretErr) continue } if secret == "" { errs = errors.Join(errs, errors.New("missing secret for CRAM-MD5 auth mechanism")) continue } return smtp.CRAMMD5Auth(username, secret), nil case "PLAIN": password, passwordErr := n.getPassword() if passwordErr != nil { errs = errors.Join(errs, passwordErr) continue } if password == "" { errs = errors.Join(errs, errors.New("missing password for PLAIN auth mechanism")) continue } return smtp.PlainAuth(n.conf.AuthIdentity, username, password, n.conf.Smarthost.Host), nil case "LOGIN": password, passwordErr := n.getPassword() if passwordErr != nil { errs = errors.Join(errs, passwordErr) continue } if password == "" { errs = errors.Join(errs, errors.New("missing password for LOGIN auth mechanism")) continue } return LoginAuth(username, password), nil default: errs = errors.Join(errs, errors.New("unknown auth mechanism: "+mech)) } } return nil, errs } // Notify implements the Notifier interface. func (n *Email) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { var ( c *smtp.Client conn net.Conn err error success = false ) // Determine whether to use Implicit TLS var useImplicitTLS bool if n.conf.ForceImplicitTLS != nil { useImplicitTLS = *n.conf.ForceImplicitTLS } else { // Default logic: port 465 uses implicit TLS (backward compatibility) useImplicitTLS = n.conf.Smarthost.Port == "465" } if useImplicitTLS { tlsConfig, err := commoncfg.NewTLSConfig(n.conf.TLSConfig) if err != nil { return false, fmt.Errorf("parse TLS configuration: %w", err) } if tlsConfig.ServerName == "" { tlsConfig.ServerName = n.conf.Smarthost.Host } conn, err = tls.Dial("tcp", n.conf.Smarthost.String(), tlsConfig) if err != nil { return true, fmt.Errorf("establish TLS connection to server: %w", err) } } else { var ( d = net.Dialer{} err error ) conn, err = d.DialContext(ctx, "tcp", n.conf.Smarthost.String()) if err != nil { return true, fmt.Errorf("establish connection to server: %w", err) } } c, err = smtp.NewClient(conn, n.conf.Smarthost.Host) if err != nil { conn.Close() return true, fmt.Errorf("create SMTP client: %w", err) } defer func() { // Try to clean up after ourselves but don't log anything if something has failed. if err := c.Quit(); success && err != nil { n.logger.Warn("failed to close SMTP connection", "err", err) } }() if n.conf.Hello != "" { err = c.Hello(n.conf.Hello) if err != nil { return true, fmt.Errorf("send EHLO command: %w", err) } } // Global Config guarantees RequireTLS is not nil. if *n.conf.RequireTLS && !useImplicitTLS { if ok, _ := c.Extension("STARTTLS"); !ok { return true, fmt.Errorf("'require_tls' is true (default) but %q does not advertise the STARTTLS extension", n.conf.Smarthost) } tlsConf, err := commoncfg.NewTLSConfig(n.conf.TLSConfig) if err != nil { return false, fmt.Errorf("parse TLS configuration: %w", err) } if tlsConf.ServerName == "" { tlsConf.ServerName = n.conf.Smarthost.Host } if err := c.StartTLS(tlsConf); err != nil { return true, fmt.Errorf("send STARTTLS command: %w", err) } } if ok, mech := c.Extension("AUTH"); ok { auth, err := n.auth(mech) if err != nil { return true, fmt.Errorf("find auth mechanism: %w", err) } if auth != nil { if err := c.Auth(auth); err != nil { return true, fmt.Errorf("%T auth: %w", auth, err) } } } var ( tmplErr error data = notify.GetTemplateData(ctx, n.tmpl, as, n.logger) tmpl = notify.TmplText(n.tmpl, data, &tmplErr) ) from := tmpl(n.conf.From) if tmplErr != nil { return false, fmt.Errorf("execute 'from' template: %w", tmplErr) } to := tmpl(n.conf.To) if tmplErr != nil { return false, fmt.Errorf("execute 'to' template: %w", tmplErr) } addrs, err := mail.ParseAddressList(from) if err != nil { return false, fmt.Errorf("parse 'from' addresses: %w", err) } if len(addrs) != 1 { return false, fmt.Errorf("must be exactly one 'from' address (got: %d)", len(addrs)) } if err = c.Mail(addrs[0].Address); err != nil { return true, fmt.Errorf("send MAIL command: %w", err) } addrs, err = mail.ParseAddressList(to) if err != nil { return false, fmt.Errorf("parse 'to' addresses: %w", err) } for _, addr := range addrs { if err = c.Rcpt(addr.Address); err != nil { return true, fmt.Errorf("send RCPT command: %w", err) } } // Send the email headers and body. message, err := c.Data() if err != nil { return true, fmt.Errorf("send DATA command: %w", err) } closeOnce := sync.OnceValue(func() error { return message.Close() }) // Close the message when this method exits in order to not leak resources. Even though we're calling this explicitly // further down, the method may exit before then. defer func() { // If we try close an already-closed writer, it'll send a subsequent request to the server which is invalid. _ = closeOnce() }() buffer := &bytes.Buffer{} for header, t := range n.conf.Headers { value, err := n.tmpl.ExecuteTextString(t, data) if err != nil { return false, fmt.Errorf("execute %q header template: %w", header, err) } fmt.Fprintf(buffer, "%s: %s\r\n", header, mime.QEncoding.Encode("utf-8", value)) } if _, ok := n.conf.Headers["Message-Id"]; !ok { fmt.Fprintf(buffer, "Message-Id: %s\r\n", fmt.Sprintf("<%d.%d@%s>", time.Now().UnixNano(), rand.Uint64(), n.hostname)) } if n.conf.Threading.Enabled { key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } // Add threading headers. All notifications for the same alert group // (identified by key hash) are threaded together. threadBy := "" if n.conf.Threading.ThreadByDate != "none" { // ThreadByDate is 'daily': // Use current date so all mails for this alert today thread together. threadBy = time.Now().Format("2006-01-02") } keyHash := key.Hash() if len(keyHash) > 16 { keyHash = keyHash[:16] } // The thread root ID is a Message-ID that doesn't correspond to // any actual email. Email clients following the (commonly used) JWZ // algorithm will create a dummy container to group these messages. threadRootID := fmt.Sprintf("", keyHash, threadBy) fmt.Fprintf(buffer, "References: %s\r\n", threadRootID) fmt.Fprintf(buffer, "In-Reply-To: %s\r\n", threadRootID) } multipartBuffer := &bytes.Buffer{} multipartWriter := multipart.NewWriter(multipartBuffer) fmt.Fprintf(buffer, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) fmt.Fprintf(buffer, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) fmt.Fprintf(buffer, "MIME-Version: 1.0\r\n\r\n") // TODO: Add some useful headers here, such as URL of the alertmanager // and active/resolved. _, err = message.Write(buffer.Bytes()) if err != nil { return false, fmt.Errorf("write headers: %w", err) } if len(n.conf.Text) > 0 { // Text template w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ "Content-Transfer-Encoding": {"quoted-printable"}, "Content-Type": {"text/plain; charset=UTF-8"}, }) if err != nil { return false, fmt.Errorf("create part for text template: %w", err) } body, err := n.tmpl.ExecuteTextString(n.conf.Text, data) if err != nil { return false, fmt.Errorf("execute text template: %w", err) } qw := quotedprintable.NewWriter(w) _, err = qw.Write([]byte(body)) if err != nil { return true, fmt.Errorf("write text part: %w", err) } err = qw.Close() if err != nil { return true, fmt.Errorf("close text part: %w", err) } } if len(n.conf.HTML) > 0 { // Html template // Preferred alternative placed last per section 5.1.4 of RFC 2046 // https://www.ietf.org/rfc/rfc2046.txt w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ "Content-Transfer-Encoding": {"quoted-printable"}, "Content-Type": {"text/html; charset=UTF-8"}, }) if err != nil { return false, fmt.Errorf("create part for html template: %w", err) } body, err := n.tmpl.ExecuteHTMLString(n.conf.HTML, data) if err != nil { return false, fmt.Errorf("execute html template: %w", err) } qw := quotedprintable.NewWriter(w) _, err = qw.Write([]byte(body)) if err != nil { return true, fmt.Errorf("write HTML part: %w", err) } err = qw.Close() if err != nil { return true, fmt.Errorf("close HTML part: %w", err) } } err = multipartWriter.Close() if err != nil { return false, fmt.Errorf("close multipartWriter: %w", err) } _, err = message.Write(multipartBuffer.Bytes()) if err != nil { return false, fmt.Errorf("write body buffer: %w", err) } // Complete the message and await response. if err = closeOnce(); err != nil { return true, fmt.Errorf("delivery failure: %w", err) } success = true return false, nil } type loginAuth struct { username, password string } func LoginAuth(username, password string) smtp.Auth { return &loginAuth{username, password} } func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { return "LOGIN", []byte{}, nil } // Used for AUTH LOGIN. (Maybe password should be encrypted). func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { if more { switch strings.ToLower(string(fromServer)) { case "username:": return []byte(a.username), nil case "password:": return []byte(a.password), nil default: return nil, errors.New("unexpected server challenge") } } return nil, nil } func (n *Email) getPassword() (string, error) { if len(n.conf.AuthPasswordFile) > 0 { content, err := os.ReadFile(n.conf.AuthPasswordFile) if err != nil { return "", fmt.Errorf("could not read %s: %w", n.conf.AuthPasswordFile, err) } return strings.TrimSpace(string(content)), nil } return string(n.conf.AuthPassword), nil } func (n *Email) getAuthSecret() (string, error) { if len(n.conf.AuthSecretFile) > 0 { content, err := os.ReadFile(n.conf.AuthSecretFile) if err != nil { return "", fmt.Errorf("could not read %s: %w", n.conf.AuthSecretFile, err) } return string(content), nil } return string(n.conf.AuthSecret), nil } ================================================ FILE: notify/email/email_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Some tests require a running mail catcher. We use MailDev for this purpose, // it can work without or with authentication (LOGIN only). It exposes a REST // API which we use to retrieve and check the sent emails. // // Those tests are only executed when specific environment variables are set, // otherwise they are skipped. The tests must be run by the CI. // // To run the tests locally, you should start 2 MailDev containers: // // $ docker run --rm -p 1080:1080 -p 1025:1025 --entrypoint bin/maildev maildev/maildev:2.2.1 -v // $ docker run --rm -p 1081:1080 -p 1026:1025 --entrypoint bin/maildev maildev/maildev:2.2.1 --incoming-user user --incoming-pass pass -v // // $ EMAIL_NO_AUTH_CONFIG=testdata/noauth-local.yml EMAIL_AUTH_CONFIG=testdata/auth-local.yml make // // See also https://github.com/maildev/maildev for more details. package email import ( "context" "fmt" "io" "net" "net/http" "net/url" "os" "strconv" "strings" "testing" "time" "github.com/emersion/go-smtp" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" // nolint:depguard // require cannot be called outside the main goroutine: https://pkg.go.dev/testing#T.FailNow "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( emailNoAuthConfigVar = "EMAIL_NO_AUTH_CONFIG" emailAuthConfigVar = "EMAIL_AUTH_CONFIG" emailTo = "alerts@example.com" emailFrom = "alertmanager@example.com" ) // email represents an email returned by the MailDev REST API. // See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md. type email struct { To []map[string]string From []map[string]string Subject string HTML *string Text *string Headers map[string]string } // mailDev is a client for the MailDev server. type mailDev struct { *url.URL } func (m *mailDev) UnmarshalYAML(unmarshal func(any) error) error { var s string if err := unmarshal(&s); err != nil { return err } urlp, err := url.Parse(s) if err != nil { return err } m.URL = urlp return nil } // getLastEmail returns the last received email. func (m *mailDev) getLastEmail(t *testing.T) (*email, error) { // The maildev API might be async. Waiting resolves some issues with flakes. time.Sleep(100 * time.Millisecond) code, b, err := m.doEmailRequest(http.MethodGet, "/email") if err != nil { return nil, err } if code != http.StatusOK { return nil, fmt.Errorf("expected status OK, got %d", code) } t.Logf("Raw email data (getLastEmail): %s", string(b)) var emails []email err = yaml.Unmarshal(b, &emails) if err != nil { return nil, err } if len(emails) == 0 { return nil, nil } return &emails[len(emails)-1], nil } // deleteAllEmails deletes all emails. func (m *mailDev) deleteAllEmails() error { _, _, err := m.doEmailRequest(http.MethodDelete, "/email/all") return err } // doEmailRequest makes a request to the MailDev API. func (m *mailDev) doEmailRequest(method, path string) (int, []byte, error) { req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s", m.Scheme, m.Host, path), nil) if err != nil { return 0, nil, err } ctx, cancel := context.WithTimeout(context.Background(), time.Second) req = req.WithContext(ctx) defer cancel() res, err := http.DefaultClient.Do(req) if err != nil { return 0, nil, err } defer res.Body.Close() b, err := io.ReadAll(res.Body) if err != nil { return 0, nil, err } return res.StatusCode, b, nil } // emailTestConfig is the configuration for the tests. type emailTestConfig struct { Smarthost config.HostPort `yaml:"smarthost"` Username string `yaml:"username"` Password string `yaml:"password"` Server *mailDev `yaml:"server"` } func loadEmailTestConfiguration(f string) (emailTestConfig, error) { c := emailTestConfig{} b, err := os.ReadFile(f) if err != nil { return c, err } err = yaml.UnmarshalStrict(b, &c) if err != nil { return c, err } return c, nil } func notifyEmail(t *testing.T, cfg *config.EmailConfig, server *mailDev) (*email, bool, error) { return notifyEmailWithContext(context.Background(), t, cfg, server) } // notifyEmailWithContext sends a notification with one firing alert and retrieves the // email from the SMTP server if the notification has been successfully delivered. func notifyEmailWithContext(ctx context.Context, t *testing.T, cfg *config.EmailConfig, server *mailDev) (*email, bool, error) { tmpl, firingAlert, err := prepare(cfg) if err != nil { return nil, false, err } err = server.deleteAllEmails() if err != nil { return nil, false, err } email := New(cfg, tmpl, promslog.NewNopLogger()) retry, err := email.Notify(ctx, firingAlert) if err != nil { return nil, retry, err } e, err := server.getLastEmail(t) if err != nil { return nil, retry, err } else if e == nil { return nil, retry, fmt.Errorf("email not found") } return e, retry, nil } func prepare(cfg *config.EmailConfig) (*template.Template, *types.Alert, error) { if cfg == nil { panic("nil config passed") } if cfg.RequireTLS == nil { cfg.RequireTLS = new(bool) } if cfg.Headers == nil { cfg.Headers = make(map[string]string) } tmpl, err := template.FromGlobs([]string{}) if err != nil { return nil, nil, err } tmpl.ExternalURL, _ = url.Parse("http://am") firingAlert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{}, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } return tmpl, firingAlert, nil } // TestEmailNotifyWithErrors tries to send emails with buggy inputs. func TestEmailNotifyWithErrors(t *testing.T) { cfgFile := os.Getenv(emailNoAuthConfigVar) if len(cfgFile) == 0 { t.Skipf("%s not set", emailNoAuthConfigVar) } c, err := loadEmailTestConfiguration(cfgFile) if err != nil { t.Fatal(err) } for _, tc := range []struct { title string updateCfg func(*config.EmailConfig) errMsg string hasEmail bool }{ { title: "invalid 'from' template", updateCfg: func(cfg *config.EmailConfig) { cfg.From = `{{ template "invalid" }}` }, errMsg: "execute 'from' template:", }, { title: "invalid 'from' address", updateCfg: func(cfg *config.EmailConfig) { cfg.From = `xxx` }, errMsg: "parse 'from' addresses:", }, { title: "invalid 'to' template", updateCfg: func(cfg *config.EmailConfig) { cfg.To = `{{ template "invalid" }}` }, errMsg: "execute 'to' template:", }, { title: "invalid 'to' address", updateCfg: func(cfg *config.EmailConfig) { cfg.To = `xxx` }, errMsg: "parse 'to' addresses:", }, { title: "invalid 'subject' template", updateCfg: func(cfg *config.EmailConfig) { cfg.Headers["subject"] = `{{ template "invalid" }}` }, errMsg: `execute "subject" header template:`, hasEmail: true, }, { title: "invalid 'text' template", updateCfg: func(cfg *config.EmailConfig) { cfg.Text = `{{ template "invalid" }}` }, errMsg: `execute text template:`, hasEmail: true, }, { title: "invalid 'html' template", updateCfg: func(cfg *config.EmailConfig) { cfg.HTML = `{{ template "invalid" }}` }, errMsg: `execute html template:`, hasEmail: true, }, } { t.Run(tc.title, func(t *testing.T) { if len(tc.errMsg) == 0 { t.Fatal("please define the expected error message") return } emailCfg := &config.EmailConfig{ Smarthost: c.Smarthost, To: emailTo, From: emailFrom, HTML: "HTML body", Text: "Text body", Headers: map[string]string{ "Subject": "{{ len .Alerts }} {{ .Status }} alert(s)", }, } if tc.updateCfg != nil { tc.updateCfg(emailCfg) } _, retry, err := notifyEmail(t, emailCfg, c.Server) require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) require.False(t, retry) e, err := c.Server.getLastEmail(t) require.NoError(t, err) if tc.hasEmail { require.NotNil(t, e) } else { require.Nil(t, e) } }) } } // TestEmailNotifyWithDoneContext tries to send an email with a context that is done. func TestEmailNotifyWithDoneContext(t *testing.T) { cfgFile := os.Getenv(emailNoAuthConfigVar) if len(cfgFile) == 0 { t.Skipf("%s not set", emailNoAuthConfigVar) } c, err := loadEmailTestConfiguration(cfgFile) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) cancel() _, _, err = notifyEmailWithContext( ctx, t, &config.EmailConfig{ Smarthost: c.Smarthost, To: emailTo, From: emailFrom, HTML: "HTML body", Text: "Text body", }, c.Server, ) require.Error(t, err) require.Contains(t, err.Error(), "establish connection to server") } // TestEmailNotifyWithoutAuthentication sends an email to an instance of // MailDev configured with no authentication then it checks that the server has // successfully processed the email. func TestEmailNotifyWithoutAuthentication(t *testing.T) { cfgFile := os.Getenv(emailNoAuthConfigVar) if len(cfgFile) == 0 { t.Skipf("%s not set", emailNoAuthConfigVar) } c, err := loadEmailTestConfiguration(cfgFile) if err != nil { t.Fatal(err) } mail, _, err := notifyEmail( t, &config.EmailConfig{ Smarthost: c.Smarthost, To: emailTo, From: emailFrom, HTML: "HTML body", Text: "Text body", }, c.Server, ) require.NoError(t, err) var ( foundMsgID bool headers []string ) for k := range mail.Headers { if strings.ToLower(k) == "message-id" { foundMsgID = true break } headers = append(headers, k) } require.True(t, foundMsgID, "Couldn't find 'message-id' in %v", headers) } // TestEmailNotifyWithSTARTTLS connects to the server, upgrades the connection // to TLS, sends an email then it checks that the server has successfully // processed the email. // MailDev doesn't support STARTTLS and authentication at the same time so it // is the only way to test successful STARTTLS. func TestEmailNotifyWithSTARTTLS(t *testing.T) { t.Skip("Skipping test as STARTTLS is funky with MailDev, see https://github.com/maildev/maildev/pull/469") cfgFile := os.Getenv(emailNoAuthConfigVar) if len(cfgFile) == 0 { t.Skipf("%s not set", emailNoAuthConfigVar) } c, err := loadEmailTestConfiguration(cfgFile) if err != nil { t.Fatal(err) } trueVar := true _, _, err = notifyEmail( t, &config.EmailConfig{ Smarthost: c.Smarthost, To: emailTo, From: emailFrom, HTML: "HTML body", Text: "Text body", RequireTLS: &trueVar, // MailDev embeds a self-signed certificate which can't be retrieved. TLSConfig: &commoncfg.TLSConfig{InsecureSkipVerify: true}, }, c.Server, ) require.NoError(t, err) } // TestEmailNotifyWithAuthentication sends emails to an instance of MailDev // configured with authentication. func TestEmailNotifyWithAuthentication(t *testing.T) { cfgFile := os.Getenv(emailAuthConfigVar) if len(cfgFile) == 0 { t.Skipf("%s not set", emailAuthConfigVar) } c, err := loadEmailTestConfiguration(cfgFile) if err != nil { t.Fatal(err) } td := t.TempDir() fileWithCorrectPassword, err := os.CreateTemp(td, "smtp-password-correct") require.NoError(t, err, "creating temp file failed") _, err = fileWithCorrectPassword.WriteString(c.Password) require.NoError(t, err, "writing to temp file failed") fileWithIncorrectPassword, err := os.CreateTemp(td, "smtp-password-incorrect") require.NoError(t, err, "creating temp file failed") _, err = fileWithIncorrectPassword.WriteString(c.Password + "wrong") require.NoError(t, err, "writing to temp file failed") for _, tc := range []struct { title string updateCfg func(*config.EmailConfig) errMsg string retry bool }{ { title: "email with authentication", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPassword = commoncfg.Secret(c.Password) }, }, { title: "email with authentication (password from file)", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPasswordFile = fileWithCorrectPassword.Name() }, }, { title: "HTML-only email", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPassword = commoncfg.Secret(c.Password) cfg.Text = "" }, }, { title: "text-only email", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPassword = commoncfg.Secret(c.Password) cfg.HTML = "" }, }, { title: "multiple To addresses", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPassword = commoncfg.Secret(c.Password) cfg.To = strings.Join([]string{emailTo, emailFrom}, ",") }, }, { title: "no more than one From address", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPassword = commoncfg.Secret(c.Password) cfg.From = strings.Join([]string{emailFrom, emailTo}, ",") }, errMsg: "must be exactly one 'from' address", retry: false, }, { title: "wrong credentials", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPassword = commoncfg.Secret(c.Password + "wrong") }, errMsg: "Invalid username or password", retry: true, }, { title: "wrong credentials (password from file)", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPasswordFile = fileWithIncorrectPassword.Name() }, errMsg: "Invalid username or password", retry: true, }, { title: "wrong credentials (missing password file)", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPasswordFile = "/does/not/exist" }, errMsg: "could not read", retry: true, }, { title: "no credentials", errMsg: "authentication Required", retry: true, }, { title: "try to enable STARTTLS", updateCfg: func(cfg *config.EmailConfig) { cfg.RequireTLS = new(bool) *cfg.RequireTLS = true }, errMsg: "does not advertise the STARTTLS extension", retry: true, }, { title: "invalid Hello string", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthUsername = c.Username cfg.AuthPassword = commoncfg.Secret(c.Password) cfg.Hello = "invalid hello string" }, errMsg: "501 Error", retry: true, }, } { t.Run(tc.title, func(t *testing.T) { emailCfg := &config.EmailConfig{ Smarthost: c.Smarthost, To: emailTo, From: emailFrom, HTML: "HTML body", Text: "Text body", Headers: map[string]string{ "Subject": "{{ len .Alerts }} {{ .Status }} alert(s)", }, } if tc.updateCfg != nil { tc.updateCfg(emailCfg) } e, retry, err := notifyEmail(t, emailCfg, c.Server) if len(tc.errMsg) > 0 { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) require.Equal(t, tc.retry, retry) return } require.NoError(t, err) require.Equal(t, "1 firing alert(s)", e.Subject) getAddresses := func(addresses []map[string]string) []string { res := make([]string, 0, len(addresses)) for _, addr := range addresses { res = append(res, addr["address"]) } return res } to := getAddresses(e.To) from := getAddresses(e.From) require.Equal(t, strings.Split(emailCfg.To, ","), to) require.Equal(t, strings.Split(emailCfg.From, ","), from) if len(emailCfg.HTML) > 0 { require.Equal(t, emailCfg.HTML, *e.HTML) } else { require.Nil(t, e.HTML) } if len(emailCfg.Text) > 0 { require.Equal(t, emailCfg.Text, *e.Text) } else { require.Nil(t, e.Text) } }) } } func TestEmailConfigNoAuthMechs(t *testing.T) { email := &Email{ conf: &config.EmailConfig{AuthUsername: "test"}, tmpl: &template.Template{}, logger: promslog.NewNopLogger(), } _, err := email.auth("") require.Error(t, err) require.Equal(t, "unknown auth mechanism: ", err.Error()) } func TestEmailConfigMissingAuthParam(t *testing.T) { conf := &config.EmailConfig{AuthUsername: "test"} email := &Email{ conf: conf, tmpl: &template.Template{}, logger: promslog.NewNopLogger(), } _, err := email.auth("CRAM-MD5") require.Error(t, err) require.Equal(t, "missing secret for CRAM-MD5 auth mechanism", err.Error()) _, err = email.auth("PLAIN") require.Error(t, err) require.Equal(t, "missing password for PLAIN auth mechanism", err.Error()) _, err = email.auth("LOGIN") require.Error(t, err) require.Equal(t, "missing password for LOGIN auth mechanism", err.Error()) _, err = email.auth("PLAIN LOGIN") require.Error(t, err) require.Equal(t, "missing password for PLAIN auth mechanism\nmissing password for LOGIN auth mechanism", err.Error()) } func TestEmailNoUsernameStillOk(t *testing.T) { email := &Email{ conf: &config.EmailConfig{}, tmpl: &template.Template{}, logger: promslog.NewNopLogger(), } a, err := email.auth("CRAM-MD5") require.NoError(t, err) require.Nil(t, a) } // TestEmailRejected simulates the failure of an otherwise valid message submission which fails at a later point than // was previously expected by the code. func TestEmailRejected(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) t.Cleanup(cancel) // Setup mock SMTP server which will reject at the DATA stage. srv, l, err := mockSMTPServer(t) require.NoError(t, err) t.Cleanup(func() { // We expect that the server has already been closed in the test. require.ErrorIs(t, srv.Shutdown(ctx), smtp.ErrServerClosed) }) done := make(chan any, 1) go func() { // nolint:testifylint // require cannot be called outside the main goroutine: https://pkg.go.dev/testing#T.FailNow assert.NoError(t, srv.Serve(l)) close(done) }() // Wait for mock SMTP server to become ready. require.Eventuallyf(t, func() bool { c, err := smtp.Dial(srv.Addr) if err != nil { t.Logf("dial failed to %q: %s", srv.Addr, err) return false } // Ping. if err = c.Noop(); err != nil { t.Logf("ping failed to %q: %s", srv.Addr, err) return false } // Ensure we close the connection to not prevent server from shutting down cleanly. if err = c.Close(); err != nil { t.Logf("close failed to %q: %s", srv.Addr, err) return false } return true }, time.Second*10, time.Millisecond*100, "mock SMTP server failed to start") // Use mock SMTP server and prepare alert to be sent. require.IsType(t, &net.TCPAddr{}, l.Addr()) addr := l.Addr().(*net.TCPAddr) cfg := &config.EmailConfig{ Smarthost: config.HostPort{Host: addr.IP.String(), Port: strconv.Itoa(addr.Port)}, Hello: "localhost", Headers: make(map[string]string), From: "alertmanager@system", To: "sre@company", } tmpl, firingAlert, err := prepare(cfg) require.NoError(t, err) e := New(cfg, tmpl, promslog.NewNopLogger()) // Send the alert to mock SMTP server. retry, err := e.Notify(context.Background(), firingAlert) require.ErrorContains(t, err, "501 5.5.4 Rejected!") require.True(t, retry) require.NoError(t, srv.Shutdown(ctx)) require.Eventuallyf(t, func() bool { <-done return true }, time.Second*10, time.Millisecond*100, "mock SMTP server goroutine failed to close in time") } func mockSMTPServer(t *testing.T) (*smtp.Server, net.Listener, error) { t.Helper() // Listen on the next available high port. l, err := net.Listen("tcp", "localhost:0") if err != nil { return nil, nil, fmt.Errorf("connect: %w", err) } addr, ok := l.Addr().(*net.TCPAddr) if !ok { return nil, nil, fmt.Errorf("unexpected address type: %T", l.Addr()) } s := smtp.NewServer(&rejectingBackend{}) s.Addr = addr.String() s.WriteTimeout = 10 * time.Second s.ReadTimeout = 10 * time.Second return s, l, nil } // rejectingBackend will reject submission at the DATA stage. type rejectingBackend struct{} func (b *rejectingBackend) NewSession(c *smtp.Conn) (smtp.Session, error) { return &mockSMTPSession{ conn: c, backend: b, }, nil } type mockSMTPSession struct { conn *smtp.Conn backend smtp.Backend } func (s *mockSMTPSession) Mail(string, *smtp.MailOptions) error { return nil } func (s *mockSMTPSession) Rcpt(string, *smtp.RcptOptions) error { return nil } func (s *mockSMTPSession) Data(io.Reader) error { return &smtp.SMTPError{Code: 501, EnhancedCode: smtp.EnhancedCode{5, 5, 4}, Message: "Rejected!"} } func (*mockSMTPSession) Reset() {} func (*mockSMTPSession) Logout() error { return nil } func TestEmailNotifyWithThreading(t *testing.T) { cfgFile := os.Getenv(emailNoAuthConfigVar) if len(cfgFile) == 0 { t.Skipf("%s not set", emailNoAuthConfigVar) } c, err := loadEmailTestConfiguration(cfgFile) if err != nil { t.Fatal(err) } for _, tc := range []struct { name string threadByDate string wantDatePart bool }{ { name: "threading with daily date (default)", threadByDate: "", wantDatePart: true, }, { name: "threading with explicit daily", threadByDate: "daily", wantDatePart: true, }, { name: "threading without date", threadByDate: "none", wantDatePart: false, }, } { t.Run(tc.name, func(t *testing.T) { // Create context with group key (required for threading). ctx := notify.WithGroupKey(context.Background(), "test-group-key") emailCfg := &config.EmailConfig{ Smarthost: c.Smarthost, To: emailTo, From: emailFrom, HTML: "HTML body", Text: "Text body", Threading: config.ThreadingConfig{ Enabled: true, ThreadByDate: tc.threadByDate, }, } mail, _, err := notifyEmailWithContext(ctx, t, emailCfg, c.Server) require.NoError(t, err) referencesValue := mail.Headers["references"] inReplyToValue := mail.Headers["in-reply-to"] require.NotEmpty(t, referencesValue, "References header not found in %v", mail.Headers) require.NotEmpty(t, inReplyToValue, "In-Reply-To header not found in %v", mail.Headers) require.Equal(t, referencesValue, inReplyToValue, "References and In-Reply-To should match") // Verify the format: require.Contains(t, referencesValue, "") if tc.wantDatePart { today := time.Now().Format("2006-01-02") require.Contains(t, referencesValue, today, "threading header should contain today's date") } else { // With thread_by_date: none, there should be no date // (empty string between hash and @). require.Contains(t, referencesValue, "-@alertmanager>", "threading header should have empty date part") } }) } } func TestEmailGetPassword(t *testing.T) { passwordFile, err := os.CreateTemp("", "smtp-password") require.NoError(t, err, "creating temp file failed") _, err = passwordFile.WriteString("secret") require.NoError(t, err, "writing to temp file failed") for _, tc := range []struct { title string updateCfg func(*config.EmailConfig) errMsg string }{ { title: "password from field", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthPassword = "secret" cfg.AuthPasswordFile = "" }, }, { title: "password from file field", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthPassword = "" cfg.AuthPasswordFile = passwordFile.Name() }, }, { title: "password file path incorrect", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthPassword = "" cfg.AuthPasswordFile = "/does/not/exist" }, errMsg: "could not read", }, } { t.Run(tc.title, func(t *testing.T) { email := &Email{ conf: &config.EmailConfig{}, } tc.updateCfg(email.conf) password, err := email.getPassword() if len(tc.errMsg) > 0 { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) require.Empty(t, password) } else { require.NoError(t, err) require.Equal(t, "secret", password) } }) } } func TestEmailGetSecret(t *testing.T) { secretFile, err := os.CreateTemp("", "smtp-password") require.NoError(t, err, "creating temp file failed") _, err = secretFile.WriteString("secret") require.NoError(t, err, "writing to temp file failed") for _, tc := range []struct { title string updateCfg func(*config.EmailConfig) errMsg string }{ { title: "secret from field", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthSecret = "secret" cfg.AuthSecretFile = "" }, }, { title: "secret from file field", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthSecret = "" cfg.AuthSecretFile = secretFile.Name() }, }, { title: "secret file path incorrect", updateCfg: func(cfg *config.EmailConfig) { cfg.AuthSecret = "" cfg.AuthSecretFile = "/does/not/exist" }, errMsg: "could not read", }, } { t.Run(tc.title, func(t *testing.T) { email := &Email{ conf: &config.EmailConfig{}, } tc.updateCfg(email.conf) secret, err := email.getAuthSecret() if len(tc.errMsg) > 0 { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) require.Empty(t, secret) } else { require.NoError(t, err) require.Equal(t, "secret", secret) } }) } } func TestEmailImplicitTLS(t *testing.T) { tests := []struct { name string port string forceImplicitTLS *bool expectImplicit bool }{ { name: "default behavior - port 465", port: "465", forceImplicitTLS: nil, expectImplicit: true, }, { name: "default behavior - port 587", port: "587", forceImplicitTLS: nil, expectImplicit: false, }, { name: "force implicit_tls=true on port 587", port: "587", forceImplicitTLS: ptrTo(true), expectImplicit: true, }, { name: "force implicit_tls=true on custom port", port: "8465", forceImplicitTLS: ptrTo(true), expectImplicit: true, }, { name: "implicit_tls=false disables implicit TLS on port 465", port: "465", forceImplicitTLS: ptrTo(false), expectImplicit: false, }, { name: "implicit_tls=false behaves like default on port 587", port: "587", forceImplicitTLS: ptrTo(false), expectImplicit: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := &config.EmailConfig{ Smarthost: config.HostPort{Host: "localhost", Port: tt.port}, ForceImplicitTLS: tt.forceImplicitTLS, } // Simulate the judgment logic var useImplicitTLS bool if cfg.ForceImplicitTLS != nil { useImplicitTLS = *cfg.ForceImplicitTLS } else { useImplicitTLS = cfg.Smarthost.Port == "465" } require.Equal(t, tt.expectImplicit, useImplicitTLS, "Expected useImplicitTLS=%v for port=%s with forceImplicitTLS=%v", tt.expectImplicit, tt.port, tt.forceImplicitTLS) }) } } func ptrTo(b bool) *bool { return &b } ================================================ FILE: notify/email/testdata/auth-local.yml ================================================ smarthost: 127.0.0.1:1026 server: http://127.0.0.1:1081/ username: user password: pass ================================================ FILE: notify/email/testdata/auth.yml ================================================ smarthost: maildev-auth:1025 server: http://maildev-auth:1080/ username: user password: pass ================================================ FILE: notify/email/testdata/noauth-local.yml ================================================ smarthost: 127.0.0.1:1025 server: http://127.0.0.1:1080/ ================================================ FILE: notify/email/testdata/noauth.yml ================================================ smarthost: maildev-noauth:1025 server: http://maildev-noauth:1080/ ================================================ FILE: notify/incidentio/incidentio.go ================================================ // Copyright 2025 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package incidentio import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( // MaxPayloadSize is the maximum size of the JSON payload incident.io accepts (512KB). maxPayloadSize = 512 * 1024 ) // Notifier implements a Notifier for incident.io. type Notifier struct { conf *config.IncidentioConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier } // New returns a new incident.io notifier. func New(conf *config.IncidentioConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { // conf.HTTPConfig is likely to be the global shared HTTPConfig, so we take a // copy to avoid modifying it. httpConfig := *conf.HTTPConfig // If an alert source token is provided, we use that one instead of whatever configuration is included in `http_config`. var token string if conf.AlertSourceToken != "" { token = string(conf.AlertSourceToken) } if conf.AlertSourceTokenFile != "" { content, err := os.ReadFile(conf.AlertSourceTokenFile) if err != nil { return nil, fmt.Errorf("failed to read alert_source_token_file: %w", err) } token = strings.TrimSpace(string(content)) } if token != "" { httpConfig.Authorization = &commoncfg.Authorization{ Type: "Bearer", Credentials: commoncfg.Secret(token), } } client, err := notify.NewClientWithTracing(httpConfig, "incidentio", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: conf, tmpl: t, logger: l, client: client, // Always retry on 429 (rate limiting) and 5xx response codes. retrier: ¬ify.Retrier{ RetryCodes: []int{http.StatusTooManyRequests}, CustomDetailsFunc: errDetails, }, }, nil } // Message defines the JSON object sent to incident.io endpoints. type Message struct { *template.Data // The protocol version. Version string `json:"version"` GroupKey string `json:"groupKey"` TruncatedAlerts uint64 `json:"truncatedAlerts"` } func truncateAlerts(maxAlerts uint64, alerts []*types.Alert) ([]*types.Alert, uint64) { if maxAlerts != 0 && uint64(len(alerts)) > maxAlerts { return alerts[:maxAlerts], uint64(len(alerts)) - maxAlerts } return alerts, 0 } // encodeMessage encodes the message and drops all alerts except the first one if it exceeds maxPayloadSize. func (n *Notifier) encodeMessage(msg *Message) (bytes.Buffer, error) { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(msg); err != nil { return buf, fmt.Errorf("failed to encode incident.io message: %w", err) } if buf.Len() <= maxPayloadSize { return buf, nil } originalSize := buf.Len() // Drop all but the first alert in the message. For most use cases, a single // alert will be created in incident.io for the group, so including more than // one alert in that group is useful but non-essential. msg.Alerts = msg.Alerts[:1] // Re-encode after annotation truncation buf.Reset() if err := json.NewEncoder(&buf).Encode(msg); err != nil { return buf, fmt.Errorf("failed to encode incident.io message after annotation truncation: %w", err) } if buf.Len() <= maxPayloadSize { n.logger.Warn("Truncated alert content due to incident.io payload size limit", "original_size", originalSize, "final_size", buf.Len(), "max_size", maxPayloadSize) return buf, nil } // Still attempt to send the message even if it exceeds the limit, but log an // error to explain why this is likely to fail. n.logger.Error("Truncated alert content due to incident.io payload size limit, but still exceeds limit", "original_size", originalSize, "final_size", buf.Len(), "max_size", maxPayloadSize) return buf, nil } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { alerts, numTruncated := truncateAlerts(n.conf.MaxAlerts, alerts) data := notify.GetTemplateData(ctx, n.tmpl, alerts, n.logger) groupKey, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } n.logger.Debug("incident.io notification", "groupKey", groupKey) msg := &Message{ Version: "1", Data: data, GroupKey: groupKey.String(), TruncatedAlerts: numTruncated, } buf, err := n.encodeMessage(msg) if err != nil { return false, err } var url string if n.conf.URL != nil { url = n.conf.URL.String() } else { content, err := os.ReadFile(n.conf.URLFile) if err != nil { return false, fmt.Errorf("read url_file: %w", err) } url = strings.TrimSpace(string(content)) } if n.conf.Timeout > 0 { ctxWithTimeout, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured incident.io timeout reached (%s)", n.conf.Timeout)) defer cancel() ctx = ctxWithTimeout } resp, err := notify.PostJSON(ctx, n.client, url, &buf) if err != nil { if ctx.Err() != nil { err = fmt.Errorf("%w: %w", err, context.Cause(ctx)) } return true, notify.RedactURL(err) } defer notify.Drain(resp) shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } return shouldRetry, err } // errDetails extracts error details from the response for better error messages. func errDetails(_ int, body io.Reader) string { if body == nil { return "" } // Try to decode the error message from JSON response var errorResponse struct { Message string `json:"message"` Errors []string `json:"errors"` Error string `json:"error"` } if err := json.NewDecoder(body).Decode(&errorResponse); err != nil { return "" } var parts []string if errorResponse.Message != "" { parts = append(parts, errorResponse.Message) } if errorResponse.Error != "" { parts = append(parts, errorResponse.Error) } if len(errorResponse.Errors) > 0 { parts = append(parts, strings.Join(errorResponse.Errors, ", ")) } return strings.Join(parts, ": ") } ================================================ FILE: notify/incidentio/incidentio_test.go ================================================ // Copyright 2025 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package incidentio import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) func TestIncidentIORetry(t *testing.T) { notifier, err := New( &config.IncidentioConfig{ URL: &amcommoncfg.URL{URL: &url.URL{Scheme: "https", Host: "example.com"}}, HTTPConfig: &commoncfg.HTTPClientConfig{}, AlertSourceToken: "test-token", }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests) for statusCode, expected := range test.RetryTests(retryCodes) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "retry - error on status %d", statusCode) } } func TestIncidentIORedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() notifier, err := New( &config.IncidentioConfig{ URL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, AlertSourceToken: "test-token", }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } func TestIncidentIOURLFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() f, err := os.CreateTemp(t.TempDir(), "incidentio_test") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(u.String() + "\n") require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.IncidentioConfig{ URLFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, AlertSourceToken: "test-token", }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } func TestIncidentIOTruncateAlerts(t *testing.T) { alerts := make([]*types.Alert, 10) truncatedAlerts, numTruncated := truncateAlerts(0, alerts) require.Len(t, truncatedAlerts, 10) require.EqualValues(t, 0, numTruncated) truncatedAlerts, numTruncated = truncateAlerts(4, alerts) require.Len(t, truncatedAlerts, 4) require.EqualValues(t, 6, numTruncated) truncatedAlerts, numTruncated = truncateAlerts(100, alerts) require.Len(t, truncatedAlerts, 10) require.EqualValues(t, 0, numTruncated) } func TestIncidentIONotify(t *testing.T) { // Test regular notifications are correctly sent server := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // Verify the content type header contentType := r.Header.Get("Content-Type") require.Equal(t, "application/json", contentType) // Decode the webhook payload var msg Message require.NoError(t, json.NewDecoder(r.Body).Decode(&msg)) // Verify required fields require.Equal(t, "1", msg.Version) require.NotEmpty(t, msg.GroupKey) w.WriteHeader(http.StatusOK) }, )) defer server.Close() u, err := url.Parse(server.URL) require.NoError(t, err) notifier, err := New( &config.IncidentioConfig{ URL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, AlertSourceToken: "test-token", }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "severity": "critical", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } retry, err := notifier.Notify(ctx, alert) require.NoError(t, err) require.False(t, retry) } func TestIncidentIORetryScenarios(t *testing.T) { testCases := []struct { name string statusCode int responseBody []byte expectRetry bool expectErrorMsgContains string }{ { name: "success response", statusCode: http.StatusOK, responseBody: []byte(`{"status":"success"}`), expectRetry: false, expectErrorMsgContains: "", }, { name: "rate limit response", statusCode: http.StatusTooManyRequests, responseBody: []byte(`{"error":"rate limit exceeded","message":"Too many requests"}`), expectRetry: true, expectErrorMsgContains: "rate limit exceeded", }, { name: "server error response", statusCode: http.StatusInternalServerError, responseBody: []byte(`{"error":"internal error"}`), expectRetry: true, expectErrorMsgContains: "internal error", }, { name: "client error response", statusCode: http.StatusBadRequest, responseBody: []byte(`{"error":"invalid request","message":"Invalid payload format"}`), expectRetry: false, expectErrorMsgContains: "invalid request", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.statusCode) w.Write(tc.responseBody) }, )) defer server.Close() u, err := url.Parse(server.URL) require.NoError(t, err) notifier, err := New( &config.IncidentioConfig{ URL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, AlertSourceToken: "test-token", }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "TestAlert", "severity": "critical", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } retry, err := notifier.Notify(ctx, alert) if tc.expectErrorMsgContains == "" { require.NoError(t, err) } else { require.Error(t, err) require.Contains(t, err.Error(), tc.expectErrorMsgContains) } require.Equal(t, tc.expectRetry, retry) }) } } func TestIncidentIOErrDetails(t *testing.T) { for _, tc := range []struct { name string status int body io.Reader expect string }{ { name: "empty body", status: http.StatusBadRequest, body: nil, expect: "", }, { name: "single error field", status: http.StatusBadRequest, body: bytes.NewBufferString(`{"error":"Invalid request"}`), expect: "Invalid request", }, { name: "message and errors", status: http.StatusBadRequest, body: bytes.NewBufferString(`{"message":"Validation failed","errors":["Field is required","Value too long"]}`), expect: "Validation failed: Field is required, Value too long", }, { name: "message and error", status: http.StatusTooManyRequests, body: bytes.NewBufferString(`{"message":"Too many requests","error":"Rate limit exceeded"}`), expect: "Too many requests: Rate limit exceeded", }, { name: "invalid JSON", status: http.StatusBadRequest, body: bytes.NewBufferString(`{invalid}`), expect: "", }, } { t.Run(tc.name, func(t *testing.T) { result := errDetails(tc.status, tc.body) if tc.expect == "" { require.Empty(t, result) } else { require.Contains(t, result, tc.expect) } }) } } func TestIncidentIOPayloadTruncation(t *testing.T) { logger := promslog.NewNopLogger() notifier, err := New( &config.IncidentioConfig{ URL: &amcommoncfg.URL{URL: &url.URL{Scheme: "https", Host: "example.com"}}, HTTPConfig: &commoncfg.HTTPClientConfig{}, AlertSourceToken: "test-token", }, test.CreateTmpl(t), logger, ) require.NoError(t, err) // Create a large annotation that will push payload over 512KB largeAnnotation := make([]byte, 100*1024) // 100KB per annotation for i := range largeAnnotation { largeAnnotation[i] = 'a' + byte(i%26) } largeAnnotationStr := string(largeAnnotation) // Create alerts with large annotations var alerts []*types.Alert for i := range 10 { // 10 alerts * 100KB = 1MB total in annotations alone alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": model.LabelValue("TestAlert" + string(rune('0'+i))), "severity": "critical", "job": "test-job", "instance": "test-instance", "env": "production", "team": "sre", }, Annotations: model.LabelSet{ "description": model.LabelValue(largeAnnotationStr), "runbook": model.LabelValue(largeAnnotationStr), "summary": model.LabelValue("This is a test alert with very large annotations"), }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } alerts = append(alerts, alert) } // Create template data ctx := context.Background() ctx = notify.WithGroupKey(ctx, "test-group") data := notify.GetTemplateData(ctx, test.CreateTmpl(t), alerts, logger) // Create message msg := &Message{ Version: "1", Data: data, GroupKey: "test-group", TruncatedAlerts: 0, } // Test encoding with truncation buf, err := notifier.encodeMessage(msg) require.NoError(t, err) // Verify the encoded message is under the size limit require.LessOrEqual(t, buf.Len(), maxPayloadSize, "Encoded message should be under maxPayloadSize after truncation") // Decode the message to verify truncation happened var decodedMsg Message err = json.NewDecoder(&buf).Decode(&decodedMsg) require.NoError(t, err) // Check that all but the first alert was dropped require.Len(t, decodedMsg.Alerts, 1, "Only the first alert should be included after truncation") } func TestIncidentIOPayloadTruncationWithLabelTruncation(t *testing.T) { // Test extreme case where even after annotation truncation, labels need to be truncated logger := promslog.NewNopLogger() notifier, err := New( &config.IncidentioConfig{ URL: &amcommoncfg.URL{URL: &url.URL{Scheme: "https", Host: "example.com"}}, HTTPConfig: &commoncfg.HTTPClientConfig{}, AlertSourceToken: "test-token", }, test.CreateTmpl(t), logger, ) require.NoError(t, err) // Create many alerts with many labels to push size over limit even without annotations var alerts []*types.Alert for i := range 100 { // Many alerts labels := model.LabelSet{ "alertname": model.LabelValue("TestAlert" + string(rune('0'+i%10))), "severity": "critical", "job": "test-job", "instance": "test-instance", } // Add many extra labels with long values for j := range 50 { labelName := model.LabelName("label_" + string(rune('a'+j%26)) + "_" + string(rune('0'+j/26))) labelValue := make([]byte, 1024) // 1KB per label value for k := range labelValue { labelValue[k] = 'x' } labels[labelName] = model.LabelValue(labelValue) } alert := &types.Alert{ Alert: model.Alert{ Labels: labels, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } alerts = append(alerts, alert) } // Create template data ctx := context.Background() ctx = notify.WithGroupKey(ctx, "test-group") data := notify.GetTemplateData(ctx, test.CreateTmpl(t), alerts, logger) // Create message msg := &Message{ Version: "1", Data: data, GroupKey: "test-group", TruncatedAlerts: 0, } // Test encoding with truncation buf, err := notifier.encodeMessage(msg) require.NoError(t, err) // Verify the encoded message is under the size limit require.LessOrEqual(t, buf.Len(), maxPayloadSize, "Encoded message should be under maxPayloadSize after label truncation") // Decode the message to verify truncation happened var decodedMsg Message err = json.NewDecoder(&buf).Decode(&decodedMsg) require.NoError(t, err) // Since we have a lot of alerts with large labels, the encoding might have reduced the number of alerts // Check that we have fewer alerts if truncation occurred require.LessOrEqual(t, len(decodedMsg.Alerts), 100, "Number of alerts may have been reduced") // Check that essential labels are preserved in remaining alerts for _, alert := range decodedMsg.Alerts { // Essential labels should be preserved require.Contains(t, alert.Labels["alertname"], "TestAlert") require.Equal(t, "critical", alert.Labels["severity"]) require.Equal(t, "test-job", alert.Labels["job"]) require.Equal(t, "test-instance", alert.Labels["instance"]) // Check if labels were truncated (will have truncated_labels marker) or if we still have all labels if truncatedLabels, ok := alert.Labels["truncated_labels"]; ok && truncatedLabels == "true" { // Non-essential labels should be removed for k := range alert.Labels { if k != "alertname" && k != "severity" && k != "job" && k != "instance" && k != "truncated_labels" { t.Errorf("Found non-essential label %s that should have been truncated", k) } } } } } ================================================ FILE: notify/jira/jira.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package jira import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "sort" "strings" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( maxSummaryLenRunes = 255 maxDescriptionLenRunes = 32767 ) // Notifier implements a Notifier for JIRA notifications. type Notifier struct { conf *config.JiraConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier } func New(c *config.JiraConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "jira", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, }, nil } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key.String()) logger.Debug("extracted group key") var ( alerts = types.Alerts(as...) tmplTextErr error data = notify.GetTemplateData(ctx, n.tmpl, as, logger) tmplText = notify.TmplText(n.tmpl, data, &tmplTextErr) tmplTextFunc = func(tmpl string) (string, error) { return tmplText(tmpl), tmplTextErr } path = "issue" method = http.MethodPost ) existingIssue, shouldRetry, err := n.searchExistingIssue(ctx, logger, key.Hash(), alerts.HasFiring(), tmplTextFunc) if err != nil { return shouldRetry, fmt.Errorf("failed to look up existing issues: %w", err) } if existingIssue == nil { // Do not create new issues for resolved alerts if alerts.Status() == model.AlertResolved { return false, nil } logger.Debug("create new issue") } else { path = "issue/" + existingIssue.Key method = http.MethodPut logger.Debug("updating existing issue", "issue_key", existingIssue.Key, "summary_update_enabled", n.conf.Summary.EnableUpdateValue(), "description_update_enabled", n.conf.Description.EnableUpdateValue()) } requestBody, err := n.prepareIssueRequestBody(ctx, logger, key.Hash(), tmplTextFunc) if err != nil { return false, err } if method == http.MethodPut && requestBody.Fields != nil { if !n.conf.Description.EnableUpdateValue() { requestBody.Fields.Description = nil } if !n.conf.Summary.EnableUpdateValue() { requestBody.Fields.Summary = nil } } _, shouldRetry, err = n.doAPIRequest(ctx, method, path, requestBody) if err != nil { return shouldRetry, fmt.Errorf("failed to %s request to %q: %w", method, path, err) } return n.transitionIssue(ctx, logger, existingIssue, alerts.HasFiring()) } func (n *Notifier) prepareIssueRequestBody(_ context.Context, logger *slog.Logger, groupID string, tmplTextFunc template.TemplateFunc) (issue, error) { summary, err := tmplTextFunc(n.conf.Summary.Template) if err != nil { return issue{}, fmt.Errorf("summary template: %w", err) } project, err := tmplTextFunc(n.conf.Project) if err != nil { return issue{}, fmt.Errorf("project template: %w", err) } issueType, err := tmplTextFunc(n.conf.IssueType) if err != nil { return issue{}, fmt.Errorf("issue_type template: %w", err) } fieldsWithStringKeys := make(map[string]any, len(n.conf.Fields)) for key, value := range n.conf.Fields { fieldsWithStringKeys[key], err = template.DeepCopyWithTemplate(value, tmplTextFunc) if err != nil { return issue{}, fmt.Errorf("fields template: %w", err) } } summary, truncated := notify.TruncateInRunes(summary, maxSummaryLenRunes) if truncated { logger.Warn("Truncated summary", "max_runes", maxSummaryLenRunes) } requestBody := issue{Fields: &issueFields{ Project: &issueProject{Key: project}, Issuetype: &idNameValue{Name: issueType}, Summary: &summary, Labels: make([]string, 0, len(n.conf.Labels)+1), Fields: fieldsWithStringKeys, }} issueDescriptionString, err := tmplTextFunc(n.conf.Description.Template) if err != nil { return issue{}, fmt.Errorf("description template: %w", err) } issueDescriptionString, truncated = notify.TruncateInRunes(issueDescriptionString, maxDescriptionLenRunes) if truncated { logger.Warn("Truncated description", "max_runes", maxDescriptionLenRunes) } var description *jiraDescription descriptionCopy := issueDescriptionString if isAPIv3Path(n.conf.APIURL.Path) { descriptionCopy = strings.TrimSpace(descriptionCopy) if descriptionCopy != "" { if !json.Valid([]byte(descriptionCopy)) { return issue{}, fmt.Errorf("description template: invalid JSON for API v3") } raw := json.RawMessage(descriptionCopy) description = &jiraDescription{ RawJSONDescription: append(json.RawMessage(nil), raw...), } } } else if descriptionCopy != "" { desc := descriptionCopy description = &jiraDescription{StringDescription: &desc} } requestBody.Fields.Description = description for i, label := range n.conf.Labels { label, err = tmplTextFunc(label) if err != nil { return issue{}, fmt.Errorf("labels[%d] template: %w", i, err) } requestBody.Fields.Labels = append(requestBody.Fields.Labels, label) } requestBody.Fields.Labels = append(requestBody.Fields.Labels, fmt.Sprintf("ALERT{%s}", groupID)) sort.Strings(requestBody.Fields.Labels) priority, err := tmplTextFunc(n.conf.Priority) if err != nil { return issue{}, fmt.Errorf("priority template: %w", err) } if priority != "" { requestBody.Fields.Priority = &idNameValue{Name: priority} } return requestBody, nil } func (n *Notifier) searchExistingIssue(ctx context.Context, logger *slog.Logger, groupID string, firing bool, tmplTextFunc template.TemplateFunc) (*issue, bool, error) { jql := strings.Builder{} if n.conf.WontFixResolution != "" { fmt.Fprintf(&jql, `resolution != %q and `, n.conf.WontFixResolution) } // If the group is firing, search for open issues. If a reopen transition is // defined, also search for issues that were closed within the reopen duration. if firing { reopenDuration := int64(time.Duration(n.conf.ReopenDuration).Minutes()) if n.conf.ReopenTransition != "" && reopenDuration > 0 { fmt.Fprintf(&jql, `(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenDuration) } else { jql.WriteString(`statusCategory != Done and `) } } else { jql.WriteString(`statusCategory != Done and `) } alertLabel := fmt.Sprintf("ALERT{%s}", groupID) project, err := tmplTextFunc(n.conf.Project) if err != nil { return nil, false, fmt.Errorf("invalid project template or value: %w", err) } fmt.Fprintf(&jql, `project=%q and labels=%q order by status ASC,resolutiondate DESC`, project, alertLabel) requestBody, searchPath := n.prepareSearchRequest(jql.String()) logger.Debug("search for recent issues", "jql", jql.String()) responseBody, shouldRetry, err := n.doAPIRequestFullPath(ctx, http.MethodPost, searchPath, requestBody) if err != nil { return nil, shouldRetry, fmt.Errorf("HTTP request to JIRA API: %w", err) } var issueSearchResult issueSearchResult err = json.Unmarshal(responseBody, &issueSearchResult) if err != nil { return nil, false, err } issuesCount := len(issueSearchResult.Issues) if issuesCount == 0 { logger.Debug("found no existing issue") return nil, false, nil } if issuesCount > 1 { logger.Warn("more than one issue matched, selecting the most recently resolved", "selected_issue", issueSearchResult.Issues[0].Key) } return &issueSearchResult.Issues[0], false, nil } // prepareSearchRequest builds the request body and search path for Jira issue search. // // Atlassian announced (see https://developer.atlassian.com/changelog/#CHANGE-2046) that // the legacy /search endpoint is no longer available on Jira Cloud. The replacement // endpoint (/rest/api/3/search/jql) is currently not available in Jira Data Center. // // Selection logic: // - If APIType is "datacenter", always use the v2 /search endpoint. // - If APIType is "cloud", or if APIType is "auto" and the host ends with // "atlassian.net", use the v3 /search/jql endpoint. // - Otherwise (APIType is "auto" without an atlassian.net host), // use the v2 /search endpoint. func (n *Notifier) prepareSearchRequest(jql string) (issueSearch, string) { requestBody := issueSearch{ JQL: jql, MaxResults: 2, Fields: []string{"status"}, } if n.conf.APIType == "datacenter" { searchPath := n.conf.APIURL.JoinPath("/search").String() return requestBody, searchPath } if n.conf.APIType == "cloud" || n.conf.APIType == "auto" && strings.HasSuffix(n.conf.APIURL.Host, "atlassian.net") { searchPath := strings.Replace(n.conf.APIURL.JoinPath("/search/jql").String(), "/rest/api/2/", "/rest/api/3/", 1) return requestBody, searchPath } searchPath := n.conf.APIURL.JoinPath("/search").String() return requestBody, searchPath } func (n *Notifier) getIssueTransitionByName(ctx context.Context, issueKey, transitionName string) (string, bool, error) { path := fmt.Sprintf("issue/%s/transitions", issueKey) responseBody, shouldRetry, err := n.doAPIRequest(ctx, http.MethodGet, path, nil) if err != nil { return "", shouldRetry, err } var issueTransitions issueTransitions err = json.Unmarshal(responseBody, &issueTransitions) if err != nil { return "", false, err } for _, issueTransition := range issueTransitions.Transitions { if issueTransition.Name == transitionName { return issueTransition.ID, false, nil } } return "", false, fmt.Errorf("can't find transition %s for issue %s", transitionName, issueKey) } func (n *Notifier) transitionIssue(ctx context.Context, logger *slog.Logger, i *issue, firing bool) (bool, error) { if i == nil || i.Key == "" || i.Fields == nil || i.Fields.Status == nil { return false, nil } var transition string if firing { if i.Fields.Status.StatusCategory.Key != "done" { return false, nil } transition = n.conf.ReopenTransition } else { if i.Fields.Status.StatusCategory.Key == "done" { return false, nil } transition = n.conf.ResolveTransition } transitionID, shouldRetry, err := n.getIssueTransitionByName(ctx, i.Key, transition) if err != nil { return shouldRetry, err } requestBody := issue{ Transition: &idNameValue{ ID: transitionID, }, } path := fmt.Sprintf("issue/%s/transitions", i.Key) logger.Debug("transitions jira issue", "issue_key", i.Key, "transition", transition) _, shouldRetry, err = n.doAPIRequest(ctx, http.MethodPost, path, requestBody) return shouldRetry, err } func (n *Notifier) doAPIRequest(ctx context.Context, method, path string, requestBody any) ([]byte, bool, error) { url := n.conf.APIURL.JoinPath(path) return n.doAPIRequestFullPath(ctx, method, url.String(), requestBody) } func (n *Notifier) doAPIRequestFullPath(ctx context.Context, method, path string, requestBody any) ([]byte, bool, error) { var body io.Reader if requestBody != nil { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(requestBody); err != nil { return nil, false, err } body = &buf } req, err := http.NewRequestWithContext(ctx, method, path, body) if err != nil { return nil, false, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept-Language", "en") resp, err := n.client.Do(req) if err != nil { return nil, false, err } defer notify.Drain(resp) responseBody, err := io.ReadAll(resp.Body) if err != nil { return nil, false, err } shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(responseBody)) if err != nil { return nil, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } return responseBody, false, nil } func isAPIv3Path(path string) bool { return strings.HasSuffix(strings.TrimRight(path, "/"), "/3") } ================================================ FILE: notify/jira/jira_test.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package jira import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) func jiraStringDescription(v string) *jiraDescription { return &jiraDescription{StringDescription: stringPtr(v)} } func stringPtr(v string) *string { return &v } func boolPtr(v bool) *bool { return &v } func TestJiraRetry(t *testing.T) { notifier, err := New( &config.JiraConfig{ APIURL: &amcommoncfg.URL{ URL: &url.URL{ Scheme: "https", Host: "example.atlassian.net", Path: "/rest/api/2", }, }, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests) for statusCode, expected := range test.RetryTests(retryCodes) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "retry - error on status %d", statusCode) } } func TestSearchExistingIssue(t *testing.T) { expectedJQL := "" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/search": body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Error reading request body", http.StatusBadRequest) return } defer r.Body.Close() // Unmarshal the JSON data into the struct var data issueSearch err = json.Unmarshal(body, &data) if err != nil { http.Error(w, "Error unmarshaling JSON", http.StatusBadRequest) return } require.Equal(t, expectedJQL, data.JQL) w.Write([]byte(`{"issues": []}`)) return default: dec := json.NewDecoder(r.Body) out := make(map[string]any) err := dec.Decode(&out) if err != nil { panic(err) } } })) defer srv.Close() u, _ := url.Parse(srv.URL) for _, tc := range []struct { title string cfg *config.JiraConfig groupKey string firing bool expectedJQL string expectedIssue *issue expectedErr bool expectedRetry bool }{ { title: "search existing issue with project template for firing alert", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Project: `{{ .CommonLabels.project }}`, }, groupKey: "1", firing: true, expectedJQL: `statusCategory != Done and project="PROJ" and labels="ALERT{1}" order by status ASC,resolutiondate DESC`, }, { title: "search existing issue with reopen duration for firing alert", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Project: `{{ .CommonLabels.project }}`, ReopenDuration: model.Duration(60 * time.Minute), ReopenTransition: "REOPEN", }, groupKey: "1", firing: true, expectedJQL: `(resolutiondate is EMPTY OR resolutiondate >= -60m) and project="PROJ" and labels="ALERT{1}" order by status ASC,resolutiondate DESC`, }, { title: "search existing issue for resolved alert", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Project: `{{ .CommonLabels.project }}`, }, groupKey: "1", firing: false, expectedJQL: `statusCategory != Done and project="PROJ" and labels="ALERT{1}" order by status ASC,resolutiondate DESC`, }, } { t.Run(tc.title, func(t *testing.T) { expectedJQL = tc.expectedJQL tc.cfg.APIURL = &amcommoncfg.URL{URL: u} tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} as := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "project": "PROJ", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, } pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) logger := pd.logger.With("group_key", tc.groupKey) ctx := notify.WithGroupKey(context.Background(), tc.groupKey) data := notify.GetTemplateData(ctx, pd.tmpl, as, logger) var tmplTextErr error tmplText := notify.TmplText(pd.tmpl, data, &tmplTextErr) tmplTextFunc := func(tmpl string) (string, error) { return tmplText(tmpl), tmplTextErr } issue, retry, err := pd.searchExistingIssue(ctx, logger, tc.groupKey, tc.firing, tmplTextFunc) if tc.expectedErr { require.Error(t, err) } else { require.NoError(t, err) } require.Equal(t, tc.expectedIssue, issue) require.Equal(t, tc.expectedRetry, retry) }) } } func TestPrepareSearchRequest(t *testing.T) { for _, tc := range []struct { title string cfg *config.JiraConfig jql string expectedBody any expectedURL string expectedURLPath string }{ { title: "cloud API type", cfg: &config.JiraConfig{ APIType: "cloud", APIURL: &amcommoncfg.URL{ URL: &url.URL{ Scheme: "https", Host: "example.atlassian.net", Path: "/rest/api/2", }, }, }, jql: "project=TEST and labels=\"ALERT{123}\"", expectedBody: issueSearch{ JQL: "project=TEST and labels=\"ALERT{123}\"", MaxResults: 2, Fields: []string{"status"}, }, expectedURL: "https://example.atlassian.net/rest/api/3/search/jql", expectedURLPath: "/rest/api/2", }, { title: "auto API type with atlassian.net url", cfg: &config.JiraConfig{ APIType: "auto", APIURL: &amcommoncfg.URL{ URL: &url.URL{ Scheme: "https", Host: "example.atlassian.net", Path: "/rest/api/2", }, }, }, jql: "project=TEST and labels=\"ALERT{123}\"", expectedBody: issueSearch{ JQL: "project=TEST and labels=\"ALERT{123}\"", MaxResults: 2, Fields: []string{"status"}, }, expectedURL: "https://example.atlassian.net/rest/api/3/search/jql", expectedURLPath: "/rest/api/2", }, { title: "auto API type without atlassian.net url", cfg: &config.JiraConfig{ APIType: "auto", APIURL: &amcommoncfg.URL{ URL: &url.URL{ Scheme: "https", Host: "jira.example.com", Path: "/rest/api/2", }, }, }, jql: "project=TEST and labels=\"ALERT{123}\"", expectedBody: issueSearch{ JQL: "project=TEST and labels=\"ALERT{123}\"", MaxResults: 2, Fields: []string{"status"}, }, expectedURL: "https://jira.example.com/rest/api/2/search", expectedURLPath: "/rest/api/2", }, { title: "atlassian.net URL suffix but datacenter api type", cfg: &config.JiraConfig{ APIType: "datacenter", APIURL: &amcommoncfg.URL{ URL: &url.URL{ Scheme: "https", Host: "example.atlassian.net", Path: "/rest/api/2", }, }, }, jql: "project=TEST and labels=\"ALERT{123}\"", expectedBody: issueSearch{ JQL: "project=TEST and labels=\"ALERT{123}\"", MaxResults: 2, Fields: []string{"status"}, }, expectedURL: "https://example.atlassian.net/rest/api/2/search", expectedURLPath: "/rest/api/2", }, { title: "datacenter API type", cfg: &config.JiraConfig{ APIType: "datacenter", APIURL: &amcommoncfg.URL{ URL: &url.URL{ Scheme: "https", Host: "jira.example.com", Path: "/rest/api/2", }, }, }, jql: "project=TEST and labels=\"ALERT{123}\"", expectedBody: issueSearch{ JQL: "project=TEST and labels=\"ALERT{123}\"", MaxResults: 2, Fields: []string{"status"}, }, expectedURL: "https://jira.example.com/rest/api/2/search", expectedURLPath: "/rest/api/2", }, } { t.Run(tc.title, func(t *testing.T) { tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} notifier, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) requestBody, searchURL := notifier.prepareSearchRequest(tc.jql) require.Equal(t, tc.expectedURL, searchURL) require.Equal(t, tc.expectedBody, requestBody) // Verify that the original APIURL.Path is not modified require.Equal(t, tc.expectedURLPath, notifier.conf.APIURL.Path) }) } } func TestJiraTemplating(t *testing.T) { var capturedBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/search": w.Write([]byte(`{"issues": []}`)) return default: dec := json.NewDecoder(r.Body) out := make(map[string]any) if err := dec.Decode(&out); err != nil { panic(err) } capturedBody = out } })) defer srv.Close() u, _ := url.Parse(srv.URL) for _, tc := range []struct { title string cfg *config.JiraConfig retry bool errMsg string expectedFieldKey string expectedFieldValue any }{ { title: "full-blown message with templated custom field", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Fields: map[string]any{ "customfield_14400": `{{ template "jira.host" . }}`, }, }, retry: false, expectedFieldKey: "customfield_14400", expectedFieldValue: "host1.example.com", }, { title: "template project", cfg: &config.JiraConfig{ Project: `{{ .CommonLabels.lbl1 }}`, Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, }, retry: false, }, { title: "template issue type", cfg: &config.JiraConfig{ IssueType: `{{ .CommonLabels.lbl1 }}`, Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, }, retry: false, }, { title: "summary with templating errors", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: "{{ "}, }, errMsg: "template: :1: unclosed action", }, { title: "description with templating errors", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: "{{ "}, }, errMsg: "template: :1: unclosed action", }, { title: "priority with templating errors", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, Priority: "{{ ", }, errMsg: "template: :1: unclosed action", }, } { t.Run(tc.title, func(t *testing.T) { capturedBody = nil tc.cfg.APIURL = &amcommoncfg.URL{URL: u} tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) // Add the jira.host template just for this test if tc.expectedFieldKey == "customfield_14400" { err = pd.tmpl.Parse(strings.NewReader(`{{ define "jira.host" }}{{ .CommonLabels.hostname }}{{ end }}`)) require.NoError(t, err) } ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") ctx = notify.WithGroupLabels(ctx, model.LabelSet{ "lbl1": "val1", "hostname": "host1.example.com", }) ok, err := pd.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", "hostname": "host1.example.com", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) if tc.errMsg == "" { require.NoError(t, err) } else { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) } require.Equal(t, tc.retry, ok) // Verify that custom fields were templated correctly if tc.expectedFieldKey != "" { require.NotNil(t, capturedBody, "expected request body") fields, ok := capturedBody["fields"].(map[string]any) require.True(t, ok, "fields should be a map") require.Equal(t, tc.expectedFieldValue, fields[tc.expectedFieldKey]) } }) } } func TestJiraNotify(t *testing.T) { for _, tc := range []struct { title string cfg *config.JiraConfig alert *types.Alert customFieldAssetFn func(t *testing.T, issue map[string]any) searchResponse issueSearchResult issue issue errMsg string }{ { title: "create new issue", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, ReopenDuration: model.Duration(1 * time.Hour), ReopenTransition: "REOPEN", ResolveTransition: "CLOSE", WontFixResolution: "WONTFIX", }, alert: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "critical", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, searchResponse: issueSearchResult{ Issues: []issue{}, }, issue: issue{ Key: "", Fields: &issueFields{ Summary: stringPtr("[FIRING:1] test (vm1 critical)"), Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n - severity = critical\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, Priority: &idNameValue{Name: "High"}, }, }, customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, errMsg: "", }, { title: "update existing issue with disabled summary and description", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{ Template: `{{ template "jira.default.summary" . }}`, EnableUpdate: boolPtr(false), }, Description: config.JiraFieldConfig{ Template: `{{ template "jira.default.description" . }}`, EnableUpdate: boolPtr(false), }, IssueType: "{{ .CommonLabels.issue_type }}", Project: "{{ .CommonLabels.project }}", Priority: `{{ template "jira.default.priority" . }}`, Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, ReopenDuration: model.Duration(1 * time.Hour), ReopenTransition: "REOPEN", ResolveTransition: "CLOSE", WontFixResolution: "WONTFIX", }, alert: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "critical", "project": "MONITORING", "issue_type": "MINOR", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, searchResponse: issueSearchResult{ Issues: []issue{ { Key: "MONITORING-1", Fields: &issueFields{ Summary: stringPtr("Original Summary"), Description: jiraStringDescription("Original Description"), Status: &issueStatus{ Name: "Open", StatusCategory: struct { Key string `json:"key"` }{ Key: "open", }, }, }, }, }, }, issue: issue{ Key: "MONITORING-1", Fields: &issueFields{ // Summary and Description should NOT be present in the update request Issuetype: &idNameValue{Name: "MINOR"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "MONITORING"}, Priority: &idNameValue{Name: "High"}, }, }, customFieldAssetFn: func(t *testing.T, issue map[string]any) { // Verify that summary and description are NOT in the update request _, hasSummary := issue["summary"] _, hasDescription := issue["description"] require.False(t, hasSummary, "summary should not be present in update request") require.False(t, hasDescription, "description should not be present in update request") }, errMsg: "", }, { title: "create new issue with template project and issue type", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "{{ .CommonLabels.issue_type }}", Project: "{{ .CommonLabels.project }}", Priority: `{{ template "jira.default.priority" . }}`, Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, ReopenDuration: model.Duration(1 * time.Hour), ReopenTransition: "REOPEN", ResolveTransition: "CLOSE", WontFixResolution: "WONTFIX", }, alert: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "critical", "project": "MONITORING", "issue_type": "MINOR", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, searchResponse: issueSearchResult{ Issues: []issue{}, }, issue: issue{ Key: "", Fields: &issueFields{ Summary: stringPtr("[FIRING:1] test (vm1 MINOR MONITORING critical)"), Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n - issue_type = MINOR\n - project = MONITORING\n - severity = critical\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "MINOR"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "MONITORING"}, Priority: &idNameValue{Name: "High"}, }, }, customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, errMsg: "", }, { title: "create new issue with custom field and too long summary", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: strings.Repeat("A", maxSummaryLenRunes+10)}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, Fields: map[string]any{ "components": map[any]any{"name": "Monitoring"}, "customfield_10001": "value", "customfield_10002": 0, "customfield_10003": []any{0}, "customfield_10004": map[any]any{"value": "red"}, "customfield_10005": map[any]any{"value": 0}, "customfield_10006": []map[any]any{{"value": "red"}, {"value": "blue"}, {"value": "green"}}, "customfield_10007": []map[any]any{{"value": "red"}, {"value": "blue"}, {"value": 0}}, "customfield_10008": []map[any]any{{"value": 0}, {"value": 1}, {"value": 2}}, "customfield_10009": []map[any]any{{1: 0}, {1.0: 1}, {"a": []any{2}}}, "customfield_10010": []any{map[any]any{1: 0}, []int{3}}, }, ReopenDuration: model.Duration(1 * time.Hour), ReopenTransition: "REOPEN", ResolveTransition: "CLOSE", WontFixResolution: "WONTFIX", }, alert: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, searchResponse: issueSearchResult{ Issues: []issue{}, }, issue: issue{ Key: "", Fields: &issueFields{ Summary: stringPtr(strings.Repeat("A", maxSummaryLenRunes-1) + "…"), Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, }, }, customFieldAssetFn: func(t *testing.T, issue map[string]any) { require.Equal(t, "value", issue["customfield_10001"]) require.Equal(t, float64(0), issue["customfield_10002"]) require.Equal(t, []any{float64(0)}, issue["customfield_10003"]) require.Equal(t, map[string]any{"value": "red"}, issue["customfield_10004"]) require.Equal(t, map[string]any{"value": float64(0)}, issue["customfield_10005"]) require.Equal(t, []any{map[string]any{"value": "red"}, map[string]any{"value": "blue"}, map[string]any{"value": "green"}}, issue["customfield_10006"]) require.Equal(t, []any{map[string]any{"value": "red"}, map[string]any{"value": "blue"}, map[string]any{"value": float64(0)}}, issue["customfield_10007"]) require.Equal(t, []any{map[string]any{"value": float64(0)}, map[string]any{"value": float64(1)}, map[string]any{"value": float64(2)}}, issue["customfield_10008"]) require.Equal(t, []any([]any{map[string]any{}, map[string]any{}, map[string]any{"a": []any{2.0}}}), issue["customfield_10009"]) require.Equal(t, []any{map[string]any{}, []any{3.0}}, issue["customfield_10010"]) }, errMsg: "", }, { title: "reopen issue", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, ReopenDuration: model.Duration(1 * time.Hour), ReopenTransition: "REOPEN", ResolveTransition: "CLOSE", WontFixResolution: "WONTFIX", }, alert: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, searchResponse: issueSearchResult{ Issues: []issue{ { Key: "OPS-1", Fields: &issueFields{ Status: &issueStatus{ Name: "Closed", StatusCategory: struct { Key string `json:"key"` }{ Key: "done", }, }, }, }, }, }, issue: issue{ Key: "", Fields: &issueFields{ Summary: stringPtr("[FIRING:1] test (vm1)"), Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, Priority: &idNameValue{Name: "High"}, }, }, customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, errMsg: "", }, { title: "error resolve transition not found", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, ReopenDuration: model.Duration(1 * time.Hour), ReopenTransition: "REOPEN", ResolveTransition: "CLOSE", WontFixResolution: "WONTFIX", }, alert: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", }, StartsAt: time.Now().Add(-time.Hour), EndsAt: time.Now().Add(-time.Hour), }, }, searchResponse: issueSearchResult{ Issues: []issue{ { Key: "OPS-3", Fields: &issueFields{ Status: &issueStatus{ Name: "Open", StatusCategory: struct { Key string `json:"key"` }{ Key: "open", }, }, }, }, }, }, issue: issue{ Key: "", Fields: &issueFields{ Summary: stringPtr("[RESOLVED] test (vm1)"), Description: jiraStringDescription("\n\n\n# Alerts Resolved:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, }, }, customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, errMsg: "can't find transition CLOSE for issue OPS-3", }, { title: "error reopen transition not found", cfg: &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: `{{ template "jira.default.description" . }}`}, IssueType: "Incident", Project: "OPS", Priority: `{{ template "jira.default.priority" . }}`, Labels: []string{"alertmanager", "{{ .GroupLabels.alertname }}"}, ReopenDuration: model.Duration(1 * time.Hour), ReopenTransition: "REOPEN", ResolveTransition: "CLOSE", WontFixResolution: "WONTFIX", }, alert: &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, searchResponse: issueSearchResult{ Issues: []issue{ { Key: "OPS-3", Fields: &issueFields{ Status: &issueStatus{ Name: "Closed", StatusCategory: struct { Key string `json:"key"` }{ Key: "done", }, }, }, }, }, }, issue: issue{ Key: "", Fields: &issueFields{ Summary: stringPtr("[FIRING:1] test (vm1)"), Description: jiraStringDescription("\n\n# Alerts Firing:\n\nLabels:\n - alertname = test\n - instance = vm1\n\nAnnotations:\n\nSource: \n\n\n\n\n"), Issuetype: &idNameValue{Name: "Incident"}, Labels: []string{"ALERT{6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b}", "alertmanager", "test"}, Project: &issueProject{Key: "OPS"}, }, }, customFieldAssetFn: func(t *testing.T, issue map[string]any) {}, errMsg: "can't find transition REOPEN for issue OPS-3", }, } { t.Run(tc.title, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/search": enc := json.NewEncoder(w) if err := enc.Encode(tc.searchResponse); err != nil { panic(err) } return case "/issue/OPS-1/transitions": switch r.Method { case http.MethodGet: w.WriteHeader(http.StatusOK) transitions := issueTransitions{ Transitions: []idNameValue{ {ID: "12345", Name: "REOPEN"}, }, } enc := json.NewEncoder(w) if err := enc.Encode(transitions); err != nil { panic(err) } case http.MethodPost: dec := json.NewDecoder(r.Body) var out issue err := dec.Decode(&out) if err != nil { panic(err) } require.Equal(t, issue{Transition: &idNameValue{ID: "12345"}}, out) w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected method %s", r.Method) } return case "/issue/OPS-2/transitions": switch r.Method { case http.MethodGet: w.WriteHeader(http.StatusOK) transitions := issueTransitions{ Transitions: []idNameValue{ {ID: "54321", Name: "CLOSE"}, }, } enc := json.NewEncoder(w) if err := enc.Encode(transitions); err != nil { panic(err) } case http.MethodPost: dec := json.NewDecoder(r.Body) var out issue err := dec.Decode(&out) if err != nil { panic(err) } require.Equal(t, issue{Transition: &idNameValue{ID: "54321"}}, out) w.WriteHeader(http.StatusNoContent) default: t.Fatalf("unexpected method %s", r.Method) } return case "/issue/OPS-3/transitions": switch r.Method { case http.MethodGet: w.WriteHeader(http.StatusOK) transitions := issueTransitions{ Transitions: []idNameValue{}, } enc := json.NewEncoder(w) if err := enc.Encode(transitions); err != nil { panic(err) } default: t.Fatalf("unexpected method %s", r.Method) } return case "/issue/MONITORING-1": body, err := io.ReadAll(r.Body) if err != nil { panic(err) } var raw map[string]any if err := json.Unmarshal(body, &raw); err != nil { panic(err) } if fields, ok := raw["fields"].(map[string]any); ok { tc.customFieldAssetFn(t, fields) } w.WriteHeader(http.StatusNoContent) return case "/issue/OPS-1": case "/issue/OPS-2": case "/issue/OPS-3": case "/issue/OPS-4": fallthrough case "/issue": body, err := io.ReadAll(r.Body) if err != nil { panic(err) } var ( issue issue raw map[string]any ) if err := json.Unmarshal(body, &issue); err != nil { panic(err) } // We don't care about the key, so copy it over. issue.Fields.Fields = tc.issue.Fields.Fields require.Equal(t, tc.issue.Key, issue.Key) require.Equal(t, tc.issue.Fields, issue.Fields) if err := json.Unmarshal(body, &raw); err != nil { panic(err) } if fields, ok := raw["fields"].(map[string]any); ok { tc.customFieldAssetFn(t, fields) } else { t.Errorf("fields should a map of string") } w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated) default: t.Fatalf("unexpected path %s", r.URL.Path) } })) defer srv.Close() u, _ := url.Parse(srv.URL) tc.cfg.APIURL = &amcommoncfg.URL{URL: u} tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} notifier, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") ctx = notify.WithGroupLabels(ctx, model.LabelSet{"alertname": "test"}) _, err = notifier.Notify(ctx, tc.alert) if tc.errMsg == "" { require.NoError(t, err) } else { require.Error(t, err) require.EqualError(t, err, tc.errMsg) } }) } } func TestJiraPriority(t *testing.T) { t.Parallel() for _, tc := range []struct { title string alerts []*types.Alert expectedPriority string }{ { "empty", []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }, "", }, { "critical", []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "critical", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }, "High", }, { "warning", []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "warning", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }, "Medium", }, { "info", []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "info", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }, "Low", }, { "critical+warning+info", []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "critical", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "warning", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "info", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }, "High", }, { "warning+info", []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "warning", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "info", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }, "Medium", }, { "critical(resolved)+warning+info", []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "critical", }, StartsAt: time.Now().Add(-time.Hour), EndsAt: time.Now().Add(-time.Hour), }, }, { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "warning", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, { Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "info", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }, "Medium", }, } { t.Run(tc.title, func(t *testing.T) { t.Parallel() u, err := url.Parse("http://example.com/") require.NoError(t, err) tmpl, err := template.FromGlobs([]string{}) require.NoError(t, err) tmpl.ExternalURL = u var ( data = tmpl.Data("jira", model.LabelSet{}, notify.ReasonFirstNotification.String(), tc.alerts...) tmplTextErr error tmplText = notify.TmplText(tmpl, data, &tmplTextErr) tmplTextFunc = func(tmpl string) (string, error) { result := tmplText(tmpl) return result, tmplTextErr } ) priority, err := tmplTextFunc(`{{ template "jira.default.priority" . }}`) require.NoError(t, err) require.Equal(t, tc.expectedPriority, priority) }) } } func TestPrepareIssueRequestBodyAPIv3DescriptionValidation(t *testing.T) { for _, tc := range []struct { name string descriptionTemplate string expectErrSubstring string }{ { name: "valid JSON description", descriptionTemplate: `{"type":"doc","version":1,"content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}`, }, { name: "invalid JSON description", descriptionTemplate: `not-json`, expectErrSubstring: "invalid JSON for API v3", }, } { t.Run(tc.name, func(t *testing.T) { cfg := &config.JiraConfig{ Summary: config.JiraFieldConfig{Template: `{{ template "jira.default.summary" . }}`}, Description: config.JiraFieldConfig{Template: tc.descriptionTemplate}, IssueType: "Incident", Project: "OPS", Labels: []string{"alertmanager"}, Priority: `{{ template "jira.default.priority" . }}`, APIURL: &amcommoncfg.URL{ URL: &url.URL{ Scheme: "https", Host: "example.atlassian.net", Path: "/rest/api/3", }, }, HTTPConfig: &commoncfg.HTTPClientConfig{}, } notifier, err := New(cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "alertname": "test", "instance": "vm1", "severity": "critical", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } ctx := context.Background() groupID := "1" ctx = notify.WithGroupKey(ctx, groupID) ctx = notify.WithGroupLabels(ctx, alert.Labels) alerts := []*types.Alert{alert} logger := notifier.logger.With("group_key", groupID) data := notify.GetTemplateData(ctx, notifier.tmpl, alerts, logger) var tmplErr error tmplText := notify.TmplText(notifier.tmpl, data, &tmplErr) tmplTextFunc := func(tmpl string) (string, error) { return tmplText(tmpl), tmplErr } issue, err := notifier.prepareIssueRequestBody(ctx, logger, groupID, tmplTextFunc) if tc.expectErrSubstring != "" { require.Error(t, err) require.ErrorContains(t, err, tc.expectErrSubstring) return } require.NoError(t, err) require.NotNil(t, issue.Fields) require.NotNil(t, issue.Fields.Description) require.JSONEq(t, tc.descriptionTemplate, string(issue.Fields.Description.RawJSONDescription)) }) } } ================================================ FILE: notify/jira/types.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package jira import ( "bytes" "encoding/json" "maps" ) // issue represents a Jira issue wrapper. type issue struct { Key string `json:"key,omitempty"` Fields *issueFields `json:"fields,omitempty"` Transition *idNameValue `json:"transition,omitempty"` } type issueFields struct { Description *jiraDescription `json:"description,omitempty"` Issuetype *idNameValue `json:"issuetype,omitempty"` Labels []string `json:"labels,omitempty"` Priority *idNameValue `json:"priority,omitempty"` Project *issueProject `json:"project,omitempty"` Resolution *idNameValue `json:"resolution,omitempty"` Summary *string `json:"summary,omitempty"` Status *issueStatus `json:"status,omitempty"` Fields map[string]any `json:"-"` } type idNameValue struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` } type issueProject struct { Key string `json:"key"` } type issueStatus struct { Name string `json:"name"` StatusCategory struct { Key string `json:"key"` } `json:"statusCategory"` } type issueSearch struct { Fields []string `json:"fields"` JQL string `json:"jql"` MaxResults int `json:"maxResults"` } type issueSearchResult struct { Issues []issue `json:"issues"` } type issueTransitions struct { Transitions []idNameValue `json:"transitions"` } // MarshalJSON merges the struct issueFields and issueFields.CustomField together. func (i issueFields) MarshalJSON() ([]byte, error) { jsonFields := map[string]any{} if i.Summary != nil { jsonFields["summary"] = *i.Summary } // Only include description when it has content. if i.Description != nil && !i.Description.IsEmpty() { jsonFields["description"] = i.Description } if i.Issuetype != nil { jsonFields["issuetype"] = i.Issuetype } if i.Labels != nil { jsonFields["labels"] = i.Labels } if i.Priority != nil { jsonFields["priority"] = i.Priority } if i.Project != nil { jsonFields["project"] = i.Project } if i.Resolution != nil { jsonFields["resolution"] = i.Resolution } if i.Status != nil { jsonFields["status"] = i.Status } // copy custom/unknown fields into the outgoing map if i.Fields != nil { maps.Copy(jsonFields, i.Fields) } return json.Marshal(jsonFields) } // jiraDescription holds either a plain string (v2 API) description or ADF (Atlassian Document Format) JSON (v3 API). type jiraDescription struct { StringDescription *string // non-nil if the description is a simple string RawJSONDescription json.RawMessage // non-empty if the description is structured JSON } func (jd jiraDescription) MarshalJSON() ([]byte, error) { // If there's a structured JSON payload, return it as-is. if len(jd.RawJSONDescription) > 0 { out := make([]byte, len(jd.RawJSONDescription)) copy(out, jd.RawJSONDescription) return out, nil } // If we have a string representation, let json.Marshal quote it properly. if jd.StringDescription != nil { return json.Marshal(*jd.StringDescription) } // No value: represent as JSON null. return []byte("null"), nil } func (jd *jiraDescription) UnmarshalJSON(data []byte) error { // Reset current state jd.StringDescription = nil jd.RawJSONDescription = nil trimmed := bytes.TrimSpace(data) if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { // nothing to do (leave both fields nil/empty) return nil } // If it starts with object or array token, treat as structured JSON and keep raw bytes. switch trimmed[0] { case '{', '[': // store a copy of the raw JSON jd.RawJSONDescription = append(json.RawMessage(nil), trimmed...) return nil default: // otherwise try to unmarshal as string (expected for Jira v2) var s string if err := json.Unmarshal(trimmed, &s); err != nil { // fallback: if it's not a string but also not an object/array, keep raw bytes jd.RawJSONDescription = append(json.RawMessage(nil), trimmed...) return nil } jd.StringDescription = &s return nil } } // IsEmpty reports whether the jiraDescription contains no useful value. func (jd *jiraDescription) IsEmpty() bool { if jd == nil { return true } return jd.StringDescription == nil && len(jd.RawJSONDescription) == 0 } ================================================ FILE: notify/mattermost/mattermost.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package mattermost import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // Mattermost supports 16383 chars max. // https://developers.mattermost.com/integrate/webhooks/incoming/#tips-and-best-practices const maxTextLenRunes = 16383 // Notifier implements a Notifier for Mattermost notifications. type Notifier struct { conf *config.MattermostConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) } // New returns a new Mattermost notifier. func New(c *config.MattermostConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "mattermost", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, postJSONFunc: notify.PostJSON, }, nil } // request is the request for sending a Mattermost notification. // https://developers.mattermost.com/integrate/webhooks/incoming/#parameters type request struct { Text string `json:"text,omitempty"` Channel string `json:"channel,omitempty"` Username string `json:"username,omitempty"` IconURL string `json:"icon_url,omitempty"` IconEmoji string `json:"icon_emoji,omitempty"` Attachments []attachment `json:"attachments,omitempty"` Type string `json:"type,omitempty"` Props *config.MattermostProps `json:"props,omitempty"` Priority *config.MattermostPriority `json:"priority,omitempty"` } // attachment is used to display a richly-formatted message block for compatibility with Slack. // https://developers.mattermost.com/integrate/reference/message-attachments/ type attachment struct { Fallback string `json:"fallback,omitempty"` Color string `json:"color,omitempty"` Pretext string `json:"pretext,omitempty"` Text string `json:"text,omitempty"` AuthorName string `json:"author_name,omitempty"` AuthorLink string `json:"author_link,omitempty"` AuthorIcon string `json:"author_icon,omitempty"` Title string `json:"title,omitempty"` TitleLink string `json:"title_link,omitempty"` Fields []config.MattermostField `json:"fields,omitempty"` ThumbURL string `json:"thumb_url,omitempty"` Footer string `json:"footer,omitempty"` FooterIcon string `json:"footer_icon,omitempty"` ImageURL string `json:"image_url,omitempty"` } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, alert ...*types.Alert) (bool, error) { var ( err error url string data = notify.GetTemplateData(ctx, n.tmpl, alert, n.logger) ) if n.conf.WebhookURL != nil { url = n.conf.WebhookURL.String() } else { content, err := os.ReadFile(n.conf.WebhookURLFile) if err != nil { return false, err } url = strings.TrimSpace(string(content)) } if url == "" { return false, errors.New("webhook url missing") } req := n.createRequest(notify.TmplText(n.tmpl, data, &err)) if err != nil { return false, err } err = n.sanitizeRequest(ctx, req) if err != nil { return false, err } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(req); err != nil { return false, err } resp, err := n.postJSONFunc(ctx, n.client, url, &buf) if err != nil { return true, notify.RedactURL(err) } defer notify.Drain(resp) // Use a retrier to generate an error message for non-200 responses and // classify them as retriable or not. retry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { err = fmt.Errorf("channel %q: %w", req.Channel, err) return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } n.logger.Debug("Message sent to Mattermost successfully", "status", resp.StatusCode) return false, nil } func (n *Notifier) createRequest(tmpl func(string) string) *request { text := tmpl(n.conf.Text) req := &request{ Channel: tmpl(n.conf.Channel), Username: tmpl(n.conf.Username), IconURL: tmpl(n.conf.IconURL), IconEmoji: tmpl(n.conf.IconEmoji), Type: tmpl(n.conf.Type), } if n.conf.Priority != nil && n.conf.Priority.Priority != "" { req.Priority = &config.MattermostPriority{ Priority: tmpl(n.conf.Priority.Priority), RequestedAck: n.conf.Priority.RequestedAck, PersistentNotifications: n.conf.Priority.PersistentNotifications, } } if n.conf.Props != nil && n.conf.Props.Card != "" { req.Props = &config.MattermostProps{ Card: tmpl(n.conf.Props.Card), } } lenAtt := len(n.conf.Attachments) if lenAtt > 0 { req.Attachments = make([]attachment, lenAtt) for idxAtt, cfgAtt := range n.conf.Attachments { att := attachment{ Fallback: tmpl(cfgAtt.Fallback), Color: tmpl(cfgAtt.Color), Pretext: tmpl(cfgAtt.Pretext), Text: tmpl(cfgAtt.Text), AuthorName: tmpl(cfgAtt.AuthorName), AuthorLink: tmpl(cfgAtt.AuthorLink), AuthorIcon: tmpl(cfgAtt.AuthorIcon), Title: tmpl(cfgAtt.Title), TitleLink: tmpl(cfgAtt.TitleLink), ThumbURL: tmpl(cfgAtt.ThumbURL), Footer: tmpl(cfgAtt.Footer), FooterIcon: tmpl(cfgAtt.FooterIcon), ImageURL: tmpl(cfgAtt.ImageURL), } lenFields := len(cfgAtt.Fields) if lenFields > 0 { att.Fields = make([]config.MattermostField, lenFields) for idxField, field := range cfgAtt.Fields { att.Fields[idxField] = config.MattermostField{ Title: tmpl(field.Title), Value: tmpl(field.Value), Short: field.Short, } } } req.Attachments[idxAtt] = att req.Text = text } } else { req.Attachments = make([]attachment, 1) att := attachment{ Text: text, Fallback: tmpl(n.conf.Fallback), Color: tmpl(n.conf.Color), Pretext: tmpl(n.conf.Pretext), AuthorName: tmpl(n.conf.AuthorName), AuthorLink: tmpl(n.conf.AuthorLink), AuthorIcon: tmpl(n.conf.AuthorIcon), Title: tmpl(n.conf.Title), TitleLink: tmpl(n.conf.TitleLink), ThumbURL: tmpl(n.conf.ThumbURL), Footer: tmpl(n.conf.Footer), FooterIcon: tmpl(n.conf.FooterIcon), ImageURL: tmpl(n.conf.ImageURL), } lenFields := len(n.conf.Fields) if lenFields > 0 { att.Fields = make([]config.MattermostField, lenFields) for idxField, field := range n.conf.Fields { att.Fields[idxField] = config.MattermostField{ Title: tmpl(field.Title), Value: tmpl(field.Value), Short: field.Short, } } } req.Attachments[0] = att } return req } func (n *Notifier) sanitizeRequest(ctx context.Context, r *request) error { key, err := notify.ExtractGroupKey(ctx) if err != nil { return err } // Truncate the text if it's too long. text, truncated := notify.TruncateInRunes(r.Text, maxTextLenRunes) if truncated { n.logger.Warn("Truncated text", "key", key, "max_runes", maxTextLenRunes) r.Text = text } if r.Priority == nil { return nil } // Check priority const ( priorityUrgent = "urgent" priorityImportant = "important" priorityStandard = "standard" ) switch strings.ToLower(r.Priority.Priority) { case priorityUrgent, priorityImportant, priorityStandard: r.Priority.Priority = strings.ToLower(r.Priority.Priority) default: n.logger.Warn("Priority is set to standard due to invalid value", "key", key, "priority", r.Priority.Priority) r.Priority.Priority = priorityStandard } // Check RequestedAck flag if r.Priority.RequestedAck && r.Priority.Priority == priorityStandard { n.logger.Warn("RequestedAck is set to false due to priority is standard", "key", key, ) r.Priority.RequestedAck = false } // Check PersistentNotifications flag if r.Priority.PersistentNotifications && r.Priority.Priority != priorityUrgent { n.logger.Warn("PersistentNotifications is set to false due to priority is not urgent", "key", key, ) r.Priority.PersistentNotifications = false } return nil } ================================================ FILE: notify/mattermost/mattermost_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package mattermost import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) var testWebhookURL, _ = url.Parse("https://mattermost.example.com/hooks/xxxxxxxxxxxxxxxxxxxxxxxxxx") func TestMattermostRetry(t *testing.T) { notifier, err := New( &config.MattermostConfig{ WebhookURL: &amcommoncfg.SecretURL{URL: testWebhookURL}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "retry - error on status %d", statusCode) } } func TestMattermostTemplating(t *testing.T) { // Create a fake HTTP server to simulate the Mattermost webhook srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(r.Body) out := make(map[string]any) err := dec.Decode(&out) if err != nil { panic(err) } })) defer srv.Close() u, _ := url.Parse(srv.URL) for _, tc := range []struct { title string cfg *config.MattermostConfig retry bool errMsg string }{ { title: "text with default templating", cfg: &config.DefaultMattermostConfig, retry: false, }, { title: "text with templating errors", cfg: &config.MattermostConfig{ Text: "{{ ", }, errMsg: "template: :1: unclosed action", }, } { t.Run(tc.title, func(t *testing.T) { tc.cfg.WebhookURL = &amcommoncfg.SecretURL{URL: u} tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") ok, err := pd.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) if tc.errMsg == "" { require.NoError(t, err) } else { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) } require.Equal(t, tc.retry, ok) }) } } func TestMattermostRedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() secret := "secret" notifier, err := New( &config.MattermostConfig{ WebhookURL: &amcommoncfg.SecretURL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret) } func TestMattermostReadingURLFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() f, err := os.CreateTemp(t.TempDir(), "webhook_url") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(u.String() + "\n") require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.MattermostConfig{ WebhookURLFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } func TestMattermost_Notify(t *testing.T) { // Create a fake HTTP server to simulate the Mattermost webhook var resp string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Read the request as a string body, err := io.ReadAll(r.Body) require.NoError(t, err, "reading request body failed") // Store the request body in the response resp = string(body) w.WriteHeader(http.StatusOK) })) // Create a temporary file to simulate the WebhookURLFile tempFile, err := os.CreateTemp(t.TempDir(), "webhook_url") require.NoError(t, err) // Write the fake webhook URL to the temp file _, err = tempFile.WriteString(srv.URL) require.NoError(t, err) // Create a context and alerts ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, } type testcase struct { name string text string props *config.MattermostProps priority *config.MattermostPriority attachments []*config.MattermostAttachment result string } tests := []testcase{ { name: "with text only", text: "Test Text", result: "{\"attachments\":[{\"text\":\"Test Text\"}]}\n", }, { name: "with text and props", text: "Test Text", props: &config.MattermostProps{Card: "Test Card"}, priority: nil, result: "{\"attachments\":[{\"text\":\"Test Text\"}],\"props\":{\"card\":\"Test Card\"}}\n", }, { name: "with text and priority standard", text: "Test Text", props: nil, priority: &config.MattermostPriority{Priority: "standard", RequestedAck: true, PersistentNotifications: true}, result: "{\"attachments\":[{\"text\":\"Test Text\"}],\"priority\":{\"priority\":\"standard\"}}\n", }, { name: "with text, props and priority", text: "Test Text", props: &config.MattermostProps{Card: "Test Card"}, priority: &config.MattermostPriority{Priority: "urgent"}, result: "{\"attachments\":[{\"text\":\"Test Text\"}],\"props\":{\"card\":\"Test Card\"},\"priority\":{\"priority\":\"urgent\"}}\n", }, { name: "with empty text - should omit text field", text: "", result: "{\"attachments\":[{}]}\n", }, { name: "with empty text and attachments - should omit text field", text: "", attachments: []*config.MattermostAttachment{ { Title: "Test Attachment", Text: "Attachment Text", }, }, result: "{\"attachments\":[{\"text\":\"Attachment Text\",\"title\":\"Test Attachment\"}]}\n", }, { name: "with text and attachments", text: "Test Text", attachments: []*config.MattermostAttachment{ { Title: "Test Attachment", Text: "Attachment Text", }, }, result: "{\"text\":\"Test Text\",\"attachments\":[{\"text\":\"Attachment Text\",\"title\":\"Test Attachment\"}]}\n", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Create a MattermostConfig with the WebhookURLFile set cfg := &config.MattermostConfig{ WebhookURLFile: tempFile.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, Text: tc.text, Props: tc.props, Priority: tc.priority, Attachments: tc.attachments, } // Create a new Mattermost notifier notifier, err := New(cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) // Call the Notify method ok, err := notifier.Notify(ctx, alerts...) require.NoError(t, err) require.False(t, ok) require.Equal(t, tc.result, resp) }) } } ================================================ FILE: notify/msteams/msteams.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package msteams import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( colorRed = "8C1A1A" colorGreen = "2DC72D" colorGrey = "808080" ) type Notifier struct { conf *config.MSTeamsConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier webhookURL *amcommoncfg.SecretURL postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) } // Message card reference can be found at https://learn.microsoft.com/en-us/outlook/actionable-messages/message-card-reference. type teamsMessage struct { Context string `json:"@context"` Type string `json:"type"` Title string `json:"title"` Summary string `json:"summary"` Text string `json:"text"` ThemeColor string `json:"themeColor"` } // New returns a new notifier that uses the Microsoft Teams Webhook API. func New(c *config.MSTeamsConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "msteams", httpOpts...) if err != nil { return nil, err } n := &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, webhookURL: c.WebhookURL, postJSONFunc: notify.PostJSON, } return n, nil } func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") data := notify.GetTemplateData(ctx, n.tmpl, as, logger) tmpl := notify.TmplText(n.tmpl, data, &err) if err != nil { return false, err } title := tmpl(n.conf.Title) if err != nil { return false, err } text := tmpl(n.conf.Text) if err != nil { return false, err } summary := tmpl(n.conf.Summary) if err != nil { return false, err } alerts := types.Alerts(as...) color := colorGrey switch alerts.Status() { case model.AlertFiring: color = colorRed case model.AlertResolved: color = colorGreen } var url string if n.conf.WebhookURL != nil { url = n.conf.WebhookURL.String() } else { content, err := os.ReadFile(n.conf.WebhookURLFile) if err != nil { return false, fmt.Errorf("read webhook_url_file: %w", err) } url = strings.TrimSpace(string(content)) } t := teamsMessage{ Context: "http://schema.org/extensions", Type: "MessageCard", Title: title, Summary: summary, Text: text, ThemeColor: color, } var payload bytes.Buffer if err = json.NewEncoder(&payload).Encode(t); err != nil { return false, err } resp, err := n.postJSONFunc(ctx, n.client, url, &payload) if err != nil { return true, notify.RedactURL(err) } defer notify.Drain(resp) // https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#rate-limiting-for-connectors shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } return shouldRetry, err } ================================================ FILE: notify/msteams/msteams_test.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package msteams import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) // This is a test URL that has been modified to not be valid. var testWebhookURL, _ = url.Parse("https://example.webhook.office.com/webhookb2/xxxxxx/IncomingWebhook/xxx/xxx") func TestMSTeamsRetry(t *testing.T) { notifier, err := New( &config.MSTeamsConfig{ WebhookURL: &amcommoncfg.SecretURL{URL: testWebhookURL}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "retry - error on status %d", statusCode) } } func TestMSTeamsTemplating(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(r.Body) out := make(map[string]any) err := dec.Decode(&out) if err != nil { panic(err) } })) defer srv.Close() u, _ := url.Parse(srv.URL) for _, tc := range []struct { title string cfg *config.MSTeamsConfig retry bool errMsg string }{ { title: "full-blown message", cfg: &config.MSTeamsConfig{ Title: `{{ template "msteams.default.title" . }}`, Summary: `{{ template "msteams.default.summary" . }}`, Text: `{{ template "msteams.default.text" . }}`, }, retry: false, }, { title: "title with templating errors", cfg: &config.MSTeamsConfig{ Title: "{{ ", }, errMsg: "template: :1: unclosed action", }, { title: "summary with templating errors", cfg: &config.MSTeamsConfig{ Title: `{{ template "msteams.default.title" . }}`, Summary: "{{ ", }, errMsg: "template: :1: unclosed action", }, { title: "message with templating errors", cfg: &config.MSTeamsConfig{ Title: `{{ template "msteams.default.title" . }}`, Summary: `{{ template "msteams.default.summary" . }}`, Text: "{{ ", }, errMsg: "template: :1: unclosed action", }, } { t.Run(tc.title, func(t *testing.T) { tc.cfg.WebhookURL = &amcommoncfg.SecretURL{URL: u} tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") ok, err := pd.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) if tc.errMsg == "" { require.NoError(t, err) } else { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) } require.Equal(t, tc.retry, ok) }) } } func TestNotifier_Notify_WithReason(t *testing.T) { tests := []struct { name string statusCode int responseContent string expectedReason notify.Reason noError bool }{ { name: "with a 2xx status code and response 1", statusCode: http.StatusOK, responseContent: "1", noError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { notifier, err := New( &config.MSTeamsConfig{ WebhookURL: &amcommoncfg.SecretURL{URL: testWebhookURL}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) { resp := httptest.NewRecorder() resp.WriteString(tt.responseContent) resp.WriteHeader(tt.statusCode) return resp.Result(), nil } ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alert1 := &types.Alert{ Alert: model.Alert{ StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } _, err = notifier.Notify(ctx, alert1) if tt.noError { require.NoError(t, err) } else { var reasonError *notify.ErrorWithReason require.ErrorAs(t, err, &reasonError) require.Equal(t, tt.expectedReason, reasonError.Reason) } }) } } func TestMSTeamsRedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() secret := "secret" notifier, err := New( &config.MSTeamsConfig{ WebhookURL: &amcommoncfg.SecretURL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret) } func TestMSTeamsReadingURLFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() f, err := os.CreateTemp(t.TempDir(), "webhook_url") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(u.String() + "\n") require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.MSTeamsConfig{ WebhookURLFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } ================================================ FILE: notify/msteamsv2/msteamsv2.go ================================================ // Copyright 2024 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package msteamsv2 import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( colorRed = "Attention" colorGreen = "Good" colorGrey = "Warning" ) type Notifier struct { conf *config.MSTeamsV2Config tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier webhookURL *amcommoncfg.SecretURL postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) } // https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1#adaptivecarditemschema type Content struct { Schema string `json:"$schema"` Type string `json:"type"` Version string `json:"version"` Body []Body `json:"body"` Msteams Msteams `json:"msteams,omitempty"` } type Body struct { Type string `json:"type"` Text string `json:"text"` Weight string `json:"weight,omitempty"` Size string `json:"size,omitempty"` Wrap bool `json:"wrap,omitempty"` Style string `json:"style,omitempty"` Color string `json:"color,omitempty"` } type Msteams struct { Width string `json:"width"` } type Attachment struct { ContentType string `json:"contentType"` ContentURL *string `json:"contentUrl"` // Use a pointer to handle null values Content Content `json:"content"` } type teamsMessage struct { Type string `json:"type"` Attachments []Attachment `json:"attachments"` } // New returns a new notifier that uses the Microsoft Teams Power Platform connector. func New(c *config.MSTeamsV2Config, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "msteamsv2", httpOpts...) if err != nil { return nil, err } n := &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, webhookURL: c.WebhookURL, postJSONFunc: notify.PostJSON, } return n, nil } func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") data := notify.GetTemplateData(ctx, n.tmpl, as, logger) tmpl := notify.TmplText(n.tmpl, data, &err) if err != nil { return false, err } title := tmpl(n.conf.Title) if err != nil { return false, err } text := tmpl(n.conf.Text) if err != nil { return false, err } alerts := types.Alerts(as...) color := colorGrey switch alerts.Status() { case model.AlertFiring: color = colorRed case model.AlertResolved: color = colorGreen } var url string if n.conf.WebhookURL != nil { url = n.conf.WebhookURL.String() } else { content, err := os.ReadFile(n.conf.WebhookURLFile) if err != nil { return false, fmt.Errorf("read webhook_url_file: %w", err) } url = strings.TrimSpace(string(content)) } // A message as referenced in https://learn.microsoft.com/en-us/connectors/teams/?tabs=text1%2Cdotnet#request-body-schema t := teamsMessage{ Type: "message", Attachments: []Attachment{ { ContentType: "application/vnd.microsoft.card.adaptive", ContentURL: nil, Content: Content{ Schema: "http://adaptivecards.io/schemas/adaptive-card.json", Type: "AdaptiveCard", Version: "1.2", Body: []Body{ { Type: "TextBlock", Text: title, Weight: "Bolder", Size: "Medium", Wrap: true, Style: "heading", Color: color, }, { Type: "TextBlock", Text: text, Wrap: true, }, }, Msteams: Msteams{ Width: "full", }, }, }, }, } var payload bytes.Buffer if err = json.NewEncoder(&payload).Encode(t); err != nil { return false, err } resp, err := n.postJSONFunc(ctx, n.client, url, &payload) if err != nil { return true, notify.RedactURL(err) } defer notify.Drain(resp) // https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#rate-limiting-for-connectors shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } return shouldRetry, err } ================================================ FILE: notify/msteamsv2/msteamsv2_test.go ================================================ // Copyright 2024 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package msteamsv2 import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) // This is a test URL that has been modified to not be valid. var testWebhookURL, _ = url.Parse("https://example.westeurope.logic.azure.com:443/workflows/xxx/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=xxx") func TestMSTeamsV2Retry(t *testing.T) { notifier, err := New( &config.MSTeamsV2Config{ WebhookURL: &amcommoncfg.SecretURL{URL: testWebhookURL}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "retry - error on status %d", statusCode) } } func TestNotifier_Notify_WithReason(t *testing.T) { tests := []struct { name string statusCode int responseContent string expectedReason notify.Reason noError bool }{ { name: "with a 2xx status code and response 1", statusCode: http.StatusOK, responseContent: "1", noError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { notifier, err := New( &config.MSTeamsV2Config{ WebhookURL: &amcommoncfg.SecretURL{URL: testWebhookURL}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) { resp := httptest.NewRecorder() resp.WriteString(tt.responseContent) resp.WriteHeader(tt.statusCode) return resp.Result(), nil } ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alert1 := &types.Alert{ Alert: model.Alert{ StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } _, err = notifier.Notify(ctx, alert1) if tt.noError { require.NoError(t, err) } else { var reasonError *notify.ErrorWithReason require.ErrorAs(t, err, &reasonError) require.Equal(t, tt.expectedReason, reasonError.Reason) } }) } } func TestMSTeamsV2Templating(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(r.Body) out := make(map[string]any) err := dec.Decode(&out) if err != nil { panic(err) } })) defer srv.Close() u, _ := url.Parse(srv.URL) for _, tc := range []struct { title string cfg *config.MSTeamsV2Config retry bool errMsg string }{ { title: "full-blown message", cfg: &config.MSTeamsV2Config{ Title: `{{ template "msteams.default.title" . }}`, Text: `{{ template "msteams.default.text" . }}`, }, retry: false, }, { title: "title with templating errors", cfg: &config.MSTeamsV2Config{ Title: "{{ ", }, errMsg: "template: :1: unclosed action", }, { title: "message with templating errors", cfg: &config.MSTeamsV2Config{ Title: `{{ template "msteams.default.title" . }}`, Text: "{{ ", }, errMsg: "template: :1: unclosed action", }, } { t.Run(tc.title, func(t *testing.T) { tc.cfg.WebhookURL = &amcommoncfg.SecretURL{URL: u} tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") ok, err := pd.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) if tc.errMsg == "" { require.NoError(t, err) } else { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) } require.Equal(t, tc.retry, ok) }) } } func TestMSTeamsV2RedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() secret := "secret" notifier, err := New( &config.MSTeamsV2Config{ WebhookURL: &amcommoncfg.SecretURL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret) } func TestMSTeamsV2ReadingURLFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() f, err := os.CreateTemp(t.TempDir(), "webhook_url") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(u.String() + "\n") require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.MSTeamsV2Config{ WebhookURLFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } ================================================ FILE: notify/mute.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package notify import ( "context" "errors" "fmt" "log/slog" "time" "github.com/prometheus/common/model" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "github.com/prometheus/alertmanager/inhibit" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/types" ) // A Muter determines whether a given label set is muted. Implementers that // maintain an underlying AlertMarker are expected to update it during a call of // Mutes. type Muter interface { Mutes(ctx context.Context, lset model.LabelSet) bool } // A MuteFunc is a function that implements the Muter interface. type MuteFunc func(ctx context.Context, lset model.LabelSet) bool // Mutes implements the Muter interface. func (f MuteFunc) Mutes(ctx context.Context, lset model.LabelSet) bool { return f(ctx, lset) } // MuteStage filters alerts through a Muter. type MuteStage struct { muter Muter metrics *Metrics } // NewMuteStage return a new MuteStage. func NewMuteStage(m Muter, metrics *Metrics) *MuteStage { return &MuteStage{muter: m, metrics: metrics} } // Exec implements the Stage interface. func (n *MuteStage) Exec(ctx context.Context, logger *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { ctx, span := tracer.Start(ctx, "notify.MuteStage.Exec", trace.WithAttributes(attribute.Int("alerting.alerts.count", len(alerts))), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() var ( filtered []*types.Alert muted []*types.Alert ) for _, a := range alerts { // TODO(fabxc): increment total alerts counter. // Do not send the alert if muted. if n.muter.Mutes(ctx, a.Labels) { muted = append(muted, a) } else { filtered = append(filtered, a) } // TODO(fabxc): increment muted alerts counter if muted. } if len(muted) > 0 { var reason string switch n.muter.(type) { case *silence.Silencer: reason = SuppressedReasonSilence case *inhibit.Inhibitor: reason = SuppressedReasonInhibition default: } span.SetAttributes( attribute.Int("alerting.alerts.muted.count", len(muted)), attribute.Int("alerting.alerts.filtered.count", len(filtered)), attribute.String("alerting.suppressed.reason", reason), ) n.metrics.numNotificationSuppressedTotal.WithLabelValues(reason).Add(float64(len(muted))) logger.Debug("Notifications will not be sent for muted alerts", "alerts", fmt.Sprintf("%v", muted), "reason", reason) } return ctx, filtered, nil } // A TimeMuter determines if the time is muted by one or more active or mute // time intervals. If the time is muted, it returns true and the names of the // time intervals that muted it. Otherwise, it returns false and a nil slice. type TimeMuter interface { Mutes(timeIntervalNames []string, now time.Time) (bool, []string, error) } type timeStage struct { muter TimeMuter marker types.GroupMarker metrics *Metrics } type TimeMuteStage timeStage func NewTimeMuteStage(muter TimeMuter, marker types.GroupMarker, metrics *Metrics) *TimeMuteStage { return &TimeMuteStage{muter, marker, metrics} } // Exec implements the stage interface for TimeMuteStage. // TimeMuteStage is responsible for muting alerts whose route is not in an active time. func (tms TimeMuteStage) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { ctx, span := tracer.Start(ctx, "notify.TimeMuteStage.Exec", trace.WithAttributes(attribute.Int("alerting.alerts.count", len(alerts))), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() routeID, ok := RouteID(ctx) if !ok { err := errors.New("route ID missing") span.SetStatus(codes.Error, err.Error()) span.RecordError(err) return ctx, nil, err } span.SetAttributes(attribute.String("alerting.route.id", routeID)) gkey, ok := GroupKey(ctx) if !ok { return ctx, nil, errors.New("group key missing") } span.SetAttributes(attribute.String("alerting.group.key", gkey)) muteTimeIntervalNames, ok := MuteTimeIntervalNames(ctx) if !ok { return ctx, alerts, nil } now, ok := Now(ctx) if !ok { return ctx, alerts, errors.New("missing now timestamp") } // Skip this stage if there are no mute timings. if len(muteTimeIntervalNames) == 0 { return ctx, alerts, nil } muted, mutedBy, err := tms.muter.Mutes(muteTimeIntervalNames, now) if err != nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) return ctx, alerts, err } // If muted is false then mutedBy is nil and the muted marker is removed. tms.marker.SetMuted(routeID, gkey, mutedBy) // If the current time is inside a mute time, all alerts are removed from the pipeline. if muted { tms.metrics.numNotificationSuppressedTotal.WithLabelValues(SuppressedReasonMuteTimeInterval).Add(float64(len(alerts))) l.Debug("Notifications not sent, route is within mute time", "alerts", len(alerts)) span.AddEvent("notify.TimeMuteStage.Exec muted the alerts") return ctx, nil, nil } return ctx, alerts, nil } type TimeActiveStage timeStage func NewTimeActiveStage(muter TimeMuter, marker types.GroupMarker, metrics *Metrics) *TimeActiveStage { return &TimeActiveStage{muter, marker, metrics} } // Exec implements the stage interface for TimeActiveStage. // TimeActiveStage is responsible for muting alerts whose route is not in an active time. func (tas TimeActiveStage) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { routeID, ok := RouteID(ctx) if !ok { return ctx, nil, errors.New("route ID missing") } ctx, span := tracer.Start(ctx, "notify.TimeActiveStage.Exec", trace.WithAttributes(attribute.String("alerting.route.id", routeID)), trace.WithAttributes(attribute.Int("alerting.alerts.count", len(alerts))), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() gkey, ok := GroupKey(ctx) if !ok { return ctx, nil, errors.New("group key missing") } activeTimeIntervalNames, ok := ActiveTimeIntervalNames(ctx) if !ok { return ctx, alerts, nil } // if we don't have active time intervals at all it is always active. if len(activeTimeIntervalNames) == 0 { return ctx, alerts, nil } now, ok := Now(ctx) if !ok { return ctx, alerts, errors.New("missing now timestamp") } active, _, err := tas.muter.Mutes(activeTimeIntervalNames, now) if err != nil { return ctx, alerts, err } var mutedBy []string if !active { // If the group is muted, then it must be muted by all active time intervals. // Otherwise, the group must be in at least one active time interval for it // to be active. mutedBy = activeTimeIntervalNames } tas.marker.SetMuted(routeID, gkey, mutedBy) // If the current time is not inside an active time, all alerts are removed from the pipeline if !active { span.AddEvent("notify.TimeActiveStage.Exec not active, removing all alerts") tas.metrics.numNotificationSuppressedTotal.WithLabelValues(SuppressedReasonActiveTimeInterval).Add(float64(len(alerts))) l.Debug("Notifications not sent, route is not within active time", "alerts", len(alerts)) return ctx, nil, nil } return ctx, alerts, nil } ================================================ FILE: notify/mute_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package notify import ( "context" "fmt" "reflect" "sort" "strings" "testing" "time" "github.com/prometheus/client_golang/prometheus" prom_testutil "github.com/prometheus/client_golang/prometheus/testutil" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/types" ) func TestMuteStage(t *testing.T) { // Mute all label sets that have a "mute" key. muter := MuteFunc(func(ctx context.Context, lset model.LabelSet) bool { _, ok := lset["mute"] return ok }) metrics := NewMetrics(prometheus.NewRegistry(), featurecontrol.NoopFlags{}) stage := NewMuteStage(muter, metrics) in := []model.LabelSet{ {}, {"test": "set"}, {"mute": "me"}, {"foo": "bar", "test": "set"}, {"foo": "bar", "mute": "me"}, {}, {"not": "muted"}, } out := []model.LabelSet{ {}, {"test": "set"}, {"foo": "bar", "test": "set"}, {}, {"not": "muted"}, } var inAlerts []*types.Alert for _, lset := range in { inAlerts = append(inAlerts, &types.Alert{ Alert: model.Alert{Labels: lset}, }) } _, alerts, err := stage.Exec(context.Background(), promslog.NewNopLogger(), inAlerts...) if err != nil { t.Fatalf("Exec failed: %s", err) } var got []model.LabelSet for _, a := range alerts { got = append(got, a.Labels) } if !reflect.DeepEqual(got, out) { t.Fatalf("Muting failed, expected: %v\ngot %v", out, got) } suppressed := int(prom_testutil.ToFloat64(metrics.numNotificationSuppressedTotal)) if (len(in) - len(got)) != suppressed { t.Fatalf("Expected %d alerts counted in suppressed metric but got %d", (len(in) - len(got)), suppressed) } } func TestMuteStageWithSilences(t *testing.T) { silences, err := silence.New(silence.Options{Metrics: prometheus.NewRegistry(), Retention: time.Hour}) if err != nil { t.Fatal(err) } sil := &silencepb.Silence{ EndsAt: timestamppb.New(utcNow().Add(time.Hour)), MatcherSets: []*silencepb.MatcherSet{{ Matchers: []*silencepb.Matcher{{Name: "mute", Pattern: "me"}}, }}, } if err = silences.Set(t.Context(), sil); err != nil { t.Fatal(err) } reg := prometheus.NewRegistry() marker := types.NewMarker(reg) silencer := silence.NewSilencer(silences, marker, promslog.NewNopLogger()) metrics := NewMetrics(reg, featurecontrol.NoopFlags{}) stage := NewMuteStage(silencer, metrics) in := []model.LabelSet{ {}, {"test": "set"}, {"mute": "me"}, {"foo": "bar", "test": "set"}, {"foo": "bar", "mute": "me"}, {}, {"not": "muted"}, } out := []model.LabelSet{ {}, {"test": "set"}, {"foo": "bar", "test": "set"}, {}, {"not": "muted"}, } var inAlerts []*types.Alert for _, lset := range in { inAlerts = append(inAlerts, &types.Alert{ Alert: model.Alert{Labels: lset}, }) } // Set the second alert as previously silenced with an old version // number. This is expected to get unsilenced by the stage. marker.SetActiveOrSilenced(inAlerts[1].Fingerprint(), []string{"123"}) _, alerts, err := stage.Exec(context.Background(), promslog.NewNopLogger(), inAlerts...) if err != nil { t.Fatalf("Exec failed: %s", err) } var got []model.LabelSet for _, a := range alerts { got = append(got, a.Labels) } if !reflect.DeepEqual(got, out) { t.Fatalf("Muting failed, expected: %v\ngot %v", out, got) } suppressedRoundOne := int(prom_testutil.ToFloat64(metrics.numNotificationSuppressedTotal)) if (len(in) - len(got)) != suppressedRoundOne { t.Fatalf("Expected %d alerts counted in suppressed metric but got %d", (len(in) - len(got)), suppressedRoundOne) } // Do it again to exercise the version tracking of silences. _, alerts, err = stage.Exec(context.Background(), promslog.NewNopLogger(), inAlerts...) if err != nil { t.Fatalf("Exec failed: %s", err) } got = got[:0] for _, a := range alerts { got = append(got, a.Labels) } if !reflect.DeepEqual(got, out) { t.Fatalf("Muting failed, expected: %v\ngot %v", out, got) } suppressedRoundTwo := int(prom_testutil.ToFloat64(metrics.numNotificationSuppressedTotal)) if (len(in) - len(got) + suppressedRoundOne) != suppressedRoundTwo { t.Fatalf("Expected %d alerts counted in suppressed metric but got %d", (len(in) - len(got)), suppressedRoundTwo) } // Expire the silence and verify that no alerts are silenced now. if err := silences.Expire(t.Context(), sil.Id); err != nil { t.Fatal(err) } _, alerts, err = stage.Exec(t.Context(), promslog.NewNopLogger(), inAlerts...) if err != nil { t.Fatalf("Exec failed: %s", err) } got = got[:0] for _, a := range alerts { got = append(got, a.Labels) } if !reflect.DeepEqual(got, in) { t.Fatalf("Unmuting failed, expected: %v\ngot %v", in, got) } suppressedRoundThree := int(prom_testutil.ToFloat64(metrics.numNotificationSuppressedTotal)) if (len(in) - len(got) + suppressedRoundTwo) != suppressedRoundThree { t.Fatalf("Expected %d alerts counted in suppressed metric but got %d", (len(in) - len(got)), suppressedRoundThree) } } func TestTimeMuteStage(t *testing.T) { sydney, err := time.LoadLocation("Australia/Sydney") if err != nil { t.Fatalf("Failed to load location Australia/Sydney: %s", err) } eveningsAndWeekends := map[string][]timeinterval.TimeInterval{ "evenings": {{ Times: []timeinterval.TimeRange{{ StartMinute: 0, // 00:00 EndMinute: 540, // 09:00 }, { StartMinute: 1020, // 17:00 EndMinute: 1440, // 24:00 }}, Location: &timeinterval.Location{Location: sydney}, }}, "weekends": {{ Weekdays: []timeinterval.WeekdayRange{{ InclusiveRange: timeinterval.InclusiveRange{Begin: 6, End: 6}, // Saturday }, { InclusiveRange: timeinterval.InclusiveRange{Begin: 0, End: 0}, // Sunday }}, Location: &timeinterval.Location{Location: sydney}, }}, } tests := []struct { name string intervals map[string][]timeinterval.TimeInterval now time.Time alerts []*types.Alert mutedBy []string }{{ name: "Should be muted outside working hours", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 1, 0, 0, 0, 0, sydney), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: []string{"evenings"}, }, { name: "Should not be muted during workings hours", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 1, 9, 0, 0, 0, sydney), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: nil, }, { name: "Should be muted during weekends", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 6, 10, 0, 0, 0, sydney), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: []string{"weekends"}, }, { name: "Should be muted at 12pm UTC on a weekday", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: []string{"evenings"}, }, { name: "Should be muted at 12pm UTC on a weekend", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 6, 10, 0, 0, 0, time.UTC), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: []string{"evenings", "weekends"}, }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { r := prometheus.NewRegistry() marker := types.NewMarker(r) metrics := NewMetrics(r, featurecontrol.NoopFlags{}) intervener := timeinterval.NewIntervener(test.intervals) st := NewTimeMuteStage(intervener, marker, metrics) // Get the names of all time intervals for the context. muteTimeIntervalNames := make([]string, 0, len(test.intervals)) for name := range test.intervals { muteTimeIntervalNames = append(muteTimeIntervalNames, name) } // Sort the names so we can compare mutedBy with test.mutedBy. sort.Strings(muteTimeIntervalNames) ctx := context.Background() ctx = WithNow(ctx, test.now) ctx = WithGroupKey(ctx, "group1") ctx = WithActiveTimeIntervals(ctx, nil) ctx = WithMuteTimeIntervals(ctx, muteTimeIntervalNames) ctx = WithRouteID(ctx, "route1") _, active, err := st.Exec(ctx, promslog.NewNopLogger(), test.alerts...) require.NoError(t, err) if len(test.mutedBy) == 0 { // All alerts should be active. require.Len(t, active, len(test.alerts)) // The group should not be marked. mutedBy, isMuted := marker.Muted("route1", "group1") require.False(t, isMuted) require.Empty(t, mutedBy) // The metric for total suppressed notifications should not // have been incremented, which means it will not be collected. require.NoError(t, prom_testutil.GatherAndCompare(r, strings.NewReader(` # HELP alertmanager_marked_alerts How many alerts by state are currently marked in the Alertmanager regardless of their expiry. # TYPE alertmanager_marked_alerts gauge alertmanager_marked_alerts{state="active"} 0 alertmanager_marked_alerts{state="suppressed"} 0 alertmanager_marked_alerts{state="unprocessed"} 0 `))) } else { // All alerts should be muted. require.Empty(t, active) // The group should be marked as muted. mutedBy, isMuted := marker.Muted("route1", "group1") require.True(t, isMuted) require.Equal(t, test.mutedBy, mutedBy) // Gets the metric for total suppressed notifications. require.NoError(t, prom_testutil.GatherAndCompare(r, strings.NewReader(fmt.Sprintf(` # HELP alertmanager_marked_alerts How many alerts by state are currently marked in the Alertmanager regardless of their expiry. # TYPE alertmanager_marked_alerts gauge alertmanager_marked_alerts{state="active"} 0 alertmanager_marked_alerts{state="suppressed"} 0 alertmanager_marked_alerts{state="unprocessed"} 0 # HELP alertmanager_notifications_suppressed_total The total number of notifications suppressed for being silenced, inhibited, outside of active time intervals or within muted time intervals. # TYPE alertmanager_notifications_suppressed_total counter alertmanager_notifications_suppressed_total{reason="mute_time_interval"} %d `, len(test.alerts))))) } }) } } func TestTimeActiveStage(t *testing.T) { sydney, err := time.LoadLocation("Australia/Sydney") if err != nil { t.Fatalf("Failed to load location Australia/Sydney: %s", err) } weekdays := map[string][]timeinterval.TimeInterval{ "weekdays": {{ Weekdays: []timeinterval.WeekdayRange{{ InclusiveRange: timeinterval.InclusiveRange{ Begin: 1, // Monday End: 5, // Friday }, }}, Times: []timeinterval.TimeRange{{ StartMinute: 540, // 09:00 EndMinute: 1020, // 17:00 }}, Location: &timeinterval.Location{Location: sydney}, }}, } tests := []struct { name string intervals map[string][]timeinterval.TimeInterval now time.Time alerts []*types.Alert mutedBy []string }{{ name: "Should be muted outside working hours", intervals: weekdays, now: time.Date(2024, 1, 1, 0, 0, 0, 0, sydney), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: []string{"weekdays"}, }, { name: "Should not be muted during workings hours", intervals: weekdays, now: time.Date(2024, 1, 1, 9, 0, 0, 0, sydney), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: nil, }, { name: "Should be muted during weekends", intervals: weekdays, now: time.Date(2024, 1, 6, 10, 0, 0, 0, sydney), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: []string{"weekdays"}, }, { name: "Should be muted at 12pm UTC", intervals: weekdays, now: time.Date(2024, 1, 6, 10, 0, 0, 0, time.UTC), alerts: []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"foo": "bar"}}}}, mutedBy: []string{"weekdays"}, }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { r := prometheus.NewRegistry() marker := types.NewMarker(r) metrics := NewMetrics(r, featurecontrol.NoopFlags{}) intervener := timeinterval.NewIntervener(test.intervals) st := NewTimeActiveStage(intervener, marker, metrics) // Get the names of all time intervals for the context. activeTimeIntervalNames := make([]string, 0, len(test.intervals)) for name := range test.intervals { activeTimeIntervalNames = append(activeTimeIntervalNames, name) } // Sort the names so we can compare mutedBy with test.mutedBy. sort.Strings(activeTimeIntervalNames) ctx := context.Background() ctx = WithNow(ctx, test.now) ctx = WithGroupKey(ctx, "group1") ctx = WithActiveTimeIntervals(ctx, activeTimeIntervalNames) ctx = WithMuteTimeIntervals(ctx, nil) ctx = WithRouteID(ctx, "route1") _, active, err := st.Exec(ctx, promslog.NewNopLogger(), test.alerts...) require.NoError(t, err) if len(test.mutedBy) == 0 { // All alerts should be active. require.Len(t, active, len(test.alerts)) // The group should not be marked. mutedBy, isMuted := marker.Muted("route1", "group1") require.False(t, isMuted) require.Empty(t, mutedBy) // The metric for total suppressed notifications should not // have been incremented, which means it will not be collected. require.NoError(t, prom_testutil.GatherAndCompare(r, strings.NewReader(` # HELP alertmanager_marked_alerts How many alerts by state are currently marked in the Alertmanager regardless of their expiry. # TYPE alertmanager_marked_alerts gauge alertmanager_marked_alerts{state="active"} 0 alertmanager_marked_alerts{state="suppressed"} 0 alertmanager_marked_alerts{state="unprocessed"} 0 `))) } else { // All alerts should be muted. require.Empty(t, active) // The group should be marked as muted. mutedBy, isMuted := marker.Muted("route1", "group1") require.True(t, isMuted) require.Equal(t, test.mutedBy, mutedBy) // Gets the metric for total suppressed notifications. require.NoError(t, prom_testutil.GatherAndCompare(r, strings.NewReader(fmt.Sprintf(` # HELP alertmanager_marked_alerts How many alerts by state are currently marked in the Alertmanager regardless of their expiry. # TYPE alertmanager_marked_alerts gauge alertmanager_marked_alerts{state="active"} 0 alertmanager_marked_alerts{state="suppressed"} 0 alertmanager_marked_alerts{state="unprocessed"} 0 # HELP alertmanager_notifications_suppressed_total The total number of notifications suppressed for being silenced, inhibited, outside of active time intervals or within muted time intervals. # TYPE alertmanager_notifications_suppressed_total counter alertmanager_notifications_suppressed_total{reason="active_time_interval"} %d `, len(test.alerts))))) } }) } } ================================================ FILE: notify/notify.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package notify import ( "context" "errors" "fmt" "log/slog" "sort" "sync" "time" "github.com/cenkalti/backoff/v4" "github.com/cespare/xxhash/v2" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/common/model" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/inhibit" "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/silence" "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/alertmanager/types" ) var tracer = otel.Tracer("github.com/prometheus/alertmanager/notify") // ResolvedSender returns true if resolved notifications should be sent. type ResolvedSender interface { SendResolved() bool } // Peer represents the cluster node from where we are the sending the notification. type Peer interface { // WaitReady waits until the node silences and notifications have settled before attempting to send a notification. WaitReady(context.Context) error } // MinTimeout is the minimum timeout that is set for the context of a call // to a notification pipeline. const MinTimeout = 10 * time.Second // Notifier notifies about alerts under constraints of the given context. It // returns an error if unsuccessful and a flag whether the error is // recoverable. This information is useful for a retry logic. type Notifier interface { Notify(context.Context, ...*types.Alert) (bool, error) } // Integration wraps a notifier and its configuration to be uniquely identified // by name and index from its origin in the configuration. type Integration struct { notifier Notifier rs ResolvedSender name string idx int receiverName string } // NewIntegration returns a new integration. func NewIntegration(notifier Notifier, rs ResolvedSender, name string, idx int, receiverName string) Integration { return Integration{ notifier: notifier, rs: rs, name: name, idx: idx, receiverName: receiverName, } } // Notify implements the Notifier interface. func (i *Integration) Notify(ctx context.Context, alerts ...*types.Alert) (recoverable bool, err error) { ctx, span := tracer.Start(ctx, "notify.Integration.Notify", trace.WithAttributes(attribute.String("alerting.notify.integration.name", i.name)), trace.WithAttributes(attribute.Int("alerting.alerts.count", len(alerts))), trace.WithSpanKind(trace.SpanKindClient), ) defer func() { span.SetAttributes(attribute.Bool("alerting.notify.error.recoverable", recoverable)) if err != nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) } span.End() }() recoverable, err = i.notifier.Notify(ctx, alerts...) return recoverable, err } // SendResolved implements the ResolvedSender interface. func (i *Integration) SendResolved() bool { return i.rs.SendResolved() } // Name returns the name of the integration. func (i *Integration) Name() string { return i.name } // Index returns the index of the integration. func (i *Integration) Index() int { return i.idx } // String implements the Stringer interface. func (i *Integration) String() string { return fmt.Sprintf("%s[%d]", i.name, i.idx) } // notifyKey defines a custom type with which a context is populated to // avoid accidental collisions. type notifyKey int const ( keyReceiverName notifyKey = iota keyRepeatInterval keyGroupLabels keyGroupKey keyFiringAlerts keyResolvedAlerts keyNow keyMuteTimeIntervals keyActiveTimeIntervals keyRouteID keyNflogStore keyNotificationReason ) // WithReceiverName populates a context with a receiver name. func WithReceiverName(ctx context.Context, rcv string) context.Context { return context.WithValue(ctx, keyReceiverName, rcv) } // WithGroupKey populates a context with a group key. func WithGroupKey(ctx context.Context, s string) context.Context { return context.WithValue(ctx, keyGroupKey, s) } // WithFiringAlerts populates a context with a slice of firing alerts. func WithFiringAlerts(ctx context.Context, alerts []uint64) context.Context { return context.WithValue(ctx, keyFiringAlerts, alerts) } // WithResolvedAlerts populates a context with a slice of resolved alerts. func WithResolvedAlerts(ctx context.Context, alerts []uint64) context.Context { return context.WithValue(ctx, keyResolvedAlerts, alerts) } // WithGroupLabels populates a context with grouping labels. func WithGroupLabels(ctx context.Context, lset model.LabelSet) context.Context { return context.WithValue(ctx, keyGroupLabels, lset) } // WithNow populates a context with a now timestamp. func WithNow(ctx context.Context, t time.Time) context.Context { return context.WithValue(ctx, keyNow, t) } // WithRepeatInterval populates a context with a repeat interval. func WithRepeatInterval(ctx context.Context, t time.Duration) context.Context { return context.WithValue(ctx, keyRepeatInterval, t) } // WithMuteTimeIntervals populates a context with a slice of mute time names. func WithMuteTimeIntervals(ctx context.Context, mt []string) context.Context { return context.WithValue(ctx, keyMuteTimeIntervals, mt) } func WithActiveTimeIntervals(ctx context.Context, at []string) context.Context { return context.WithValue(ctx, keyActiveTimeIntervals, at) } func WithRouteID(ctx context.Context, routeID string) context.Context { return context.WithValue(ctx, keyRouteID, routeID) } func WithNotificationReason(ctx context.Context, reason NotifyReason) context.Context { return context.WithValue(ctx, keyNotificationReason, reason) } // RepeatInterval extracts a repeat interval from the context. Iff none exists, the // second argument is false. func RepeatInterval(ctx context.Context) (time.Duration, bool) { v, ok := ctx.Value(keyRepeatInterval).(time.Duration) return v, ok } // ReceiverName extracts a receiver name from the context. Iff none exists, the // second argument is false. func ReceiverName(ctx context.Context) (string, bool) { v, ok := ctx.Value(keyReceiverName).(string) return v, ok } // GroupKey extracts a group key from the context. Iff none exists, the // second argument is false. func GroupKey(ctx context.Context) (string, bool) { v, ok := ctx.Value(keyGroupKey).(string) return v, ok } // GroupLabels extracts grouping label set from the context. Iff none exists, the // second argument is false. func GroupLabels(ctx context.Context) (model.LabelSet, bool) { v, ok := ctx.Value(keyGroupLabels).(model.LabelSet) return v, ok } // Now extracts a now timestamp from the context. Iff none exists, the // second argument is false. func Now(ctx context.Context) (time.Time, bool) { v, ok := ctx.Value(keyNow).(time.Time) return v, ok } // FiringAlerts extracts a slice of firing alerts from the context. // Iff none exists, the second argument is false. func FiringAlerts(ctx context.Context) ([]uint64, bool) { v, ok := ctx.Value(keyFiringAlerts).([]uint64) return v, ok } // ResolvedAlerts extracts a slice of firing alerts from the context. // Iff none exists, the second argument is false. func ResolvedAlerts(ctx context.Context) ([]uint64, bool) { v, ok := ctx.Value(keyResolvedAlerts).([]uint64) return v, ok } // MuteTimeIntervalNames extracts a slice of mute time names from the context. If and only if none exists, the // second argument is false. func MuteTimeIntervalNames(ctx context.Context) ([]string, bool) { v, ok := ctx.Value(keyMuteTimeIntervals).([]string) return v, ok } // ActiveTimeIntervalNames extracts a slice of active time names from the context. If none exists, the // second argument is false. func ActiveTimeIntervalNames(ctx context.Context) ([]string, bool) { v, ok := ctx.Value(keyActiveTimeIntervals).([]string) return v, ok } // RouteID extracts a RouteID from the context. Iff none exists, the // // second argument is false. func RouteID(ctx context.Context) (string, bool) { v, ok := ctx.Value(keyRouteID).(string) return v, ok } func NotificationReason(ctx context.Context) (NotifyReason, bool) { v, ok := ctx.Value(keyNotificationReason).(NotifyReason) return v, ok } func WithNflogStore(ctx context.Context, store *nflog.Store) context.Context { return context.WithValue(ctx, keyNflogStore, store) } func NflogStore(ctx context.Context) (*nflog.Store, bool) { v, ok := ctx.Value(keyNflogStore).(*nflog.Store) return v, ok } // A Stage processes alerts under the constraints of the given context. type Stage interface { Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) } // StageFunc wraps a function to represent a Stage. type StageFunc func(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) // Exec implements Stage interface. func (f StageFunc) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { return f(ctx, l, alerts...) } type NotificationLog interface { Log(r *nflogpb.Receiver, gkey string, firingAlerts, resolvedAlerts []uint64, store *nflog.Store, expiry time.Duration) error Query(params ...nflog.QueryParam) ([]*nflogpb.Entry, error) } type Metrics struct { numNotifications *prometheus.CounterVec numTotalFailedNotifications *prometheus.CounterVec numNotificationRequestsTotal *prometheus.CounterVec numNotificationRequestsFailedTotal *prometheus.CounterVec numNotificationSuppressedTotal *prometheus.CounterVec notificationLatencySeconds *prometheus.HistogramVec ff featurecontrol.Flagger } func NewMetrics(r prometheus.Registerer, ff featurecontrol.Flagger) *Metrics { labels := []string{"integration"} if ff.EnableReceiverNamesInMetrics() { labels = append(labels, "receiver_name") } m := &Metrics{ numNotifications: promauto.With(r).NewCounterVec(prometheus.CounterOpts{ Namespace: "alertmanager", Name: "notifications_total", Help: "The total number of attempted notifications.", }, labels), numTotalFailedNotifications: promauto.With(r).NewCounterVec(prometheus.CounterOpts{ Namespace: "alertmanager", Name: "notifications_failed_total", Help: "The total number of failed notifications.", }, append(labels, "reason")), numNotificationRequestsTotal: promauto.With(r).NewCounterVec(prometheus.CounterOpts{ Namespace: "alertmanager", Name: "notification_requests_total", Help: "The total number of attempted notification requests.", }, labels), numNotificationRequestsFailedTotal: promauto.With(r).NewCounterVec(prometheus.CounterOpts{ Namespace: "alertmanager", Name: "notification_requests_failed_total", Help: "The total number of failed notification requests.", }, labels), numNotificationSuppressedTotal: promauto.With(r).NewCounterVec(prometheus.CounterOpts{ Namespace: "alertmanager", Name: "notifications_suppressed_total", Help: "The total number of notifications suppressed for being silenced, inhibited, outside of active time intervals or within muted time intervals.", }, []string{"reason"}), notificationLatencySeconds: promauto.With(r).NewHistogramVec(prometheus.HistogramOpts{ Namespace: "alertmanager", Name: "notification_latency_seconds", Help: "The latency of notifications in seconds.", Buckets: []float64{1, 5, 10, 15, 20}, NativeHistogramBucketFactor: 1.1, NativeHistogramMaxBucketNumber: 100, NativeHistogramMinResetDuration: 1 * time.Hour, }, labels), ff: ff, } return m } func (m *Metrics) InitializeFor(receiver map[string][]Integration) { if m.ff.EnableReceiverNamesInMetrics() { // Reset the vectors to take into account receiver names changing after hot reloads. m.numNotifications.Reset() m.numNotificationRequestsTotal.Reset() m.numNotificationRequestsFailedTotal.Reset() m.notificationLatencySeconds.Reset() m.numTotalFailedNotifications.Reset() for name, integrations := range receiver { for _, integration := range integrations { m.numNotifications.WithLabelValues(integration.Name(), name) m.numNotificationRequestsTotal.WithLabelValues(integration.Name(), name) m.numNotificationRequestsFailedTotal.WithLabelValues(integration.Name(), name) m.notificationLatencySeconds.WithLabelValues(integration.Name(), name) for _, reason := range possibleFailureReasonCategory { m.numTotalFailedNotifications.WithLabelValues(integration.Name(), name, reason) } } } return } // When the feature flag is not enabled, we just carry on registering _all_ the integrations. for _, integration := range []string{ "email", "pagerduty", "wechat", "pushover", "slack", "opsgenie", "webhook", "victorops", "sns", "telegram", "discord", "webex", "msteams", "msteamsv2", "incidentio", "jira", "rocketchat", "mattermost", } { m.numNotifications.WithLabelValues(integration) m.numNotificationRequestsTotal.WithLabelValues(integration) m.numNotificationRequestsFailedTotal.WithLabelValues(integration) m.notificationLatencySeconds.WithLabelValues(integration) for _, reason := range possibleFailureReasonCategory { m.numTotalFailedNotifications.WithLabelValues(integration, reason) } } } type PipelineBuilder struct { metrics *Metrics ff featurecontrol.Flagger } func NewPipelineBuilder(r prometheus.Registerer, ff featurecontrol.Flagger) *PipelineBuilder { return &PipelineBuilder{ metrics: NewMetrics(r, ff), ff: ff, } } // New returns a map of receivers to Stages. func (pb *PipelineBuilder) New( receivers map[string][]Integration, wait func() time.Duration, inhibitor *inhibit.Inhibitor, silencer *silence.Silencer, intervener *timeinterval.Intervener, marker types.GroupMarker, notificationLog NotificationLog, peer Peer, ) RoutingStage { rs := make(RoutingStage, len(receivers)) ms := NewGossipSettleStage(peer) is := NewMuteStage(inhibitor, pb.metrics) tas := NewTimeActiveStage(intervener, marker, pb.metrics) tms := NewTimeMuteStage(intervener, marker, pb.metrics) ss := NewMuteStage(silencer, pb.metrics) for name := range receivers { st := createReceiverStage(name, receivers[name], wait, notificationLog, pb.metrics) rs[name] = MultiStage{ms, is, tas, tms, ss, st} } pb.metrics.InitializeFor(receivers) return rs } // createReceiverStage creates a pipeline of stages for a receiver. func createReceiverStage( name string, integrations []Integration, wait func() time.Duration, notificationLog NotificationLog, metrics *Metrics, ) Stage { var fs FanoutStage for i := range integrations { recv := &nflogpb.Receiver{ GroupName: name, Integration: integrations[i].Name(), Idx: uint32(integrations[i].Index()), } var s MultiStage s = append(s, NewWaitStage(wait)) s = append(s, NewDedupStage(&integrations[i], notificationLog, recv)) s = append(s, NewRetryStage(integrations[i], name, metrics)) s = append(s, NewSetNotifiesStage(notificationLog, recv)) fs = append(fs, s) } return fs } // RoutingStage executes the inner stages based on the receiver specified in // the context. type RoutingStage map[string]Stage // Exec implements the Stage interface. func (rs RoutingStage) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { receiver, ok := ReceiverName(ctx) if !ok { return ctx, nil, errors.New("receiver missing") } ctx, span := tracer.Start(ctx, "notify.RoutingStage.Exec", trace.WithAttributes( attribute.String("alerting.notify.receiver.name", receiver), attribute.Int("alerting.alerts.count", len(alerts)), ), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() s, ok := rs[receiver] if !ok { return ctx, nil, errors.New("stage for receiver missing") } return s.Exec(ctx, l, alerts...) } // A MultiStage executes a series of stages sequentially. type MultiStage []Stage // Exec implements the Stage interface. func (ms MultiStage) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { var err error for _, s := range ms { if len(alerts) == 0 { return ctx, nil, nil } ctx, alerts, err = s.Exec(ctx, l, alerts...) if err != nil { return ctx, nil, err } } return ctx, alerts, nil } // FanoutStage executes its stages concurrently. type FanoutStage []Stage // Exec attempts to execute all stages concurrently and discards the results. // It returns its input alerts and an error if one or more stages fail. func (fs FanoutStage) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { var ( wg sync.WaitGroup mtx sync.Mutex errs error ) wg.Add(len(fs)) for _, s := range fs { go func(s Stage) { if _, _, err := s.Exec(ctx, l, alerts...); err != nil { mtx.Lock() errs = errors.Join(errs, err) mtx.Unlock() } wg.Done() }(s) } wg.Wait() return ctx, alerts, errs } // GossipSettleStage waits until the Gossip has settled to forward alerts. type GossipSettleStage struct { peer Peer } // NewGossipSettleStage returns a new GossipSettleStage. func NewGossipSettleStage(p Peer) *GossipSettleStage { return &GossipSettleStage{peer: p} } func (n *GossipSettleStage) Exec(ctx context.Context, _ *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { if n.peer != nil { if err := n.peer.WaitReady(ctx); err != nil { return ctx, nil, err } } return ctx, alerts, nil } const ( SuppressedReasonSilence = "silence" SuppressedReasonInhibition = "inhibition" SuppressedReasonMuteTimeInterval = "mute_time_interval" SuppressedReasonActiveTimeInterval = "active_time_interval" ) // WaitStage waits for a certain amount of time before continuing or until the // context is done. type WaitStage struct { wait func() time.Duration } // NewWaitStage returns a new WaitStage. func NewWaitStage(wait func() time.Duration) *WaitStage { return &WaitStage{ wait: wait, } } // Exec implements the Stage interface. func (ws *WaitStage) Exec(ctx context.Context, _ *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { select { case <-time.After(ws.wait()): case <-ctx.Done(): return ctx, nil, ctx.Err() } return ctx, alerts, nil } // DedupStage filters alerts. // Filtering happens based on a notification log. type DedupStage struct { rs ResolvedSender nflog NotificationLog recv *nflogpb.Receiver now func() time.Time hash func(*types.Alert) uint64 } // NewDedupStage wraps a DedupStage that runs against the given notification log. func NewDedupStage(rs ResolvedSender, l NotificationLog, recv *nflogpb.Receiver) *DedupStage { return &DedupStage{ rs: rs, nflog: l, recv: recv, now: utcNow, hash: hashAlert, } } func utcNow() time.Time { return time.Now().UTC() } // Wrap a slice in a struct so we can store a pointer in sync.Pool. type hashBuffer struct { buf []byte } var hashBuffers = sync.Pool{ New: func() any { return &hashBuffer{buf: make([]byte, 0, 1024)} }, } func hashAlert(a *types.Alert) uint64 { const sep = '\xff' hb := hashBuffers.Get().(*hashBuffer) defer hashBuffers.Put(hb) b := hb.buf[:0] names := make(model.LabelNames, 0, len(a.Labels)) for ln := range a.Labels { names = append(names, ln) } sort.Sort(names) for _, ln := range names { b = append(b, string(ln)...) b = append(b, sep) b = append(b, string(a.Labels[ln])...) b = append(b, sep) } hash := xxhash.Sum64(b) return hash } type NotifyReason int const ( ReasonDoNotNotify NotifyReason = iota ReasonFirstNotification ReasonNewAlertsInGroup ReasonNewResolvedAlerts ReasonAllAlertsResolved ReasonRepeatIntervalElapsed ReasonUnknown ) func (r NotifyReason) shouldNotify() bool { return r != ReasonDoNotNotify } func (r NotifyReason) String() string { switch r { case ReasonDoNotNotify: return "none" case ReasonFirstNotification: return "first notification" case ReasonNewAlertsInGroup: return "new alerts added" case ReasonNewResolvedAlerts: return "some alerts resolved" case ReasonAllAlertsResolved: return "all alerts resolved" case ReasonRepeatIntervalElapsed: return "repeat interval elapsed" default: return "unknown" } } func (n *DedupStage) needsUpdate(entry *nflogpb.Entry, firing, resolved map[uint64]struct{}, repeat time.Duration, now time.Time) NotifyReason { // If we haven't notified about the alert group before, notify right away // unless we only have resolved alerts. if entry == nil { if len(firing) > 0 { return ReasonFirstNotification } return ReasonDoNotNotify } // new alerts in the group if !entry.IsFiringSubset(firing) { // If the previous entry has no firing alerts, it was a resolution and we // should treat this as the first notification for the group. if len(entry.FiringAlerts) == 0 { return ReasonFirstNotification } return ReasonNewAlertsInGroup } // Notify about all alerts being resolved. // This is done irrespective of the send_resolved flag to make sure that // the firing alerts are cleared from the notification log. if len(firing) == 0 { // If the current alert group and last notification contain no firing // alert, it means that some alerts have been fired and resolved during the // last interval. In this case, there is no need to notify the receiver // since it doesn't know about them. if len(entry.FiringAlerts) > 0 { return ReasonAllAlertsResolved } return ReasonDoNotNotify } if n.rs.SendResolved() && !entry.IsResolvedSubset(resolved) { return ReasonNewResolvedAlerts } // Nothing changed, only notify if the repeat interval has passed. isRepeatIntervalElapsed := entry.Timestamp.AsTime().Before(now.Add(-repeat)) if isRepeatIntervalElapsed { return ReasonRepeatIntervalElapsed } return ReasonDoNotNotify } // partitionAlertsByState separates alerts into firing and resolved, returning both slices and sets. func partitionAlertsByState(alerts []*types.Alert, hashFn func(*types.Alert) uint64) (firing, resolved []uint64, firingSet, resolvedSet map[uint64]struct{}) { firingSet = make(map[uint64]struct{}, len(alerts)) resolvedSet = make(map[uint64]struct{}, len(alerts)) firing = make([]uint64, 0, len(alerts)) resolved = make([]uint64, 0, len(alerts)) for _, a := range alerts { hash := hashFn(a) if a.Resolved() { resolved = append(resolved, hash) resolvedSet[hash] = struct{}{} } else { firing = append(firing, hash) firingSet[hash] = struct{}{} } } return firing, resolved, firingSet, resolvedSet } // Exec implements the Stage interface. func (n *DedupStage) Exec(ctx context.Context, _ *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { gkey, ok := GroupKey(ctx) if !ok { return ctx, nil, errors.New("group key missing") } ctx, span := tracer.Start(ctx, "notify.DedupStage.Exec", trace.WithAttributes(attribute.String("alerting.group.key", gkey)), trace.WithAttributes(attribute.Int("alerting.alerts.count", len(alerts))), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() repeatInterval, ok := RepeatInterval(ctx) if !ok { return ctx, nil, errors.New("repeat interval missing") } firing, resolved, firingSet, resolvedSet := partitionAlertsByState(alerts, n.hash) ctx = WithFiringAlerts(ctx, firing) ctx = WithResolvedAlerts(ctx, resolved) entries, err := n.nflog.Query(nflog.QGroupKey(gkey), nflog.QReceiver(n.recv)) if err != nil && !errors.Is(err, nflog.ErrNotFound) { return ctx, nil, err } var entry *nflogpb.Entry switch len(entries) { case 0: case 1: entry = entries[0] default: return ctx, nil, fmt.Errorf("unexpected entry result size %d", len(entries)) } now := n.now() if ctxNow, ok := Now(ctx); ok { now = ctxNow } updateReason := n.needsUpdate(entry, firingSet, resolvedSet, repeatInterval, now) ctx = WithNotificationReason(ctx, updateReason) if updateReason == ReasonFirstNotification { ctx = WithNflogStore(ctx, nflog.NewStore(nil)) } else { ctx = WithNflogStore(ctx, nflog.NewStore(entry)) } if updateReason.shouldNotify() { span.AddEvent("notify.DedupStage.Exec nflog needs update") return ctx, alerts, nil } return ctx, nil, nil } // RetryStage notifies via passed integration with exponential backoff until it // succeeds. It aborts if the context is canceled or timed out. type RetryStage struct { integration Integration groupName string metrics *Metrics labelValues []string } // NewRetryStage returns a new instance of a RetryStage. func NewRetryStage(i Integration, groupName string, metrics *Metrics) *RetryStage { labelValues := []string{i.Name()} if metrics.ff.EnableReceiverNamesInMetrics() { labelValues = append(labelValues, i.receiverName) } return &RetryStage{ integration: i, groupName: groupName, metrics: metrics, labelValues: labelValues, } } func (r RetryStage) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { r.metrics.numNotifications.WithLabelValues(r.labelValues...).Inc() ctx, span := tracer.Start(ctx, "notify.RetryStage.Exec", trace.WithAttributes(attribute.String("alerting.group.name", r.groupName)), trace.WithAttributes(attribute.String("alerting.integration.name", r.integration.name)), trace.WithAttributes(attribute.StringSlice("alerting.label.values", r.labelValues)), trace.WithAttributes(attribute.Int("alerting.alerts.count", len(alerts))), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() ctx, alerts, err := r.exec(ctx, l, alerts...) failureReason := DefaultReason.String() if err != nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) var e *ErrorWithReason if errors.As(err, &e) { failureReason = e.Reason.String() } r.metrics.numTotalFailedNotifications.WithLabelValues(append(r.labelValues, failureReason)...).Inc() } return ctx, alerts, err } func (r RetryStage) exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { var sent []*types.Alert // If we shouldn't send notifications for resolved alerts, but there are only // resolved alerts, report them all as successfully notified (we still want the // notification log to log them for the next run of DedupStage). if !r.integration.SendResolved() { firing, ok := FiringAlerts(ctx) if !ok { return ctx, nil, errors.New("firing alerts missing") } if len(firing) == 0 { return ctx, alerts, nil } for _, a := range alerts { if a.Status() != model.AlertResolved { sent = append(sent, a) } } } else { sent = alerts } b := backoff.NewExponentialBackOff() b.MaxElapsedTime = 0 // Always retry. tick := backoff.NewTicker(b) defer tick.Stop() var ( i = 0 iErr error ) l = l.With("receiver", r.groupName, "integration", r.integration.String()) if groupKey, ok := GroupKey(ctx); ok { l = l.With("aggrGroup", groupKey) } for { // Always check the context first to not notify again. select { case <-ctx.Done(): if iErr == nil { iErr = ctx.Err() if errors.Is(iErr, context.Canceled) { iErr = NewErrorWithReason(ContextCanceledReason, iErr) } else if errors.Is(iErr, context.DeadlineExceeded) { iErr = NewErrorWithReason(ContextDeadlineExceededReason, iErr) } } if iErr != nil { return ctx, nil, fmt.Errorf("%s/%s: notify retry canceled after %d attempts: %w", r.groupName, r.integration.String(), i, iErr) } return ctx, nil, nil default: } select { case <-tick.C: now := time.Now() retry, err := r.integration.Notify(ctx, sent...) i++ dur := time.Since(now) r.metrics.notificationLatencySeconds.WithLabelValues(r.labelValues...).Observe(dur.Seconds()) r.metrics.numNotificationRequestsTotal.WithLabelValues(r.labelValues...).Inc() if err != nil { r.metrics.numNotificationRequestsFailedTotal.WithLabelValues(r.labelValues...).Inc() if !retry { return ctx, alerts, fmt.Errorf("%s/%s: notify retry canceled due to unrecoverable error after %d attempts: %w", r.groupName, r.integration.String(), i, err) } if ctx.Err() == nil { if iErr == nil || err.Error() != iErr.Error() { // Log the error if the context isn't done and the error isn't the same as before. l.Warn("Notify attempt failed, will retry later", "attempts", i, "err", err) } // Save this error to be able to return the last seen error by an // integration upon context timeout. iErr = err } } else { l := l.With("attempts", i, "duration", dur) if i <= 1 { l = l.With("alerts", fmt.Sprintf("%v", alerts)) l.Debug("Notify success") } else { l.Info("Notify success") } return ctx, alerts, nil } case <-ctx.Done(): } } } // SetNotifiesStage sets the notification information about passed alerts. The // passed alerts should have already been sent to the receivers. type SetNotifiesStage struct { nflog NotificationLog recv *nflogpb.Receiver } // NewSetNotifiesStage returns a new instance of a SetNotifiesStage. func NewSetNotifiesStage(l NotificationLog, recv *nflogpb.Receiver) *SetNotifiesStage { return &SetNotifiesStage{ nflog: l, recv: recv, } } // Exec implements the Stage interface. func (n SetNotifiesStage) Exec(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { gkey, ok := GroupKey(ctx) if !ok { return ctx, nil, errors.New("group key missing") } ctx, span := tracer.Start(ctx, "notify.SetNotifiesStage.Exec", trace.WithAttributes(attribute.String("alerting.group.key", gkey)), trace.WithAttributes(attribute.Int("alerting.alerts.count", len(alerts))), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() firing, ok := FiringAlerts(ctx) if !ok { return ctx, nil, errors.New("firing alerts missing") } resolved, ok := ResolvedAlerts(ctx) if !ok { return ctx, nil, errors.New("resolved alerts missing") } repeat, ok := RepeatInterval(ctx) if !ok { return ctx, nil, errors.New("repeat interval missing") } expiry := 2 * repeat span.SetAttributes( attribute.Int("alerting.alerts.firing.count", len(firing)), attribute.Int("alerting.alerts.resolved.count", len(resolved)), ) // Extract receiver data from context if present (it's ok for it to be nil). store, _ := NflogStore(ctx) return ctx, alerts, n.nflog.Log(n.recv, gkey, firing, resolved, store, expiry) } ================================================ FILE: notify/notify_test.go ================================================ // Copyright 2015 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package notify import ( "context" "errors" "fmt" "io" "log/slog" "reflect" "testing" "time" "github.com/prometheus/client_golang/prometheus" prom_testutil "github.com/prometheus/client_golang/prometheus/testutil" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/nflog/nflogpb" "github.com/prometheus/alertmanager/types" ) type sendResolved bool func (s sendResolved) SendResolved() bool { return bool(s) } type notifierFunc func(ctx context.Context, alerts ...*types.Alert) (bool, error) func (f notifierFunc) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { return f(ctx, alerts...) } type failStage struct{} func (s failStage) Exec(ctx context.Context, l *slog.Logger, as ...*types.Alert) (context.Context, []*types.Alert, error) { return ctx, nil, fmt.Errorf("some error") } type testNflog struct { qres []*nflogpb.Entry qerr error logFunc func(r *nflogpb.Receiver, gkey string, firingAlerts, resolvedAlerts []uint64, receiverData *nflog.Store, expiry time.Duration) error } func (l *testNflog) Query(p ...nflog.QueryParam) ([]*nflogpb.Entry, error) { return l.qres, l.qerr } func (l *testNflog) Log(r *nflogpb.Receiver, gkey string, firingAlerts, resolvedAlerts []uint64, receiverData *nflog.Store, expiry time.Duration) error { return l.logFunc(r, gkey, firingAlerts, resolvedAlerts, receiverData, expiry) } func (l *testNflog) GC() (int, error) { return 0, nil } func (l *testNflog) Snapshot(w io.Writer) (int, error) { return 0, nil } func alertHashSet(hashes ...uint64) map[uint64]struct{} { res := map[uint64]struct{}{} for _, h := range hashes { res[h] = struct{}{} } return res } func TestDedupStageNeedsUpdate(t *testing.T) { now := utcNow() cases := []struct { entry *nflogpb.Entry firingAlerts map[uint64]struct{} resolvedAlerts map[uint64]struct{} repeat time.Duration resolve bool res bool }{ { // No matching nflog entry should update. entry: nil, firingAlerts: alertHashSet(2, 3, 4), res: true, }, { // No matching nflog entry shouldn't update if no alert fires. entry: nil, resolvedAlerts: alertHashSet(2, 3, 4), res: false, }, { // Different sets of firing alerts should update. entry: &nflogpb.Entry{FiringAlerts: []uint64{1, 2, 3}}, firingAlerts: alertHashSet(2, 3, 4), res: true, }, { // Zero timestamp in the nflog entry should always update. entry: &nflogpb.Entry{ FiringAlerts: []uint64{1, 2, 3}, Timestamp: ×tamppb.Timestamp{}, }, firingAlerts: alertHashSet(1, 2, 3), res: true, }, { // Identical sets of alerts shouldn't update before repeat_interval. entry: &nflogpb.Entry{ FiringAlerts: []uint64{1, 2, 3}, Timestamp: timestamppb.New(now.Add(-9 * time.Minute)), }, repeat: 10 * time.Minute, firingAlerts: alertHashSet(1, 2, 3), res: false, }, { // Identical sets of alerts should update after repeat_interval. entry: &nflogpb.Entry{ FiringAlerts: []uint64{1, 2, 3}, Timestamp: timestamppb.New(now.Add(-11 * time.Minute)), }, repeat: 10 * time.Minute, firingAlerts: alertHashSet(1, 2, 3), res: true, }, { // Different sets of resolved alerts without firing alerts shouldn't update after repeat_interval. entry: &nflogpb.Entry{ ResolvedAlerts: []uint64{1, 2, 3}, Timestamp: timestamppb.New(now.Add(-11 * time.Minute)), }, repeat: 10 * time.Minute, resolvedAlerts: alertHashSet(3, 4, 5), resolve: true, res: false, }, { // Different sets of resolved alerts shouldn't update when resolve is false. entry: &nflogpb.Entry{ FiringAlerts: []uint64{1, 2}, ResolvedAlerts: []uint64{3}, Timestamp: timestamppb.New(now.Add(-9 * time.Minute)), }, repeat: 10 * time.Minute, firingAlerts: alertHashSet(1), resolvedAlerts: alertHashSet(2, 3), resolve: false, res: false, }, { // Different sets of resolved alerts should update when resolve is true. entry: &nflogpb.Entry{ FiringAlerts: []uint64{1, 2}, ResolvedAlerts: []uint64{3}, Timestamp: timestamppb.New(now.Add(-9 * time.Minute)), }, repeat: 10 * time.Minute, firingAlerts: alertHashSet(1), resolvedAlerts: alertHashSet(2, 3), resolve: true, res: true, }, { // Empty set of firing alerts should update when resolve is false. entry: &nflogpb.Entry{ FiringAlerts: []uint64{1, 2}, ResolvedAlerts: []uint64{3}, Timestamp: timestamppb.New(now.Add(-9 * time.Minute)), }, repeat: 10 * time.Minute, firingAlerts: alertHashSet(), resolvedAlerts: alertHashSet(1, 2, 3), resolve: false, res: true, }, { // Empty set of firing alerts should update when resolve is true. entry: &nflogpb.Entry{ FiringAlerts: []uint64{1, 2}, ResolvedAlerts: []uint64{3}, Timestamp: timestamppb.New(now.Add(-9 * time.Minute)), }, repeat: 10 * time.Minute, firingAlerts: alertHashSet(), resolvedAlerts: alertHashSet(1, 2, 3), resolve: true, res: true, }, } for i, c := range cases { t.Log("case", i) s := &DedupStage{ now: func() time.Time { return now }, rs: sendResolved(c.resolve), } res := s.needsUpdate(c.entry, c.firingAlerts, c.resolvedAlerts, c.repeat, now).shouldNotify() require.Equal(t, c.res, res) } } func TestDedupStageUsesContextNow(t *testing.T) { base := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) s := &DedupStage{ hash: func(*types.Alert) uint64 { return 1 }, now: func() time.Time { return base.Add(time.Hour) }, rs: sendResolved(false), nflog: &testNflog{ qerr: nil, qres: []*nflogpb.Entry{{ FiringAlerts: []uint64{1}, Timestamp: timestamppb.New(base), }}, }, } ctx := context.Background() ctx = WithGroupKey(ctx, "group") ctx = WithRepeatInterval(ctx, 30*time.Minute) ctx = WithNow(ctx, base.Add(10*time.Minute)) alerts := []*types.Alert{{Alert: model.Alert{Labels: model.LabelSet{"alertname": "test"}}}} _, res, err := s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Empty(t, res) } func TestDedupStage(t *testing.T) { i := 0 now := utcNow() s := &DedupStage{ hash: func(a *types.Alert) uint64 { res := uint64(i) i++ return res }, now: func() time.Time { return now }, rs: sendResolved(false), } ctx := context.Background() _, _, err := s.Exec(ctx, promslog.NewNopLogger()) require.EqualError(t, err, "group key missing") ctx = WithGroupKey(ctx, "1") _, _, err = s.Exec(ctx, promslog.NewNopLogger()) require.EqualError(t, err, "repeat interval missing") ctx = WithRepeatInterval(ctx, time.Hour) alerts := []*types.Alert{{}, {}, {}} // Must catch notification log query errors. s.nflog = &testNflog{ qerr: errors.New("bad things"), } ctx, _, err = s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.EqualError(t, err, "bad things") // ... but skip ErrNotFound. s.nflog = &testNflog{ qerr: nflog.ErrNotFound, } ctx, res, err := s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err, "unexpected error on not found log entry") require.Equal(t, alerts, res, "input alerts differ from result alerts") reason, ok := NotificationReason(ctx) require.True(t, ok, "NotificationReason should be in context") require.Equal(t, ReasonFirstNotification, reason, "should be first notification") s.nflog = &testNflog{ qerr: nil, qres: []*nflogpb.Entry{ {FiringAlerts: []uint64{0, 1, 2}}, {FiringAlerts: []uint64{1, 2, 3}}, }, } ctx, _, err = s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.Contains(t, err.Error(), "result size") // Must return no error and no alerts no need to update. i = 0 s.nflog = &testNflog{ qerr: nflog.ErrNotFound, qres: []*nflogpb.Entry{ { FiringAlerts: []uint64{0, 1, 2}, Timestamp: timestamppb.New(now), }, }, } ctx, res, err = s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Nil(t, res, "unexpected alerts returned") reason, ok = NotificationReason(ctx) require.True(t, ok, "NotificationReason should be in context") require.Equal(t, ReasonDoNotNotify, reason, "should not notify when nothing changed") // Must return no error and all input alerts on changes. i = 0 s.nflog = &testNflog{ qerr: nil, qres: []*nflogpb.Entry{ { FiringAlerts: []uint64{1, 2, 3, 4}, Timestamp: timestamppb.New(now), }, }, } ctx, res, err = s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, alerts, res, "unexpected alerts returned") reason, ok = NotificationReason(ctx) require.True(t, ok, "NotificationReason should be in context") require.Equal(t, ReasonNewAlertsInGroup, reason, "should notify when alerts change") } func TestMultiStage(t *testing.T) { var ( alerts1 = []*types.Alert{{}} alerts2 = []*types.Alert{{}, {}} alerts3 = []*types.Alert{{}, {}, {}} ) stage := MultiStage{ StageFunc(func(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { if !reflect.DeepEqual(alerts, alerts1) { t.Fatal("Input not equal to input of MultiStage") } //nolint:staticcheck // Ignore SA1029 ctx = context.WithValue(ctx, "key", "value") return ctx, alerts2, nil }), StageFunc(func(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { if !reflect.DeepEqual(alerts, alerts2) { t.Fatal("Input not equal to output of previous stage") } v, ok := ctx.Value("key").(string) if !ok || v != "value" { t.Fatalf("Expected value %q for key %q but got %q", "value", "key", v) } return ctx, alerts3, nil }), } _, alerts, err := stage.Exec(context.Background(), promslog.NewNopLogger(), alerts1...) if err != nil { t.Fatalf("Exec failed: %s", err) } if !reflect.DeepEqual(alerts, alerts3) { t.Fatal("Output of MultiStage is not equal to the output of the last stage") } } func TestMultiStageFailure(t *testing.T) { var ( ctx = context.Background() s1 = failStage{} stage = MultiStage{s1} ) _, _, err := stage.Exec(ctx, promslog.NewNopLogger(), nil) if err.Error() != "some error" { t.Fatal("Errors were not propagated correctly by MultiStage") } } func TestRoutingStage(t *testing.T) { var ( alerts1 = []*types.Alert{{}} alerts2 = []*types.Alert{{}, {}} ) stage := RoutingStage{ "name": StageFunc(func(ctx context.Context, l *slog.Logger, alerts ...*types.Alert) (context.Context, []*types.Alert, error) { if !reflect.DeepEqual(alerts, alerts1) { t.Fatal("Input not equal to input of RoutingStage") } return ctx, alerts2, nil }), "not": failStage{}, } ctx := WithReceiverName(context.Background(), "name") _, alerts, err := stage.Exec(ctx, promslog.NewNopLogger(), alerts1...) if err != nil { t.Fatalf("Exec failed: %s", err) } if !reflect.DeepEqual(alerts, alerts2) { t.Fatal("Output of RoutingStage is not equal to the output of the inner stage") } } func TestRetryStageWithError(t *testing.T) { fail, retry := true, true sent := []*types.Alert{} i := Integration{ notifier: notifierFunc(func(ctx context.Context, alerts ...*types.Alert) (bool, error) { if fail { fail = false return retry, errors.New("fail to deliver notification") } sent = append(sent, alerts...) return false, nil }), rs: sendResolved(false), } r := NewRetryStage(i, "", NewMetrics(prometheus.NewRegistry(), featurecontrol.NoopFlags{})) alerts := []*types.Alert{ { Alert: model.Alert{ EndsAt: time.Now().Add(time.Hour), }, }, } ctx := context.Background() ctx = WithFiringAlerts(ctx, []uint64{0}) // Notify with a recoverable error should retry and succeed. resctx, res, err := r.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, alerts, res) require.Equal(t, alerts, sent) require.NotNil(t, resctx) // Notify with an unrecoverable error should fail. sent = sent[:0] fail = true retry = false resctx, _, err = r.Exec(ctx, promslog.NewNopLogger(), alerts...) require.Error(t, err) require.NotNil(t, resctx) } func TestRetryStageWithErrorCode(t *testing.T) { testcases := map[string]struct { isNewErrorWithReason bool reason Reason reasonlabel string expectedCount int }{ "for clientError": {isNewErrorWithReason: true, reason: ClientErrorReason, reasonlabel: ClientErrorReason.String(), expectedCount: 1}, "for serverError": {isNewErrorWithReason: true, reason: ServerErrorReason, reasonlabel: ServerErrorReason.String(), expectedCount: 1}, "for unexpected code": {isNewErrorWithReason: false, reason: DefaultReason, reasonlabel: DefaultReason.String(), expectedCount: 1}, } for _, testData := range testcases { retry := false testData := testData i := Integration{ name: "test", notifier: notifierFunc(func(ctx context.Context, alerts ...*types.Alert) (bool, error) { if !testData.isNewErrorWithReason { return retry, errors.New("fail to deliver notification") } return retry, NewErrorWithReason(testData.reason, errors.New("fail to deliver notification")) }), rs: sendResolved(false), } r := NewRetryStage(i, "", NewMetrics(prometheus.NewRegistry(), featurecontrol.NoopFlags{})) alerts := []*types.Alert{ { Alert: model.Alert{ EndsAt: time.Now().Add(time.Hour), }, }, } ctx := context.Background() ctx = WithFiringAlerts(ctx, []uint64{0}) // Notify with a non-recoverable error. resctx, _, err := r.Exec(ctx, promslog.NewNopLogger(), alerts...) counter := r.metrics.numTotalFailedNotifications require.Equal(t, testData.expectedCount, int(prom_testutil.ToFloat64(counter.WithLabelValues(r.integration.Name(), testData.reasonlabel)))) require.Error(t, err) require.NotNil(t, resctx) } } func TestRetryStageWithContextCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) i := Integration{ name: "test", notifier: notifierFunc(func(ctx context.Context, alerts ...*types.Alert) (bool, error) { cancel() return true, errors.New("request failed: context canceled") }), rs: sendResolved(false), } r := NewRetryStage(i, "", NewMetrics(prometheus.NewRegistry(), featurecontrol.NoopFlags{})) alerts := []*types.Alert{ { Alert: model.Alert{ EndsAt: time.Now().Add(time.Hour), }, }, } ctx = WithFiringAlerts(ctx, []uint64{0}) // Notify with a non-recoverable error. resctx, _, err := r.Exec(ctx, promslog.NewNopLogger(), alerts...) counter := r.metrics.numTotalFailedNotifications require.Equal(t, 1, int(prom_testutil.ToFloat64(counter.WithLabelValues(r.integration.Name(), ContextCanceledReason.String())))) require.Contains(t, err.Error(), "notify retry canceled after 1 attempts: context canceled") require.Error(t, err) require.NotNil(t, resctx) } func TestRetryStageNoResolved(t *testing.T) { sent := []*types.Alert{} i := Integration{ notifier: notifierFunc(func(ctx context.Context, alerts ...*types.Alert) (bool, error) { sent = append(sent, alerts...) return false, nil }), rs: sendResolved(false), } r := NewRetryStage(i, "", NewMetrics(prometheus.NewRegistry(), featurecontrol.NoopFlags{})) alerts := []*types.Alert{ { Alert: model.Alert{ EndsAt: time.Now().Add(-time.Hour), }, }, { Alert: model.Alert{ EndsAt: time.Now().Add(time.Hour), }, }, } ctx := context.Background() resctx, res, err := r.Exec(ctx, promslog.NewNopLogger(), alerts...) require.EqualError(t, err, "firing alerts missing") require.Nil(t, res) require.NotNil(t, resctx) ctx = WithFiringAlerts(ctx, []uint64{0}) resctx, res, err = r.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, alerts, res) require.Equal(t, []*types.Alert{alerts[1]}, sent) require.NotNil(t, resctx) // All alerts are resolved. sent = sent[:0] ctx = WithFiringAlerts(ctx, []uint64{}) alerts[1].EndsAt = time.Now().Add(-time.Hour) resctx, res, err = r.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, alerts, res) require.Equal(t, []*types.Alert{}, sent) require.NotNil(t, resctx) } func TestRetryStageSendResolved(t *testing.T) { sent := []*types.Alert{} i := Integration{ notifier: notifierFunc(func(ctx context.Context, alerts ...*types.Alert) (bool, error) { sent = append(sent, alerts...) return false, nil }), rs: sendResolved(true), } r := NewRetryStage(i, "", NewMetrics(prometheus.NewRegistry(), featurecontrol.NoopFlags{})) alerts := []*types.Alert{ { Alert: model.Alert{ EndsAt: time.Now().Add(-time.Hour), }, }, { Alert: model.Alert{ EndsAt: time.Now().Add(time.Hour), }, }, } ctx := context.Background() ctx = WithFiringAlerts(ctx, []uint64{0}) resctx, res, err := r.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, alerts, res) require.Equal(t, alerts, sent) require.NotNil(t, resctx) // All alerts are resolved. sent = sent[:0] ctx = WithFiringAlerts(ctx, []uint64{}) alerts[1].EndsAt = time.Now().Add(-time.Hour) resctx, res, err = r.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, alerts, res) require.Equal(t, alerts, sent) require.NotNil(t, resctx) } func TestSetNotifiesStage(t *testing.T) { tnflog := &testNflog{} s := &SetNotifiesStage{ recv: &nflogpb.Receiver{GroupName: "test"}, nflog: tnflog, } alerts := []*types.Alert{{}, {}, {}} ctx := context.Background() resctx, res, err := s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.EqualError(t, err, "group key missing") require.Nil(t, res) require.NotNil(t, resctx) ctx = WithGroupKey(ctx, "1") resctx, res, err = s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.EqualError(t, err, "firing alerts missing") require.Nil(t, res) require.NotNil(t, resctx) ctx = WithFiringAlerts(ctx, []uint64{0, 1, 2}) resctx, res, err = s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.EqualError(t, err, "resolved alerts missing") require.Nil(t, res) require.NotNil(t, resctx) ctx = WithResolvedAlerts(ctx, []uint64{}) ctx = WithRepeatInterval(ctx, time.Hour) tnflog.logFunc = func(r *nflogpb.Receiver, gkey string, firingAlerts, resolvedAlerts []uint64, receiverData *nflog.Store, expiry time.Duration) error { require.Equal(t, s.recv, r) require.Equal(t, "1", gkey) require.Equal(t, []uint64{0, 1, 2}, firingAlerts) require.Equal(t, []uint64{}, resolvedAlerts) require.Equal(t, 2*time.Hour, expiry) return nil } resctx, res, err = s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, alerts, res) require.NotNil(t, resctx) ctx = WithFiringAlerts(ctx, []uint64{}) ctx = WithResolvedAlerts(ctx, []uint64{0, 1, 2}) tnflog.logFunc = func(r *nflogpb.Receiver, gkey string, firingAlerts, resolvedAlerts []uint64, receiverData *nflog.Store, expiry time.Duration) error { require.Equal(t, s.recv, r) require.Equal(t, "1", gkey) require.Equal(t, []uint64{}, firingAlerts) require.Equal(t, []uint64{0, 1, 2}, resolvedAlerts) require.Equal(t, 2*time.Hour, expiry) return nil } resctx, res, err = s.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, alerts, res) require.NotNil(t, resctx) } func TestReceiverData_PreservationWhenNotifierDoesNotUpdate(t *testing.T) { var storedData *nflog.Store callCount := 0 tnflog := &testNflog{ logFunc: func(r *nflogpb.Receiver, gkey string, firingAlerts, resolvedAlerts []uint64, receiverData *nflog.Store, expiry time.Duration) error { storedData = receiverData return nil }, } tnflog.qres = []*nflogpb.Entry{} recv := &nflogpb.Receiver{GroupName: "test"} dedupStage := NewDedupStage(sendResolved(true), tnflog, recv) notifier := notifierFunc(func(ctx context.Context, alerts ...*types.Alert) (bool, error) { callCount++ if callCount == 1 { // First call - store some data if store, ok := NflogStore(ctx); ok { store.SetStr("threadTs", "1234.5678") } return false, nil } // Second call - notifier doesn't update ReceiverData // Does NOT call StoreStr - just returns success return false, nil }) integration := NewIntegration(notifier, sendResolved(true), "test", 0, "test-receiver") retryStage := NewRetryStage(integration, "test", NewMetrics(prometheus.NewRegistry(), featurecontrol.NoopFlags{})) setNotifiesStage := NewSetNotifiesStage(tnflog, recv) ctx := context.Background() ctx = WithGroupKey(ctx, "testkey") ctx = WithRepeatInterval(ctx, time.Hour) alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "test"}, }, }, } // First notification ctx, _, err := dedupStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) ctx, _, err = retryStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) _, _, err = setNotifiesStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) // Verify first notification stored data require.NotNil(t, storedData) threadTs, found := storedData.GetStr("threadTs") require.True(t, found, "threadTs should be in stored data") require.Equal(t, "1234.5678", threadTs) firstReceiverData := map[string]*nflogpb.ReceiverDataValue{ "threadTs": { Value: &nflogpb.ReceiverDataValue_StrVal{StrVal: "1234.5678"}, }, } // Second notification - load previous state tnflog.qres = []*nflogpb.Entry{ { Receiver: recv, GroupKey: []byte("testkey"), FiringAlerts: []uint64{1}, ResolvedAlerts: []uint64{}, ReceiverData: firstReceiverData, }, } ctx = context.Background() ctx = WithGroupKey(ctx, "testkey") ctx = WithRepeatInterval(ctx, time.Hour) ctx, _, err = dedupStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) ctx, _, err = retryStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) _, _, err = setNotifiesStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) if storedData == nil { t.Error("ReceiverData was lost! Second notification has nil data") } else { if threadTs, exists := storedData.GetStr("threadTs"); !exists { t.Error("ReceiverData 'threadTs' was lost! Second notification doesn't have it") } else { t.Logf("threadTs value: %s", threadTs) } } } func TestDedupStageExtractsReceiverData_DataPresent(t *testing.T) { receiverData := map[string]*nflogpb.ReceiverDataValue{ "threadTs": { Value: &nflogpb.ReceiverDataValue_StrVal{StrVal: "1234.5678"}, }, "counter": { Value: &nflogpb.ReceiverDataValue_IntVal{IntVal: 42}, }, } entry := &nflogpb.Entry{ Receiver: &nflogpb.Receiver{GroupName: "test"}, GroupKey: []byte("key"), FiringAlerts: []uint64{1, 2, 3}, ReceiverData: receiverData, } tnflog := &testNflog{ qres: []*nflogpb.Entry{entry}, } stage := NewDedupStage(sendResolved(false), tnflog, &nflogpb.Receiver{GroupName: "test"}) ctx := context.Background() ctx = WithGroupKey(ctx, "key") ctx = WithRepeatInterval(ctx, time.Hour) alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "test"}, }, }, } resCtx, _, err := stage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) store, ok := NflogStore(resCtx) require.True(t, ok, "NflogStore should be in context") require.NotNil(t, store) threadTs, found := store.GetStr("threadTs") require.True(t, found) require.Equal(t, "1234.5678", threadTs) counter, found := store.GetInt("counter") require.True(t, found) require.Equal(t, int64(42), counter) } func TestDedupStageExtractsReceiverData_NilReceiverData(t *testing.T) { entry := &nflogpb.Entry{ Receiver: &nflogpb.Receiver{GroupName: "test"}, GroupKey: []byte("key"), FiringAlerts: []uint64{1, 2, 3}, ReceiverData: nil, } tnflog := &testNflog{ qres: []*nflogpb.Entry{entry}, } stage := NewDedupStage(sendResolved(false), tnflog, &nflogpb.Receiver{GroupName: "test"}) ctx := context.Background() ctx = WithGroupKey(ctx, "key") ctx = WithRepeatInterval(ctx, time.Hour) alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "test"}, }, }, } resCtx, _, err := stage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) store, ok := NflogStore(resCtx) require.True(t, ok, "NflogStore should be in context even when ReceiverData is nil") require.NotNil(t, store) } func TestDedupStageExtractsReceiverData_NoEntry(t *testing.T) { tnflog := &testNflog{ qres: []*nflogpb.Entry{}, } stage := NewDedupStage(sendResolved(false), tnflog, &nflogpb.Receiver{GroupName: "test"}) ctx := context.Background() ctx = WithGroupKey(ctx, "key") ctx = WithRepeatInterval(ctx, time.Hour) alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "test"}, }, }, } resCtx, _, err := stage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) store, ok := NflogStore(resCtx) require.True(t, ok, "NflogStore should be in context even when no entry exists") require.NotNil(t, store) } func TestNflogStore_NoLeakBetweenNotificationSequences(t *testing.T) { var storedData *nflog.Store callCount := 0 var capturedStoreValues []map[string]string tnflog := &testNflog{ logFunc: func(r *nflogpb.Receiver, gkey string, firingAlerts, resolvedAlerts []uint64, receiverData *nflog.Store, expiry time.Duration) error { storedData = receiverData return nil }, } recv := &nflogpb.Receiver{GroupName: "test"} dedupStage := NewDedupStage(sendResolved(true), tnflog, recv) notifier := notifierFunc(func(ctx context.Context, alerts ...*types.Alert) (bool, error) { callCount++ store, ok := NflogStore(ctx) require.True(t, ok, "Store should be available in context") storeSnapshot := make(map[string]string) if val, found := store.GetStr("session_data"); found { storeSnapshot["session_data"] = val } capturedStoreValues = append(capturedStoreValues, storeSnapshot) store.SetStr("session_data", fmt.Sprintf("session_%d", callCount)) return false, nil }) integration := NewIntegration(notifier, sendResolved(true), "test", 0, "test-receiver") retryStage := NewRetryStage(integration, "test", NewMetrics(prometheus.NewRegistry(), featurecontrol.NoopFlags{})) setNotifiesStage := NewSetNotifiesStage(tnflog, recv) alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "test"}, EndsAt: time.Now().Add(time.Hour), }, }, } // Scenario 1: First notification ever (no previous nflog entry) tnflog.qres = []*nflogpb.Entry{} ctx := context.Background() ctx = WithGroupKey(ctx, "testkey") ctx = WithRepeatInterval(ctx, time.Hour) ctx, _, err := dedupStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) ctx, _, err = retryStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) _, _, err = setNotifiesStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, 1, callCount) require.Empty(t, capturedStoreValues[0], "First notification should see empty Store") require.NotNil(t, storedData) sessionData, found := storedData.GetStr("session_data") require.True(t, found) require.Equal(t, "session_1", sessionData) // Scenario 2: Alert resolves, then fires again (new firing sequence) firstSessionData := map[string]*nflogpb.ReceiverDataValue{ "session_data": { Value: &nflogpb.ReceiverDataValue_StrVal{StrVal: "session_1"}, }, } tnflog.qres = []*nflogpb.Entry{ { Receiver: recv, GroupKey: []byte("testkey"), FiringAlerts: []uint64{}, ResolvedAlerts: []uint64{1}, ReceiverData: firstSessionData, }, } ctx = context.Background() ctx = WithGroupKey(ctx, "testkey") ctx = WithRepeatInterval(ctx, time.Hour) ctx, _, err = dedupStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) ctx, _, err = retryStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) _, _, err = setNotifiesStage.Exec(ctx, promslog.NewNopLogger(), alerts...) require.NoError(t, err) require.Equal(t, 2, callCount) require.Len(t, capturedStoreValues, 2) require.Empty(t, capturedStoreValues[1], "New firing sequence should see empty Store (no leak from resolved entry)") require.NotNil(t, storedData) sessionData, found = storedData.GetStr("session_data") require.True(t, found) require.Equal(t, "session_2", sessionData) } func BenchmarkHashAlert(b *testing.B) { alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"foo": "the_first_value", "bar": "the_second_value", "another": "value"}, }, } for b.Loop() { hashAlert(alert) } } ================================================ FILE: notify/opsgenie/api_key_file ================================================ my_secret_api_key ================================================ FILE: notify/opsgenie/opsgenie.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package opsgenie import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "maps" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes. const maxMessageLenRunes = 130 // Notifier implements a Notifier for OpsGenie notifications. type Notifier struct { conf *config.OpsGenieConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier } // New returns a new OpsGenie notifier. func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "opsgenie", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, }, nil } type opsGenieCreateMessage struct { Alias string `json:"alias"` Message string `json:"message"` Description string `json:"description,omitempty"` Details map[string]string `json:"details"` Source string `json:"source"` Responders []opsGenieCreateMessageResponder `json:"responders,omitempty"` Tags []string `json:"tags,omitempty"` Note string `json:"note,omitempty"` Priority string `json:"priority,omitempty"` Entity string `json:"entity,omitempty"` Actions []string `json:"actions,omitempty"` } type opsGenieCreateMessageResponder struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Username string `json:"username,omitempty"` Type string `json:"type"` // team, user, escalation, schedule etc. } type opsGenieCloseMessage struct { Source string `json:"source"` } type opsGenieUpdateMessageMessage struct { Message string `json:"message,omitempty"` } type opsGenieUpdateDescriptionMessage struct { Description string `json:"description,omitempty"` } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { requests, retry, err := n.createRequests(ctx, as...) if err != nil { return retry, err } for _, req := range requests { req.Header.Set("User-Agent", notify.UserAgentHeader) resp, err := n.client.Do(req) if err != nil { return true, err } shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) notify.Drain(resp) if err != nil { return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } } return true, nil } // Like Split but filter out empty strings. func safeSplit(s, sep string) []string { a := strings.Split(strings.TrimSpace(s), sep) b := a[:0] for _, x := range a { if x != "" { b = append(b, x) } } return b } // Create requests for a list of alerts. func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return nil, false, err } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") data := notify.GetTemplateData(ctx, n.tmpl, as, logger) tmpl := notify.TmplText(n.tmpl, data, &err) details := make(map[string]string) maps.Copy(details, data.CommonLabels) for k, v := range n.conf.Details { details[k] = tmpl(v) } requests := []*http.Request{} var ( alias = key.Hash() alerts = types.Alerts(as...) ) switch alerts.Status() { case model.AlertResolved: resolvedEndpointURL := n.conf.APIURL.Copy() resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias) q := resolvedEndpointURL.Query() q.Set("identifierType", "alias") resolvedEndpointURL.RawQuery = q.Encode() msg := &opsGenieCloseMessage{Source: tmpl(n.conf.Source)} var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(msg); err != nil { return nil, false, err } req, err := http.NewRequest("POST", resolvedEndpointURL.String(), &buf) if err != nil { return nil, true, err } requests = append(requests, req.WithContext(ctx)) default: message, truncated := notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes) if truncated { logger.Warn("Truncated message", "alert", key, "max_runes", maxMessageLenRunes) } createEndpointURL := n.conf.APIURL.Copy() createEndpointURL.Path += "v2/alerts" var responders []opsGenieCreateMessageResponder for _, r := range n.conf.Responders { responder := opsGenieCreateMessageResponder{ ID: tmpl(r.ID), Name: tmpl(r.Name), Username: tmpl(r.Username), Type: tmpl(r.Type), } if responder == (opsGenieCreateMessageResponder{}) { // Filter out empty responders. This is useful if you want to fill // responders dynamically from alert's common labels. continue } if responder.Type == "teams" { teams := safeSplit(responder.Name, ",") for _, team := range teams { newResponder := opsGenieCreateMessageResponder{ Name: tmpl(team), Type: tmpl("team"), } responders = append(responders, newResponder) } continue } responders = append(responders, responder) } msg := &opsGenieCreateMessage{ Alias: alias, Message: message, Description: tmpl(n.conf.Description), Details: details, Source: tmpl(n.conf.Source), Responders: responders, Tags: safeSplit(tmpl(n.conf.Tags), ","), Note: tmpl(n.conf.Note), Priority: tmpl(n.conf.Priority), Entity: tmpl(n.conf.Entity), Actions: safeSplit(tmpl(n.conf.Actions), ","), } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(msg); err != nil { return nil, false, err } req, err := http.NewRequest("POST", createEndpointURL.String(), &buf) if err != nil { return nil, true, err } requests = append(requests, req.WithContext(ctx)) if n.conf.UpdateAlerts { updateMessageEndpointURL := n.conf.APIURL.Copy() updateMessageEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/message", alias) q := updateMessageEndpointURL.Query() q.Set("identifierType", "alias") updateMessageEndpointURL.RawQuery = q.Encode() updateMsgMsg := &opsGenieUpdateMessageMessage{ Message: msg.Message, } var updateMessageBuf bytes.Buffer if err := json.NewEncoder(&updateMessageBuf).Encode(updateMsgMsg); err != nil { return nil, false, err } req, err := http.NewRequest("PUT", updateMessageEndpointURL.String(), &updateMessageBuf) if err != nil { return nil, true, err } requests = append(requests, req) updateDescriptionEndpointURL := n.conf.APIURL.Copy() updateDescriptionEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/description", alias) q = updateDescriptionEndpointURL.Query() q.Set("identifierType", "alias") updateDescriptionEndpointURL.RawQuery = q.Encode() updateDescMsg := &opsGenieUpdateDescriptionMessage{ Description: msg.Description, } var updateDescriptionBuf bytes.Buffer if err := json.NewEncoder(&updateDescriptionBuf).Encode(updateDescMsg); err != nil { return nil, false, err } req, err = http.NewRequest("PUT", updateDescriptionEndpointURL.String(), &updateDescriptionBuf) if err != nil { return nil, true, err } requests = append(requests, req.WithContext(ctx)) } } var apiKey string if n.conf.APIKey != "" { apiKey = tmpl(string(n.conf.APIKey)) } else { content, err := os.ReadFile(n.conf.APIKeyFile) if err != nil { return nil, false, fmt.Errorf("read key_file error: %w", err) } apiKey = tmpl(string(content)) apiKey = strings.TrimSpace(string(apiKey)) } if err != nil { return nil, false, fmt.Errorf("templating error: %w", err) } for _, req := range requests { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("GenieKey %s", apiKey)) } return requests, true, nil } ================================================ FILE: notify/opsgenie/opsgenie_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package opsgenie import ( "context" "fmt" "io" "net/http" "net/url" "os" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) func TestOpsGenieRetry(t *testing.T) { notifier, err := New( &config.OpsGenieConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests) for statusCode, expected := range test.RetryTests(retryCodes) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "error on status %d", statusCode) } } func TestOpsGenieRedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() key := "key" notifier, err := New( &config.OpsGenieConfig{ APIURL: &amcommoncfg.URL{URL: u}, APIKey: commoncfg.Secret(key), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key) } func TestGettingOpsGegineApikeyFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() key := "key" f, err := os.CreateTemp(t.TempDir(), "opsgenie_test") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(key) require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.OpsGenieConfig{ APIURL: &amcommoncfg.URL{URL: u}, APIKeyFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key) } func TestOpsGenie(t *testing.T) { u, err := url.Parse("https://opsgenie/api") if err != nil { t.Fatalf("failed to parse URL: %v", err) } logger := promslog.NewNopLogger() tmpl := test.CreateTmpl(t) for _, tc := range []struct { title string cfg *config.OpsGenieConfig expectedEmptyAlertBody string expectedBody string }{ { title: "config without details", cfg: &config.OpsGenieConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Message: `{{ .CommonLabels.Message }}`, Description: `{{ .CommonLabels.Description }}`, Source: `{{ .CommonLabels.Source }}`, Responders: []config.OpsGenieConfigResponder{ { Name: `{{ .CommonLabels.ResponderName1 }}`, Type: `{{ .CommonLabels.ResponderType1 }}`, }, { Name: `{{ .CommonLabels.ResponderName2 }}`, Type: `{{ .CommonLabels.ResponderType2 }}`, }, }, Tags: `{{ .CommonLabels.Tags }}`, Note: `{{ .CommonLabels.Note }}`, Priority: `{{ .CommonLabels.Priority }}`, Entity: `{{ .CommonLabels.Entity }}`, Actions: `{{ .CommonLabels.Actions }}`, APIKey: `{{ .ExternalURL }}`, APIURL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{},"source":""} `, expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"EscalationA","type":"escalation"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1","entity":"test-domain","actions":["doThis","doThat"]} `, }, { title: "config with details", cfg: &config.OpsGenieConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Message: `{{ .CommonLabels.Message }}`, Description: `{{ .CommonLabels.Description }}`, Source: `{{ .CommonLabels.Source }}`, Details: map[string]string{ "Description": `adjusted {{ .CommonLabels.Description }}`, }, Responders: []config.OpsGenieConfigResponder{ { Name: `{{ .CommonLabels.ResponderName1 }}`, Type: `{{ .CommonLabels.ResponderType1 }}`, }, { Name: `{{ .CommonLabels.ResponderName2 }}`, Type: `{{ .CommonLabels.ResponderType2 }}`, }, }, Tags: `{{ .CommonLabels.Tags }}`, Note: `{{ .CommonLabels.Note }}`, Priority: `{{ .CommonLabels.Priority }}`, Entity: `{{ .CommonLabels.Entity }}`, Actions: `{{ .CommonLabels.Actions }}`, APIKey: `{{ .ExternalURL }}`, APIURL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{"Description":"adjusted "},"source":""} `, expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"adjusted description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"EscalationA","type":"escalation"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1","entity":"test-domain","actions":["doThis","doThat"]} `, }, { title: "config with multiple teams", cfg: &config.OpsGenieConfig{ NotifierConfig: amcommoncfg.NotifierConfig{ VSendResolved: true, }, Message: `{{ .CommonLabels.Message }}`, Description: `{{ .CommonLabels.Description }}`, Source: `{{ .CommonLabels.Source }}`, Details: map[string]string{ "Description": `adjusted {{ .CommonLabels.Description }}`, }, Responders: []config.OpsGenieConfigResponder{ { Name: `{{ .CommonLabels.ResponderName3 }}`, Type: `{{ .CommonLabels.ResponderType3 }}`, }, }, Tags: `{{ .CommonLabels.Tags }}`, Note: `{{ .CommonLabels.Note }}`, Priority: `{{ .CommonLabels.Priority }}`, APIKey: `{{ .ExternalURL }}`, APIURL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, expectedEmptyAlertBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"","details":{"Description":"adjusted "},"source":""} `, expectedBody: `{"alias":"6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b","message":"message","description":"description","details":{"Actions":"doThis,doThat","Description":"adjusted description","Entity":"test-domain","Message":"message","Note":"this is a note","Priority":"P1","ResponderName1":"TeamA","ResponderName2":"EscalationA","ResponderName3":"TeamA,TeamB","ResponderType1":"team","ResponderType2":"escalation","ResponderType3":"teams","Source":"http://prometheus","Tags":"tag1,tag2"},"source":"http://prometheus","responders":[{"name":"TeamA","type":"team"},{"name":"TeamB","type":"team"}],"tags":["tag1","tag2"],"note":"this is a note","priority":"P1"} `, }, } { t.Run(tc.title, func(t *testing.T) { notifier, err := New(tc.cfg, tmpl, logger) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") expectedURL, _ := url.Parse("https://opsgenie/apiv2/alerts") // Empty alert. alert1 := &types.Alert{ Alert: model.Alert{ StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } req, retry, err := notifier.createRequests(ctx, alert1) require.NoError(t, err) require.Len(t, req, 1) require.True(t, retry) require.Equal(t, expectedURL, req[0].URL) require.Equal(t, "GenieKey http://am", req[0].Header.Get("Authorization")) require.Equal(t, tc.expectedEmptyAlertBody, readBody(t, req[0])) // Fully defined alert. alert2 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "Message": "message", "Description": "description", "Source": "http://prometheus", "ResponderName1": "TeamA", "ResponderType1": "team", "ResponderName2": "EscalationA", "ResponderType2": "escalation", "ResponderName3": "TeamA,TeamB", "ResponderType3": "teams", "Tags": "tag1,tag2", "Note": "this is a note", "Priority": "P1", "Entity": "test-domain", "Actions": "doThis,doThat", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } req, retry, err = notifier.createRequests(ctx, alert2) require.NoError(t, err) require.True(t, retry) require.Len(t, req, 1) require.Equal(t, tc.expectedBody, readBody(t, req[0])) // Broken API Key Template. tc.cfg.APIKey = "{{ kaput " _, _, err = notifier.createRequests(ctx, alert2) require.Error(t, err) require.Equal(t, "templating error: template: :1: function \"kaput\" not defined", err.Error()) }) } } func TestOpsGenieWithUpdate(t *testing.T) { u, err := url.Parse("https://test-opsgenie-url") require.NoError(t, err) tmpl := test.CreateTmpl(t) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") opsGenieConfigWithUpdate := config.OpsGenieConfig{ Message: `{{ .CommonLabels.Message }}`, Description: `{{ .CommonLabels.Description }}`, UpdateAlerts: true, APIKey: "test-api-key", APIURL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, } notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger()) alert := &types.Alert{ Alert: model.Alert{ StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), Labels: model.LabelSet{ "Message": "new message", "Description": "new description", }, }, } require.NoError(t, err) requests, retry, err := notifierWithUpdate.createRequests(ctx, alert) require.NoError(t, err) require.True(t, retry) require.Len(t, requests, 3) body0 := readBody(t, requests[0]) body1 := readBody(t, requests[1]) body2 := readBody(t, requests[2]) key, _ := notify.ExtractGroupKey(ctx) alias := key.Hash() require.Equal(t, "https://test-opsgenie-url/v2/alerts", requests[0].URL.String()) require.NotEmpty(t, body0) require.Equal(t, requests[1].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/message?identifierType=alias", alias)) require.JSONEq(t, `{"message":"new message"}`, body1) require.Equal(t, requests[2].URL.String(), fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/description?identifierType=alias", alias)) require.JSONEq(t, `{"description":"new description"}`, body2) } func TestOpsGenieApiKeyFile(t *testing.T) { u, err := url.Parse("https://test-opsgenie-url") require.NoError(t, err) tmpl := test.CreateTmpl(t) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") opsGenieConfigWithUpdate := config.OpsGenieConfig{ APIKeyFile: `./api_key_file`, APIURL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, } notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger()) require.NoError(t, err) requests, _, err := notifierWithUpdate.createRequests(ctx) require.NoError(t, err) require.Equal(t, "GenieKey my_secret_api_key", requests[0].Header.Get("Authorization")) } func readBody(t *testing.T, r *http.Request) string { t.Helper() body, err := io.ReadAll(r.Body) require.NoError(t, err) return string(body) } ================================================ FILE: notify/pagerduty/pagerduty.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pagerduty import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "os" "strings" "github.com/alecthomas/units" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( maxEventSize int = 512000 // https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTc4-send-a-v1-event - 1024 characters or runes. maxV1DescriptionLenRunes = 1024 // https://developer.pagerduty.com/docs/ZG9jOjExMDI5NTgx-send-an-alert-event - 1024 characters or runes. maxV2SummaryLenRunes = 1024 ) // Notifier implements a Notifier for PagerDuty notifications. type Notifier struct { conf *config.PagerdutyConfig tmpl *template.Template logger *slog.Logger apiV1 string // for tests. client *http.Client retrier *notify.Retrier } // New returns a new PagerDuty notifier. func New(c *config.PagerdutyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "pagerduty", httpOpts...) if err != nil { return nil, err } n := &Notifier{conf: c, tmpl: t, logger: l, client: client} if c.ServiceKey != "" || c.ServiceKeyFile != "" { n.apiV1 = "https://events.pagerduty.com/generic/2010-04-15/create_event.json" // Retrying can solve the issue on 403 (rate limiting) and 5xx response codes. // https://developer.pagerduty.com/docs/events-api-v1-overview#api-response-codes--retry-logic n.retrier = ¬ify.Retrier{RetryCodes: []int{http.StatusForbidden}, CustomDetailsFunc: errDetails} } else { // Retrying can solve the issue on 429 (rate limiting) and 5xx response codes. // https://developer.pagerduty.com/docs/events-api-v2-overview#response-codes--retry-logic n.retrier = ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}, CustomDetailsFunc: errDetails} } return n, nil } const ( pagerDutyEventTrigger = "trigger" pagerDutyEventResolve = "resolve" ) type pagerDutyMessage struct { RoutingKey string `json:"routing_key,omitempty"` ServiceKey string `json:"service_key,omitempty"` DedupKey string `json:"dedup_key,omitempty"` IncidentKey string `json:"incident_key,omitempty"` EventType string `json:"event_type,omitempty"` Description string `json:"description,omitempty"` EventAction string `json:"event_action"` Payload *pagerDutyPayload `json:"payload"` Client string `json:"client,omitempty"` ClientURL string `json:"client_url,omitempty"` Details map[string]any `json:"details,omitempty"` Images []pagerDutyImage `json:"images,omitempty"` Links []pagerDutyLink `json:"links,omitempty"` } type pagerDutyLink struct { HRef string `json:"href"` Text string `json:"text"` } type pagerDutyImage struct { Src string `json:"src"` Alt string `json:"alt"` Href string `json:"href"` } type pagerDutyPayload struct { Summary string `json:"summary"` Source string `json:"source"` Severity string `json:"severity"` Timestamp string `json:"timestamp,omitempty"` Class string `json:"class,omitempty"` Component string `json:"component,omitempty"` Group string `json:"group,omitempty"` CustomDetails map[string]any `json:"custom_details,omitempty"` } func (n *Notifier) encodeMessage(msg *pagerDutyMessage) (bytes.Buffer, error) { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(msg); err != nil { return buf, fmt.Errorf("failed to encode PagerDuty message: %w", err) } if buf.Len() > maxEventSize { truncatedMsg := fmt.Sprintf("Custom details have been removed because the original event exceeds the maximum size of %s", units.MetricBytes(maxEventSize).String()) if n.apiV1 != "" { msg.Details = map[string]any{"error": truncatedMsg} } else { msg.Payload.CustomDetails = map[string]any{"error": truncatedMsg} } warningMsg := fmt.Sprintf("Truncated Details because message of size %s exceeds limit %s", units.MetricBytes(buf.Len()).String(), units.MetricBytes(maxEventSize).String()) n.logger.Warn(warningMsg) buf.Reset() if err := json.NewEncoder(&buf).Encode(msg); err != nil { return buf, fmt.Errorf("failed to encode PagerDuty message: %w", err) } } return buf, nil } func (n *Notifier) notifyV1( ctx context.Context, eventType string, key notify.Key, data *template.Data, details map[string]any, ) (bool, error) { var tmplErr error tmpl := notify.TmplText(n.tmpl, data, &tmplErr) description, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV1DescriptionLenRunes) if truncated { n.logger.Warn("Truncated description", "key", key, "max_runes", maxV1DescriptionLenRunes) } serviceKey := string(n.conf.ServiceKey) if serviceKey == "" { content, fileErr := os.ReadFile(n.conf.ServiceKeyFile) if fileErr != nil { return false, fmt.Errorf("failed to read service key from file: %w", fileErr) } serviceKey = strings.TrimSpace(string(content)) } msg := &pagerDutyMessage{ ServiceKey: tmpl(serviceKey), EventType: eventType, IncidentKey: key.Hash(), Description: description, Details: details, } if eventType == pagerDutyEventTrigger { msg.Client = tmpl(n.conf.Client) msg.ClientURL = tmpl(n.conf.ClientURL) } if tmplErr != nil { return false, fmt.Errorf("failed to template PagerDuty v1 message: %w", tmplErr) } // Ensure that the service key isn't empty after templating. if msg.ServiceKey == "" { return false, errors.New("service key cannot be empty") } encodedMsg, err := n.encodeMessage(msg) if err != nil { return false, err } resp, err := notify.PostJSON(ctx, n.client, n.apiV1, &encodedMsg) if err != nil { return true, fmt.Errorf("failed to post message to PagerDuty v1: %w", err) } defer notify.Drain(resp) return n.retrier.Check(resp.StatusCode, resp.Body) } func (n *Notifier) notifyV2( ctx context.Context, eventType string, key notify.Key, data *template.Data, details map[string]any, ) (bool, error) { var tmplErr error tmpl := notify.TmplText(n.tmpl, data, &tmplErr) if n.conf.Severity == "" { n.conf.Severity = "error" } summary, truncated := notify.TruncateInRunes(tmpl(n.conf.Description), maxV2SummaryLenRunes) if truncated { n.logger.Warn("Truncated summary", "key", key, "max_runes", maxV2SummaryLenRunes) } routingKey := string(n.conf.RoutingKey) if routingKey == "" { content, fileErr := os.ReadFile(n.conf.RoutingKeyFile) if fileErr != nil { return false, fmt.Errorf("failed to read routing key from file: %w", fileErr) } routingKey = strings.TrimSpace(string(content)) } msg := &pagerDutyMessage{ Client: tmpl(n.conf.Client), ClientURL: tmpl(n.conf.ClientURL), RoutingKey: tmpl(routingKey), EventAction: eventType, DedupKey: key.Hash(), Images: make([]pagerDutyImage, 0, len(n.conf.Images)), Links: make([]pagerDutyLink, 0, len(n.conf.Links)), Payload: &pagerDutyPayload{ Summary: summary, Source: tmpl(n.conf.Source), Severity: tmpl(n.conf.Severity), CustomDetails: details, Class: tmpl(n.conf.Class), Component: tmpl(n.conf.Component), Group: tmpl(n.conf.Group), }, } for _, item := range n.conf.Images { image := pagerDutyImage{ Src: tmpl(item.Src), Alt: tmpl(item.Alt), Href: tmpl(item.Href), } if image.Src != "" { msg.Images = append(msg.Images, image) } } for _, item := range n.conf.Links { link := pagerDutyLink{ HRef: tmpl(item.Href), Text: tmpl(item.Text), } if link.HRef != "" { msg.Links = append(msg.Links, link) } } if tmplErr != nil { return false, fmt.Errorf("failed to template PagerDuty v2 message: %w", tmplErr) } // Ensure that the routing key isn't empty after templating. if msg.RoutingKey == "" { return false, errors.New("routing key cannot be empty") } encodedMsg, err := n.encodeMessage(msg) if err != nil { return false, err } resp, err := notify.PostJSON(ctx, n.client, n.conf.URL.String(), &encodedMsg) if err != nil { return true, fmt.Errorf("failed to post message to PagerDuty: %w", err) } defer notify.Drain(resp) retry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } return retry, err } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key) var ( alerts = types.Alerts(as...) data = notify.GetTemplateData(ctx, n.tmpl, as, logger) eventType = pagerDutyEventTrigger ) if alerts.Status() == model.AlertResolved { eventType = pagerDutyEventResolve } logger.Debug("extracted group key", "eventType", eventType) details, err := n.renderDetails(data) if err != nil { return false, fmt.Errorf("failed to render details: %w", err) } if n.conf.Timeout > 0 { nfCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured pagerduty timeout reached (%s)", n.conf.Timeout)) defer cancel() ctx = nfCtx } nf := n.notifyV2 if n.apiV1 != "" { nf = n.notifyV1 } retry, err := nf(ctx, eventType, key, data, details) if err != nil { if ctx.Err() != nil { err = fmt.Errorf("%w: %w", err, context.Cause(ctx)) } return retry, err } return retry, nil } func errDetails(status int, body io.Reader) string { // See https://v2.developer.pagerduty.com/docs/trigger-events for the v1 events API. // See https://v2.developer.pagerduty.com/docs/send-an-event-events-api-v2 for the v2 events API. if status != http.StatusBadRequest || body == nil { return "" } var pgr struct { Status string `json:"status"` Message string `json:"message"` Errors []string `json:"errors"` } if err := json.NewDecoder(body).Decode(&pgr); err != nil { return "" } return fmt.Sprintf("%s: %s", pgr.Message, strings.Join(pgr.Errors, ",")) } func (n *Notifier) renderDetails( data *template.Data, ) (map[string]any, error) { var ( tmplTextErr error tmplText = notify.TmplText(n.tmpl, data, &tmplTextErr) tmplTextFunc = func(tmpl string) (string, error) { return tmplText(tmpl), tmplTextErr } ) var err error rendered := make(map[string]any, len(n.conf.Details)) for k, v := range n.conf.Details { rendered[k], err = template.DeepCopyWithTemplate(v, tmplTextFunc) if err != nil { return nil, err } } return rendered, nil } ================================================ FILE: notify/pagerduty/pagerduty_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pagerduty import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) func TestPagerDutyRetryV1(t *testing.T) { notifier, err := New( &config.PagerdutyConfig{ ServiceKey: commoncfg.Secret("01234567890123456789012345678901"), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) retryCodes := append(test.DefaultRetryCodes(), http.StatusForbidden) for statusCode, expected := range test.RetryTests(retryCodes) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "retryv1 - error on status %d", statusCode) } } func TestPagerDutyRetryV2(t *testing.T) { notifier, err := New( &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) retryCodes := append(test.DefaultRetryCodes(), http.StatusTooManyRequests) for statusCode, expected := range test.RetryTests(retryCodes) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "retryv2 - error on status %d", statusCode) } } func TestPagerDutyRedactedURLV1(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() key := "01234567890123456789012345678901" notifier, err := New( &config.PagerdutyConfig{ ServiceKey: commoncfg.Secret(key), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) notifier.apiV1 = u.String() test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key) } func TestPagerDutyRedactedURLV2(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() key := "01234567890123456789012345678901" notifier, err := New( &config.PagerdutyConfig{ URL: &amcommoncfg.URL{URL: u}, RoutingKey: commoncfg.Secret(key), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key) } func TestPagerDutyV1ServiceKeyFromFile(t *testing.T) { key := "01234567890123456789012345678901" f, err := os.CreateTemp(t.TempDir(), "pagerduty_test") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(key) require.NoError(t, err, "writing to temp file failed") ctx, u, fn := test.GetContextWithCancelingURL() defer fn() notifier, err := New( &config.PagerdutyConfig{ ServiceKeyFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) notifier.apiV1 = u.String() test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key) } func TestPagerDutyV2RoutingKeyFromFile(t *testing.T) { key := "01234567890123456789012345678901" f, err := os.CreateTemp(t.TempDir(), "pagerduty_test") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(key) require.NoError(t, err, "writing to temp file failed") ctx, u, fn := test.GetContextWithCancelingURL() defer fn() notifier, err := New( &config.PagerdutyConfig{ URL: &amcommoncfg.URL{URL: u}, RoutingKeyFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key) } func TestPagerDutyTemplating(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(r.Body) out := make(map[string]any) err := dec.Decode(&out) if err != nil { panic(err) } })) defer srv.Close() u, _ := url.Parse(srv.URL) for _, tc := range []struct { title string cfg *config.PagerdutyConfig retry bool errMsg string }{ { title: "full-blown legacy message", cfg: &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), Images: []config.PagerdutyImage{ { Src: "{{ .Status }}", Alt: "{{ .Status }}", Href: "{{ .Status }}", }, }, Links: []config.PagerdutyLink{ { Href: "{{ .Status }}", Text: "{{ .Status }}", }, }, Details: map[string]any{ "firing": `{{ .Alerts.Firing | toJson }}`, "resolved": `{{ .Alerts.Resolved | toJson }}`, "num_firing": `{{ .Alerts.Firing | len }}`, "num_resolved": `{{ .Alerts.Resolved | len }}`, }, }, }, { title: "full-blown legacy message", cfg: &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), Images: []config.PagerdutyImage{ { Src: "{{ .Status }}", Alt: "{{ .Status }}", Href: "{{ .Status }}", }, }, Links: []config.PagerdutyLink{ { Href: "{{ .Status }}", Text: "{{ .Status }}", }, }, Details: map[string]any{ "firing": `{{ template "pagerduty.default.instances" .Alerts.Firing }}`, "resolved": `{{ template "pagerduty.default.instances" .Alerts.Resolved }}`, "num_firing": `{{ .Alerts.Firing | len }}`, "num_resolved": `{{ .Alerts.Resolved | len }}`, }, }, }, { title: "nested details", cfg: &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), Details: map[string]any{ "a": map[string]any{ "b": map[string]any{ "c": map[string]any{ "firing": `{{ .Alerts.Firing | toJson }}`, "resolved": `{{ .Alerts.Resolved | toJson }}`, "num_firing": `{{ .Alerts.Firing | len }}`, "num_resolved": `{{ .Alerts.Resolved | len }}`, }, }, }, }, }, }, { title: "nested details with template error", cfg: &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), Details: map[string]any{ "a": map[string]any{ "b": map[string]any{ "c": map[string]any{ "firing": `{{ template "pagerduty.default.instances" .Alerts.Firing`, }, }, }, }, }, errMsg: "failed to render details: template: :1: unclosed action", }, { title: "details with templating errors", cfg: &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), Details: map[string]any{ "firing": `{{ .Alerts.Firing | toJson`, "resolved": `{{ .Alerts.Resolved | toJson }}`, "num_firing": `{{ .Alerts.Firing | len }}`, "num_resolved": `{{ .Alerts.Resolved | len }}`, }, }, errMsg: "failed to render details: template: :1: unclosed action", }, { title: "v2 message with templating errors", cfg: &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), Severity: "{{ ", }, errMsg: "failed to template", }, { title: "v1 message with templating errors", cfg: &config.PagerdutyConfig{ ServiceKey: commoncfg.Secret("01234567890123456789012345678901"), Client: "{{ ", }, errMsg: "failed to template", }, { title: "routing key cannot be empty", cfg: &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret(`{{ "" }}`), }, errMsg: "routing key cannot be empty", }, { title: "service_key cannot be empty", cfg: &config.PagerdutyConfig{ ServiceKey: commoncfg.Secret(`{{ "" }}`), }, errMsg: "service key cannot be empty", }, } { t.Run(tc.title, func(t *testing.T) { tc.cfg.URL = &amcommoncfg.URL{URL: u} tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} pd, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) if pd.apiV1 != "" { pd.apiV1 = u.String() } ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") ok, err := pd.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) if tc.errMsg == "" { require.NoError(t, err) } else { require.Error(t, err) require.Contains(t, err.Error(), tc.errMsg) } require.Equal(t, tc.retry, ok) }) } } func TestErrDetails(t *testing.T) { for _, tc := range []struct { status int body io.Reader exp string }{ { status: http.StatusBadRequest, body: bytes.NewBuffer([]byte( `{"status":"invalid event","message":"Event object is invalid","errors":["Length of 'routing_key' is incorrect (should be 32 characters)"]}`, )), exp: "Length of 'routing_key' is incorrect", }, { status: http.StatusBadRequest, body: bytes.NewBuffer([]byte(`{"status"}`)), exp: "", }, { status: http.StatusBadRequest, exp: "", }, { status: http.StatusTooManyRequests, exp: "", }, } { t.Run("", func(t *testing.T) { err := errDetails(tc.status, tc.body) require.Contains(t, err, tc.exp) }) } } func TestEventSizeEnforcement(t *testing.T) { bigDetailsV1 := map[string]any{ "firing": strings.Repeat("a", 513000), } bigDetailsV2 := map[string]any{ "firing": strings.Repeat("a", 513000), } // V1 Messages msgV1 := &pagerDutyMessage{ ServiceKey: "01234567890123456789012345678901", EventType: "trigger", Details: bigDetailsV1, } notifierV1, err := New( &config.PagerdutyConfig{ ServiceKey: commoncfg.Secret("01234567890123456789012345678901"), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) encodedV1, err := notifierV1.encodeMessage(msgV1) require.NoError(t, err) require.Contains(t, encodedV1.String(), `"details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`) // V2 Messages msgV2 := &pagerDutyMessage{ RoutingKey: "01234567890123456789012345678901", EventAction: "trigger", Payload: &pagerDutyPayload{ CustomDetails: bigDetailsV2, }, } notifierV2, err := New( &config.PagerdutyConfig{ RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) encodedV2, err := notifierV2.encodeMessage(msgV2) require.NoError(t, err) require.Contains(t, encodedV2.String(), `"custom_details":{"error":"Custom details have been removed because the original event exceeds the maximum size of 512KB"}`) } func TestPagerDutyEmptySrcHref(t *testing.T) { type pagerDutyEvent struct { RoutingKey string `json:"routing_key"` EventAction string `json:"event_action"` DedupKey string `json:"dedup_key"` Payload pagerDutyPayload `json:"payload"` Images []pagerDutyImage Links []pagerDutyLink } images := []config.PagerdutyImage{ { Src: "", Alt: "Empty src", Href: "https://example.com/", }, { Src: "https://example.com/cat.jpg", Alt: "Empty href", Href: "", }, { Src: "https://example.com/cat.jpg", Alt: "", Href: "https://example.com/", }, } links := []config.PagerdutyLink{ { Href: "", Text: "Empty href", }, { Href: "https://example.com/", Text: "", }, } expectedImages := make([]pagerDutyImage, 0, len(images)) for _, image := range images { if image.Src == "" { continue } expectedImages = append(expectedImages, pagerDutyImage{ Src: image.Src, Alt: image.Alt, Href: image.Href, }) } expectedLinks := make([]pagerDutyLink, 0, len(links)) for _, link := range links { if link.Href == "" { continue } expectedLinks = append(expectedLinks, pagerDutyLink{ HRef: link.Href, Text: link.Text, }) } server := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { decoder := json.NewDecoder(r.Body) var event pagerDutyEvent if err := decoder.Decode(&event); err != nil { panic(err) } if event.RoutingKey == "" || event.EventAction == "" { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } for _, image := range event.Images { if image.Src == "" { http.Error(w, "Event object is invalid: 'image src' is missing or blank", http.StatusBadRequest) return } } for _, link := range event.Links { if link.HRef == "" { http.Error(w, "Event object is invalid: 'link href' is missing or blank", http.StatusBadRequest) return } } require.Equal(t, expectedImages, event.Images) require.Equal(t, expectedLinks, event.Links) }, )) defer server.Close() url, err := url.Parse(server.URL) require.NoError(t, err) pagerDutyConfig := config.PagerdutyConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), URL: &amcommoncfg.URL{URL: url}, Images: images, Links: links, } pagerDuty, err := New(&pagerDutyConfig, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") _, err = pagerDuty.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) require.NoError(t, err) } func TestPagerDutyTimeout(t *testing.T) { type pagerDutyEvent struct { RoutingKey string `json:"routing_key"` EventAction string `json:"event_action"` DedupKey string `json:"dedup_key"` Payload pagerDutyPayload `json:"payload"` Images []pagerDutyImage Links []pagerDutyLink } tests := map[string]struct { latency time.Duration timeout time.Duration wantErr bool }{ "success": {latency: 100 * time.Millisecond, timeout: 120 * time.Millisecond, wantErr: false}, "error": {latency: 100 * time.Millisecond, timeout: 80 * time.Millisecond, wantErr: true}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { decoder := json.NewDecoder(r.Body) var event pagerDutyEvent if err := decoder.Decode(&event); err != nil { panic(err) } if event.RoutingKey == "" || event.EventAction == "" { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } time.Sleep(tt.latency) }, )) defer srv.Close() u, err := url.Parse(srv.URL) require.NoError(t, err) cfg := config.PagerdutyConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, RoutingKey: commoncfg.Secret("01234567890123456789012345678901"), URL: &amcommoncfg.URL{URL: u}, Timeout: tt.timeout, } pd, err := New(&cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } _, err = pd.Notify(ctx, alert) require.Equal(t, tt.wantErr, err != nil) }) } } func TestRenderDetails(t *testing.T) { type args struct { details map[string]any data *template.Data } tests := []struct { name string args args want map[string]any wantErr bool }{ { name: "flat", args: args{ details: map[string]any{ "a": "{{ .Status }}", "b": "String", }, data: &template.Data{ Status: "Flat", }, }, want: map[string]any{ "a": "Flat", "b": "String", }, wantErr: false, }, { name: "flat error", args: args{ details: map[string]any{ "a": "{{ .Status", }, data: &template.Data{ Status: "Error", }, }, want: nil, wantErr: true, }, { name: "nested", args: args{ details: map[string]any{ "a": map[string]any{ "b": map[string]any{ "c": "{{ .Status }}", "d": "String", }, }, }, data: &template.Data{ Status: "Nested", }, }, want: map[string]any{ "a": map[string]any{ "b": map[string]any{ "c": "Nested", "d": "String", }, }, }, wantErr: false, }, { name: "nested error", args: args{ details: map[string]any{ "a": map[string]any{ "b": map[string]any{ "c": "{{ .Status", }, }, }, data: &template.Data{ Status: "Error", }, }, want: nil, wantErr: true, }, { name: "alerts", args: args{ details: map[string]any{ "alerts": map[string]any{ "firing": "{{ .Alerts.Firing | toJson }}", "resolved": "{{ .Alerts.Resolved | toJson }}", "num_firing": "{{ len .Alerts.Firing }}", "num_resolved": "{{ len .Alerts.Resolved }}", }, }, data: &template.Data{ Alerts: template.Alerts{ { Status: "firing", Annotations: template.KV{ "annotation1": "value1", "annotation2": "value2", }, Labels: template.KV{ "alertname": "Firing1", "label1": "value1", "label2": "value2", }, Fingerprint: "fingerprint1", GeneratorURL: "http://generator1", StartsAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC), }, { Status: "firing", Annotations: template.KV{ "annotation1": "value1", "annotation2": "value2", }, Labels: template.KV{ "alertname": "Firing2", "label1": "value1", "label2": "value2", }, Fingerprint: "fingerprint2", GeneratorURL: "http://generator2", StartsAt: time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC), }, { Status: "resolved", Annotations: template.KV{ "annotation1": "value1", "annotation2": "value2", }, Labels: template.KV{ "alertname": "Resolved1", "label1": "value1", "label2": "value2", }, Fingerprint: "fingerprint3", GeneratorURL: "http://generator3", StartsAt: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC), }, { Status: "resolved", Annotations: template.KV{ "annotation1": "value1", "annotation2": "value2", }, Labels: template.KV{ "alertname": "Resolved2", "label1": "value1", "label2": "value2", }, Fingerprint: "fingerprint4", GeneratorURL: "http://generator4", StartsAt: time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC), }, }, }, }, want: map[string]any{ "alerts": map[string]any{ "firing": []any{ map[string]any{ "status": "firing", "labels": map[string]any{ "alertname": "Firing1", "label1": "value1", "label2": "value2", }, "annotations": map[string]any{ "annotation1": "value1", "annotation2": "value2", }, "startsAt": time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), "endsAt": time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339), "fingerprint": "fingerprint1", "generatorURL": "http://generator1", }, map[string]any{ "status": "firing", "labels": map[string]any{ "alertname": "Firing2", "label1": "value1", "label2": "value2", }, "annotations": map[string]any{ "annotation1": "value1", "annotation2": "value2", }, "startsAt": time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), "endsAt": time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339), "fingerprint": "fingerprint2", "generatorURL": "http://generator2", }, }, "resolved": []any{ map[string]any{ "status": "resolved", "labels": map[string]any{ "alertname": "Resolved1", "label1": "value1", "label2": "value2", }, "annotations": map[string]any{ "annotation1": "value1", "annotation2": "value2", }, "startsAt": time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), "endsAt": time.Date(2001, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339), "fingerprint": "fingerprint3", "generatorURL": "http://generator3", }, map[string]any{ "status": "resolved", "labels": map[string]any{ "alertname": "Resolved2", "label1": "value1", "label2": "value2", }, "annotations": map[string]any{ "annotation1": "value1", "annotation2": "value2", }, "startsAt": time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339), "endsAt": time.Date(2002, time.January, 1, 1, 0, 0, 0, time.UTC).Format(time.RFC3339), "fingerprint": "fingerprint4", "generatorURL": "http://generator4", }, }, "num_firing": 2, "num_resolved": 2, }, }, wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { n := &Notifier{ conf: &config.PagerdutyConfig{ Details: tt.args.details, }, tmpl: test.CreateTmpl(t), } got, err := n.renderDetails(tt.args.data) if (err != nil) != tt.wantErr { t.Errorf("renderDetails() error = %v, wantErr %v", err, tt.wantErr) return } require.Equal(t, tt.want, got) }) } } ================================================ FILE: notify/pushover/pushover.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pushover import ( "context" "fmt" "log/slog" "net/http" "net/url" "os" "strings" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( // https://pushover.net/api#limits - 250 characters or runes. maxTitleLenRunes = 250 // https://pushover.net/api#limits - 1024 characters or runes. maxMessageLenRunes = 1024 // https://pushover.net/api#limits - 512 characters or runes. maxURLLenRunes = 512 ) // Notifier implements a Notifier for Pushover notifications. type Notifier struct { conf *config.PushoverConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier apiURL string // for tests. } // New returns a new Pushover notifier. func New(c *config.PushoverConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "pushover", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, apiURL: "https://api.pushover.net/1/messages.json", }, nil } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { key, ok := notify.GroupKey(ctx) if !ok { return false, fmt.Errorf("group key missing") } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") data := notify.GetTemplateData(ctx, n.tmpl, as, logger) var ( err error message string ) tmpl := notify.TmplText(n.tmpl, data, &err) tmplHTML := notify.TmplHTML(n.tmpl, data, &err) var ( token string userKey string ) if n.conf.Token != "" { token = string(n.conf.Token) } else { content, err := os.ReadFile(n.conf.TokenFile) if err != nil { return false, fmt.Errorf("read token_file: %w", err) } token = string(content) } if n.conf.UserKey != "" { userKey = string(n.conf.UserKey) } else { content, err := os.ReadFile(n.conf.UserKeyFile) if err != nil { return false, fmt.Errorf("read user_key_file: %w", err) } userKey = string(content) } parameters := url.Values{} parameters.Add("token", tmpl(token)) parameters.Add("user", tmpl(userKey)) title, truncated := notify.TruncateInRunes(tmpl(n.conf.Title), maxTitleLenRunes) if truncated { logger.Warn("Truncated title", "incident", key, "max_runes", maxTitleLenRunes) } parameters.Add("title", title) if n.conf.HTML { parameters.Add("html", "1") message = tmplHTML(n.conf.Message) } else { message = tmpl(n.conf.Message) } if n.conf.Monospace { parameters.Add("monospace", "1") } message, truncated = notify.TruncateInRunes(message, maxMessageLenRunes) if truncated { logger.Warn("Truncated message", "incident", key, "max_runes", maxMessageLenRunes) } message = strings.TrimSpace(message) if message == "" { // Pushover rejects empty messages. message = "(no details)" } parameters.Add("message", message) supplementaryURL, truncated := notify.TruncateInRunes(tmpl(n.conf.URL), maxURLLenRunes) if truncated { logger.Warn("Truncated URL", "incident", key, "max_runes", maxURLLenRunes) } parameters.Add("url", supplementaryURL) parameters.Add("url_title", tmpl(n.conf.URLTitle)) parameters.Add("priority", tmpl(n.conf.Priority)) parameters.Add("retry", fmt.Sprintf("%d", int64(time.Duration(n.conf.Retry).Seconds()))) parameters.Add("expire", fmt.Sprintf("%d", int64(time.Duration(n.conf.Expire).Seconds()))) parameters.Add("device", tmpl(n.conf.Device)) parameters.Add("sound", tmpl(n.conf.Sound)) newttl := int64(time.Duration(n.conf.TTL).Seconds()) if newttl > 0 { parameters.Add("ttl", fmt.Sprintf("%d", newttl)) } if err != nil { return false, err } u, err := url.Parse(n.apiURL) if err != nil { return false, err } u.RawQuery = parameters.Encode() // Don't log the URL as it contains secret data (see #1825). logger.Debug("Sending message", "incident", key) resp, err := notify.PostText(ctx, n.client, u.String(), nil) if err != nil { return true, notify.RedactURL(err) } defer notify.Drain(resp) shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } return shouldRetry, err } ================================================ FILE: notify/pushover/pushover_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package pushover import ( "net/http" "os" "testing" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) func TestPushoverRetry(t *testing.T) { notifier, err := New( &config.PushoverConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "error on status %d", statusCode) } } func TestPushoverRedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() key, token := "user_key", "token" notifier, err := New( &config.PushoverConfig{ UserKey: commoncfg.Secret(key), Token: commoncfg.Secret(token), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) notifier.apiURL = u.String() test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key, token) } func TestPushoverReadingUserKeyFromFile(t *testing.T) { ctx, apiURL, fn := test.GetContextWithCancelingURL() defer fn() const userKey = "user key" f, err := os.CreateTemp(t.TempDir(), "pushover_user_key") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(userKey) require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.PushoverConfig{ UserKeyFile: f.Name(), Token: commoncfg.Secret("token"), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) notifier.apiURL = apiURL.String() require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, userKey) } func TestPushoverReadingTokenFromFile(t *testing.T) { ctx, apiURL, fn := test.GetContextWithCancelingURL() defer fn() const token = "token" f, err := os.CreateTemp(t.TempDir(), "pushover_token") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(token) require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.PushoverConfig{ UserKey: commoncfg.Secret("user key"), TokenFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) notifier.apiURL = apiURL.String() require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, token) } func TestPushoverMonospaceParameter(t *testing.T) { ctx, apiURL, fn := test.GetContextWithCancelingURL(func(w http.ResponseWriter, r *http.Request) { require.NoError(t, r.ParseForm()) require.Equal(t, "1", r.FormValue("monospace"), `expected monospace parameter to be set to "1"`) }) defer fn() notifier, err := New( &config.PushoverConfig{ UserKey: commoncfg.Secret("user_key"), Token: commoncfg.Secret("token"), Monospace: true, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) notifier.apiURL = apiURL.String() require.NoError(t, err) _, err = notifier.Notify(notify.WithGroupKey(ctx, "1"), &types.Alert{}) require.NoError(t, err) } ================================================ FILE: notify/rocketchat/rocketchat.go ================================================ // Copyright 2022 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rocketchat import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const maxTitleLenRunes = 1024 type Notifier struct { conf *config.RocketchatConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier token string tokenID string postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) } // PostMessage Payload for postmessage rest API // // https://rocket.chat/docs/developer-guides/rest-api/chat/postmessage/ type Attachment struct { Title string `json:"title,omitempty"` TitleLink string `json:"title_link,omitempty"` Text string `json:"text,omitempty"` ImageURL string `json:"image_url,omitempty"` ThumbURL string `json:"thumb_url,omitempty"` Color string `json:"color,omitempty"` Fields []config.RocketchatAttachmentField `json:"fields,omitempty"` Actions []config.RocketchatAttachmentAction `json:"actions,omitempty"` } // PostMessage Payload for postmessage rest API // // https://rocket.chat/docs/developer-guides/rest-api/chat/postmessage/ type PostMessage struct { Channel string `json:"channel,omitempty"` Text string `json:"text,omitempty"` ParseUrls bool `json:"parseUrls,omitempty"` Alias string `json:"alias,omitempty"` Emoji string `json:"emoji,omitempty"` Avatar string `json:"avatar,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` Actions []config.RocketchatAttachmentAction `json:"actions,omitempty"` } type rocketchatRoundTripper struct { wrapped http.RoundTripper token string tokenID string } func (t *rocketchatRoundTripper) RoundTrip(req *http.Request) (res *http.Response, e error) { req.Header.Set("X-Auth-Token", t.token) req.Header.Set("X-User-Id", t.tokenID) return t.wrapped.RoundTrip(req) } // New returns a new Rocketchat notification handler. func New(c *config.RocketchatConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "rocketchat", httpOpts...) if err != nil { return nil, err } token, err := getToken(c) if err != nil { return nil, err } tokenID, err := getTokenID(c) if err != nil { return nil, err } client.Transport = &rocketchatRoundTripper{wrapped: client.Transport, token: token, tokenID: tokenID} return &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, postJSONFunc: notify.PostJSON, token: token, tokenID: tokenID, }, nil } func getTokenID(c *config.RocketchatConfig) (string, error) { if len(c.TokenIDFile) > 0 { content, err := os.ReadFile(c.TokenIDFile) if err != nil { return "", fmt.Errorf("could not read %s: %w", c.TokenIDFile, err) } return strings.TrimSpace(string(content)), nil } return string(*c.TokenID), nil } func getToken(c *config.RocketchatConfig) (string, error) { if len(c.TokenFile) > 0 { content, err := os.ReadFile(c.TokenFile) if err != nil { return "", fmt.Errorf("could not read %s: %w", c.TokenFile, err) } return strings.TrimSpace(string(content)), nil } return string(*c.Token), nil } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { var err error key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") data := notify.GetTemplateData(ctx, n.tmpl, as, logger) tmplText := notify.TmplText(n.tmpl, data, &err) if err != nil { return false, err } title := tmplText(n.conf.Title) if err != nil { return false, err } title, truncated := notify.TruncateInRunes(title, maxTitleLenRunes) if truncated { logger.Warn("Truncated title", "max_runes", maxTitleLenRunes) } att := &Attachment{ Title: title, TitleLink: tmplText(n.conf.TitleLink), Text: tmplText(n.conf.Text), ImageURL: tmplText(n.conf.ImageURL), ThumbURL: tmplText(n.conf.ThumbURL), Color: tmplText(n.conf.Color), } numFields := len(n.conf.Fields) if numFields > 0 { fields := make([]config.RocketchatAttachmentField, numFields) for index, field := range n.conf.Fields { // Check if short was defined for the field otherwise fallback to the global setting var short bool if field.Short != nil { short = *field.Short } else { short = n.conf.ShortFields } // Rebuild the field by executing any templates and setting the new value for short fields[index] = config.RocketchatAttachmentField{ Title: tmplText(field.Title), Value: tmplText(field.Value), Short: &short, } } att.Fields = fields } numActions := len(n.conf.Actions) if numActions > 0 { actions := make([]config.RocketchatAttachmentAction, numActions) for index, action := range n.conf.Actions { actions[index] = config.RocketchatAttachmentAction{ Type: "button", // Only button type is supported Text: tmplText(action.Text), URL: tmplText(action.URL), Msg: tmplText(action.Msg), } } att.Actions = actions } body := &PostMessage{ Channel: tmplText(n.conf.Channel), Emoji: tmplText(n.conf.Emoji), Avatar: tmplText(n.conf.IconURL), Attachments: []Attachment{*att}, } if err != nil { return false, err } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(body); err != nil { return false, err } url := n.conf.APIURL.JoinPath("api/v1/chat.postMessage").String() resp, err := n.postJSONFunc(ctx, n.client, url, &buf) if err != nil { return true, notify.RedactURL(err) } defer notify.Drain(resp) // Use a retrier to generate an error message for non-200 responses and // classify them as retriable or not. retry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { err = fmt.Errorf("channel %q: %w", body.Channel, err) return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } // Rocketchat web API might return errors with a 200 response code. retry, err = checkResponseError(resp) if err != nil { err = fmt.Errorf("channel %q: %w", body.Channel, err) return retry, notify.NewErrorWithReason(notify.ClientErrorReason, err) } return retry, nil } // checkResponseError parses out the error message from Rocketchat API response. func checkResponseError(resp *http.Response) (bool, error) { body, err := io.ReadAll(resp.Body) if err != nil { return true, fmt.Errorf("could not read response body: %w", err) } return checkJSONResponseError(body) } // checkJSONResponseError classifies JSON responses from Rocketchat. func checkJSONResponseError(body []byte) (bool, error) { // response is for parsing out errors from the JSON response. type response struct { Success bool `json:"success"` Error string `json:"error"` } var data response if err := json.Unmarshal(body, &data); err != nil { return true, fmt.Errorf("could not unmarshal JSON response %q: %w", string(body), err) } if !data.Success { return false, fmt.Errorf("error response from Rocketchat: %s", data.Error) } return false, nil } ================================================ FILE: notify/rocketchat/rocketchat_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package rocketchat import ( "net/url" "os" "testing" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify/test" ) func TestRocketchatRetry(t *testing.T) { secret := commoncfg.Secret("xxxxx") notifier, err := New( &config.RocketchatConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, Token: &secret, TokenID: &secret, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "error on status %d", statusCode) } } func TestGettingRocketchatTokenFromFile(t *testing.T) { f, err := os.CreateTemp(t.TempDir(), "rocketchat_test") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString("secret") require.NoError(t, err, "writing to temp file failed") _, err = New( &config.RocketchatConfig{ TokenFile: f.Name(), TokenIDFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, APIURL: &amcommoncfg.URL{URL: &url.URL{Scheme: "http", Host: "example.com", Path: "/api/v1/"}}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) } ================================================ FILE: notify/slack/slack.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package slack import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/nflog" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // https://api.slack.com/reference/messaging/attachments#legacy_fields - 1024, no units given, assuming runes or characters. const maxTitleLenRunes = 1024 // Notifier implements a Notifier for Slack notifications. type Notifier struct { conf *config.SlackConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) } // New returns a new Slack notification handler. func New(c *config.SlackConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "slack", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, postJSONFunc: notify.PostJSON, }, nil } // request is the request for sending a slack notification. type request struct { Channel string `json:"channel,omitempty"` Timestamp string `json:"ts,omitempty"` Username string `json:"username,omitempty"` IconEmoji string `json:"icon_emoji,omitempty"` IconURL string `json:"icon_url,omitempty"` LinkNames bool `json:"link_names,omitempty"` Text string `json:"text,omitempty"` Attachments []attachment `json:"attachments"` } // attachment is used to display a richly-formatted message block. type attachment struct { Title string `json:"title,omitempty"` TitleLink string `json:"title_link,omitempty"` Pretext string `json:"pretext,omitempty"` Text string `json:"text"` Fallback string `json:"fallback"` CallbackID string `json:"callback_id"` Fields []config.SlackField `json:"fields,omitempty"` Actions []config.SlackAction `json:"actions,omitempty"` ImageURL string `json:"image_url,omitempty"` ThumbURL string `json:"thumb_url,omitempty"` Footer string `json:"footer"` Color string `json:"color,omitempty"` MrkdwnIn []string `json:"mrkdwn_in,omitempty"` } // slackResponse represents the response from Slack API. type slackResponse struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` Channel string `json:"channel,omitempty"` Timestamp string `json:"ts,omitempty"` } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { var err error key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") var ( data = notify.GetTemplateData(ctx, n.tmpl, as, logger) tmplText = notify.TmplText(n.tmpl, data, &err) ) var markdownIn []string if len(n.conf.MrkdwnIn) == 0 { markdownIn = []string{"fallback", "pretext", "text"} } else { markdownIn = n.conf.MrkdwnIn } title, truncated := notify.TruncateInRunes(tmplText(n.conf.Title), maxTitleLenRunes) if truncated { logger.Warn("Truncated title", "max_runes", maxTitleLenRunes) } att := &attachment{ Title: title, TitleLink: tmplText(n.conf.TitleLink), Pretext: tmplText(n.conf.Pretext), Text: tmplText(n.conf.Text), Fallback: tmplText(n.conf.Fallback), CallbackID: tmplText(n.conf.CallbackID), ImageURL: tmplText(n.conf.ImageURL), ThumbURL: tmplText(n.conf.ThumbURL), Footer: tmplText(n.conf.Footer), Color: tmplText(n.conf.Color), MrkdwnIn: markdownIn, } numFields := len(n.conf.Fields) if numFields > 0 { fields := make([]config.SlackField, numFields) for index, field := range n.conf.Fields { // Check if short was defined for the field otherwise fallback to the global setting var short bool if field.Short != nil { short = *field.Short } else { short = n.conf.ShortFields } // Rebuild the field by executing any templates and setting the new value for short fields[index] = config.SlackField{ Title: tmplText(field.Title), Value: tmplText(field.Value), Short: &short, } } att.Fields = fields } numActions := len(n.conf.Actions) if numActions > 0 { actions := make([]config.SlackAction, numActions) for index, action := range n.conf.Actions { slackAction := config.SlackAction{ Type: tmplText(action.Type), Text: tmplText(action.Text), URL: tmplText(action.URL), Style: tmplText(action.Style), Name: tmplText(action.Name), Value: tmplText(action.Value), } if action.ConfirmField != nil { slackAction.ConfirmField = &config.SlackConfirmationField{ Title: tmplText(action.ConfirmField.Title), Text: tmplText(action.ConfirmField.Text), OkText: tmplText(action.ConfirmField.OkText), DismissText: tmplText(action.ConfirmField.DismissText), } } actions[index] = slackAction } att.Actions = actions } var u string if n.conf.APIURL != nil { u = n.conf.APIURL.String() } else { content, err := os.ReadFile(n.conf.APIURLFile) if err != nil { return false, err } u = strings.TrimSpace(string(content)) } if n.conf.Timeout > 0 { postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured slack timeout reached (%s)", n.conf.Timeout)) defer cancel() ctx = postCtx } req := &request{ Channel: tmplText(n.conf.Channel), Username: tmplText(n.conf.Username), IconEmoji: tmplText(n.conf.IconEmoji), IconURL: tmplText(n.conf.IconURL), LinkNames: n.conf.LinkNames, Text: tmplText(n.conf.MessageText), Attachments: []attachment{*att}, } // If a notification for this alert group has already been sent and `update_message` config is set // edit API endpoint and payload to update notification instead of sending a new one. var store *nflog.Store if n.conf.UpdateMessage { var ok bool store, ok = notify.NflogStore(ctx) if !ok { logger.Warn("cannot create NflogStore, updatable messages will be disabled.") } else { threadTs, _ := store.GetStr("threadTs") channelId, _ := store.GetStr("channelId") logger.Debug("attempt recovering threadTs and channelId to update an existing message", "threadTs", threadTs, "channelId", channelId) if threadTs != "" && channelId != "" { u = "https://slack.com/api/chat.update" req.Timestamp = threadTs req.Channel = channelId logger.Debug("updating previously sent message", "threadTs", threadTs, "channelId", channelId) } } } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(req); err != nil { return false, err } resp, err := n.postJSONFunc(ctx, n.client, u, &buf) if err != nil { if ctx.Err() != nil { err = fmt.Errorf("%w: %w", err, context.Cause(ctx)) } return true, notify.RedactURL(err) } defer notify.Drain(resp) // Use a retrier to generate an error message for non-200 responses and // classify them as retriable or not. retry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { err = fmt.Errorf("channel %q: %w", req.Channel, err) return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } retry, err = n.slackResponseHandler(resp, store) if err != nil { err = fmt.Errorf("channel %q: %w", req.Channel, err) return retry, notify.NewErrorWithReason(notify.ClientErrorReason, err) } return retry, nil } // slackResponseHandler parses the response body of the request, handles retryable errors // and saves the response timestamp and channelId to nflog. func (n *Notifier) slackResponseHandler(resp *http.Response, store *nflog.Store) (bool, error) { body, err := io.ReadAll(resp.Body) if err != nil { return true, fmt.Errorf("could not read response body: %w", err) } if !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { return checkTextResponseError(body) } var data slackResponse if err := json.Unmarshal(body, &data); err != nil { return true, fmt.Errorf("could not unmarshal JSON response %q: %w", string(body), err) } if !data.OK { return false, fmt.Errorf("error response from Slack: %s", data.Error) } // If store, TS and Channel are set, store the threadTS and channelId if store != nil && data.Timestamp != "" && data.Channel != "" { store.SetStr("threadTs", data.Timestamp) store.SetStr("channelId", data.Channel) n.logger.Debug("stored threadTs and channelId", "threadTs", data.Timestamp, "channelId", data.Channel) } return false, nil } // checkTextResponseError classifies plaintext responses from Slack. // A plaintext (non-JSON) response is successful if it's a string "ok". // This is typically a response for an Incoming Webhook // (https://api.slack.com/messaging/webhooks#handling_errors) func checkTextResponseError(body []byte) (bool, error) { if !bytes.Equal(body, []byte("ok")) { return false, fmt.Errorf("received an error response from Slack: %s", string(body)) } return false, nil } ================================================ FILE: notify/slack/slack_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package slack import ( "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) func TestSlackRetry(t *testing.T) { notifier, err := New( &config.SlackConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "error on status %d", statusCode) } } func TestSlackRedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() notifier, err := New( &config.SlackConfig{ APIURL: &amcommoncfg.SecretURL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } func TestGettingSlackURLFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() f, err := os.CreateTemp(t.TempDir(), "slack_test") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(u.String()) require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.SlackConfig{ APIURLFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } func TestTrimmingSlackURLFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() f, err := os.CreateTemp(t.TempDir(), "slack_test_newline") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(u.String() + "\n\n") require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.SlackConfig{ APIURLFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } func TestNotifier_Notify_WithReason(t *testing.T) { tests := []struct { name string statusCode int responseBody string expectedReason notify.Reason expectedErr string expectedRetry bool noError bool }{ { name: "with a 4xx status code", statusCode: http.StatusUnauthorized, expectedReason: notify.ClientErrorReason, expectedRetry: false, expectedErr: "unexpected status code 401", }, { name: "with a 5xx status code", statusCode: http.StatusInternalServerError, expectedReason: notify.ServerErrorReason, expectedRetry: true, expectedErr: "unexpected status code 500", }, { name: "with a 3xx status code", statusCode: http.StatusTemporaryRedirect, expectedReason: notify.DefaultReason, expectedRetry: false, expectedErr: "unexpected status code 307", }, { name: "with a 1xx status code", statusCode: http.StatusSwitchingProtocols, expectedReason: notify.DefaultReason, expectedRetry: false, expectedErr: "unexpected status code 101", }, { name: "2xx response with invalid JSON", statusCode: http.StatusOK, responseBody: `{"not valid json"}`, expectedReason: notify.ClientErrorReason, expectedRetry: true, expectedErr: "could not unmarshal", }, { name: "2xx response with a JSON error", statusCode: http.StatusOK, responseBody: `{"ok":false,"error":"error_message"}`, expectedReason: notify.ClientErrorReason, expectedRetry: false, expectedErr: "error response from Slack: error_message", }, { name: "2xx response with a plaintext error", statusCode: http.StatusOK, responseBody: "no_channel", expectedReason: notify.ClientErrorReason, expectedRetry: false, expectedErr: "error response from Slack: no_channel", }, { name: "successful JSON response", statusCode: http.StatusOK, responseBody: `{"ok":true}`, noError: true, }, { name: "successful plaintext response", statusCode: http.StatusOK, responseBody: "ok", noError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { apiurl, _ := url.Parse("https://slack.com/post.Message") notifier, err := New( &config.SlackConfig{ NotifierConfig: amcommoncfg.NotifierConfig{}, HTTPConfig: &commoncfg.HTTPClientConfig{}, APIURL: &amcommoncfg.SecretURL{URL: apiurl}, Channel: "channelname", }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) { resp := httptest.NewRecorder() if strings.HasPrefix(tt.responseBody, "{") { resp.Header().Add("Content-Type", "application/json; charset=utf-8") } resp.WriteHeader(tt.statusCode) resp.WriteString(tt.responseBody) return resp.Result(), nil } ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alert1 := &types.Alert{ Alert: model.Alert{ StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } retry, err := notifier.Notify(ctx, alert1) require.Equal(t, tt.expectedRetry, retry) if tt.noError { require.NoError(t, err) } else { var reasonError *notify.ErrorWithReason require.ErrorAs(t, err, &reasonError) require.Equal(t, tt.expectedReason, reasonError.Reason) require.Contains(t, err.Error(), tt.expectedErr) require.Contains(t, err.Error(), "channelname") } }) } } func TestSlackTimeout(t *testing.T) { tests := map[string]struct { latency time.Duration timeout time.Duration wantErr bool }{ "success": {latency: 100 * time.Millisecond, timeout: 120 * time.Millisecond, wantErr: false}, "error": {latency: 100 * time.Millisecond, timeout: 80 * time.Millisecond, wantErr: true}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { u, _ := url.Parse("https://slack.com/post.Message") notifier, err := New( &config.SlackConfig{ NotifierConfig: amcommoncfg.NotifierConfig{}, HTTPConfig: &commoncfg.HTTPClientConfig{}, APIURL: &amcommoncfg.SecretURL{URL: u}, Channel: "channelname", Timeout: tt.timeout, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) notifier.postJSONFunc = func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(tt.latency): resp := httptest.NewRecorder() resp.Header().Set("Content-Type", "application/json; charset=utf-8") resp.WriteHeader(http.StatusOK) resp.WriteString(`{"ok":true}`) return resp.Result(), nil } } ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alert := &types.Alert{ Alert: model.Alert{ StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } _, err = notifier.Notify(ctx, alert) require.Equal(t, tt.wantErr, err != nil) }) } } func TestSlackMessageField(t *testing.T) { // 1. Setup a fake Slack server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatal(err) } // 2. VERIFY: Top-level text exists if body["text"] != "My Top Level Message" { t.Errorf("Expected top-level 'text' to be 'My Top Level Message', got %v", body["text"]) } // 3. VERIFY: Old attachments still exist attachments, ok := body["attachments"].([]any) if !ok || len(attachments) == 0 { t.Errorf("Expected attachments to exist") } else { first := attachments[0].(map[string]any) if first["title"] != "Old Attachment Title" { t.Errorf("Expected attachment title 'Old Attachment Title', got %v", first["title"]) } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"ok": true}`)) })) defer server.Close() // 4. Configure Notifier with BOTH new and old fields u, _ := url.Parse(server.URL) conf := &config.SlackConfig{ APIURL: &amcommoncfg.SecretURL{URL: u}, MessageText: "My Top Level Message", // Your NEW field Title: "Old Attachment Title", // An OLD field Channel: "#test-channel", HTTPConfig: &commoncfg.HTTPClientConfig{}, } tmpl, err := template.FromGlobs([]string{}) if err != nil { t.Fatal(err) } tmpl.ExternalURL = u logger := slog.New(slog.DiscardHandler) notifier, err := New(conf, tmpl, logger) if err != nil { t.Fatal(err) } ctx := context.Background() ctx = notify.WithGroupKey(ctx, "test-group-key") if _, err := notifier.Notify(ctx); err != nil { t.Fatal("Notify failed:", err) } } ================================================ FILE: notify/sns/sns.go ================================================ // Copyright 2021 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package sns import ( "context" "errors" "fmt" "log/slog" "net/http" "strings" "unicode/utf8" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/sns" snstypes "github.com/aws/aws-sdk-go-v2/service/sns/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/aws/smithy-go" smithyhttp "github.com/aws/smithy-go/transport/http" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // Notifier implements a Notifier for SNS notifications. type Notifier struct { conf *config.SNSConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier } // New returns a new SNS notification handler. func New(c *config.SNSConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "sns", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, }, nil } func (n *Notifier) Notify(ctx context.Context, alert ...*types.Alert) (bool, error) { var ( tmplErr error data = notify.GetTemplateData(ctx, n.tmpl, alert, n.logger) tmpl = notify.TmplText(n.tmpl, data, &tmplErr) ) client, err := n.createSNSClient(ctx, tmpl, &tmplErr) if err != nil { // V2 error handling is different. We don't have awserr.RequestFailure. // We can check for a generic smithy.APIError to see if it's a service error. var apiErr smithy.APIError if errors.As(err, &apiErr) { // To maintain compatibility with the retrier, we attempt to get an HTTP status code. var respErr *smithyhttp.ResponseError if errors.As(err, &respErr) && respErr.Response != nil { return n.retrier.Check(respErr.Response.StatusCode, strings.NewReader(apiErr.ErrorMessage())) } // Fallback if we can't get a status code. return true, fmt.Errorf("failed to create SNS client: %s: %s", apiErr.ErrorCode(), apiErr.ErrorMessage()) } return true, err } publishInput, err := n.createPublishInput(ctx, tmpl, &tmplErr) if err != nil { return true, err } publishOutput, err := client.Publish(ctx, publishInput) if err != nil { // V2 error handling uses errors.As to inspect the error chain. var apiErr smithy.APIError if errors.As(err, &apiErr) { var statusCode int var respErr *smithyhttp.ResponseError // Try to extract the HTTP status code for the retrier. if errors.As(err, &respErr) && respErr.Response != nil { statusCode = respErr.Response.StatusCode } // If we got a status code, use the retrier logic. if statusCode != 0 { retryable, checkErr := n.retrier.Check(statusCode, strings.NewReader(apiErr.ErrorMessage())) reasonErr := notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(statusCode), checkErr) return retryable, reasonErr } } // Fallback for non-API errors or if status code extraction fails. return true, err } n.logger.Debug("SNS message successfully published", "message_id", aws.ToString(publishOutput.MessageId), "sequence_number", aws.ToString(publishOutput.SequenceNumber)) return false, nil } func (n *Notifier) createSNSClient(ctx context.Context, tmpl func(string) string, tmplErr *error) (*sns.Client, error) { // Base configuration options that apply to both STS (if used) and the final SNS client. baseCfgOpts := []func(*awsconfig.LoadOptions) error{ awsconfig.WithHTTPClient(n.client), awsconfig.WithRegion(n.conf.Sigv4.Region), } if n.conf.Sigv4.Profile != "" { baseCfgOpts = append(baseCfgOpts, awsconfig.WithSharedConfigProfile(n.conf.Sigv4.Profile)) } if n.conf.Sigv4.AccessKey != "" { creds := credentials.NewStaticCredentialsProvider(n.conf.Sigv4.AccessKey, string(n.conf.Sigv4.SecretKey), "") baseCfgOpts = append(baseCfgOpts, awsconfig.WithCredentialsProvider(creds)) } // Final configuration options for the SNS client. snsCfgOpts := baseCfgOpts // If a RoleARN is provided, create an STS client to assume the role. // This uses a separate config load to ensure the STS client does not use a custom SNS endpoint. if n.conf.Sigv4.RoleARN != "" { stsCfg, err := awsconfig.LoadDefaultConfig(ctx, baseCfgOpts...) if err != nil { return nil, fmt.Errorf("failed to load base config for STS: %w", err) } stsClient := sts.NewFromConfig(stsCfg) stsProvider := stscreds.NewAssumeRoleProvider(stsClient, n.conf.Sigv4.RoleARN) // Add the AssumeRole provider to the options for the SNS client config. snsCfgOpts = append(snsCfgOpts, awsconfig.WithCredentialsProvider(aws.NewCredentialsCache(stsProvider))) } // Resolve the API URL from the template. apiURL := tmpl(n.conf.APIUrl) if *tmplErr != nil { return nil, notify.NewErrorWithReason(notify.ClientErrorReason, fmt.Errorf("execute 'api_url' template: %w", *tmplErr)) } if apiURL != "" { snsCfgOpts = append(snsCfgOpts, awsconfig.WithBaseEndpoint(apiURL)) } // Load the final configuration for the SNS client. snsCfg, err := awsconfig.LoadDefaultConfig(ctx, snsCfgOpts...) if err != nil { return nil, fmt.Errorf("failed to load final config for SNS: %w", err) } // We will always need a region to be set. if snsCfg.Region == "" { return nil, fmt.Errorf("region not configured in sns.sigv4.region or in default credentials chain") } return sns.NewFromConfig(snsCfg), nil } func (n *Notifier) createPublishInput(ctx context.Context, tmpl func(string) string, tmplErr *error) (*sns.PublishInput, error) { publishInput := &sns.PublishInput{} messageAttributes := n.createMessageAttributes(tmpl) if *tmplErr != nil { return nil, notify.NewErrorWithReason(notify.ClientErrorReason, fmt.Errorf("execute 'attributes' template: %w", *tmplErr)) } // Max message size for a message in an SNS publish request is 256KB, // except for SMS messages where the limit is 1600 characters/runes. messageSizeLimit := 256 * 1024 if n.conf.TopicARN != "" { topicARN := tmpl(n.conf.TopicARN) if *tmplErr != nil { return nil, notify.NewErrorWithReason(notify.ClientErrorReason, fmt.Errorf("execute 'topic_arn' template: %w", *tmplErr)) } publishInput.TopicArn = aws.String(topicARN) // If we are using a topic ARN, it could be a FIFO topic specified by the topic's suffix ".fifo". if strings.HasSuffix(topicARN, ".fifo") { key, err := notify.ExtractGroupKey(ctx) if err != nil { return nil, err } publishInput.MessageDeduplicationId = aws.String(key.Hash()) publishInput.MessageGroupId = aws.String(key.Hash()) } } if n.conf.PhoneNumber != "" { publishInput.PhoneNumber = aws.String(tmpl(n.conf.PhoneNumber)) if *tmplErr != nil { return nil, notify.NewErrorWithReason(notify.ClientErrorReason, fmt.Errorf("execute 'phone_number' template: %w", *tmplErr)) } // If we have an SMS message, we need to truncate to 1600 characters/runes. messageSizeLimit = 1600 } if n.conf.TargetARN != "" { publishInput.TargetArn = aws.String(tmpl(n.conf.TargetARN)) if *tmplErr != nil { return nil, notify.NewErrorWithReason(notify.ClientErrorReason, fmt.Errorf("execute 'target_arn' template: %w", *tmplErr)) } } tmplMessage := tmpl(n.conf.Message) if *tmplErr != nil { return nil, notify.NewErrorWithReason(notify.ClientErrorReason, fmt.Errorf("execute 'message' template: %w", *tmplErr)) } messageToSend, isTrunc, err := validateAndTruncateMessage(tmplMessage, messageSizeLimit) if err != nil { return nil, err } if isTrunc { // If we truncated the message we need to add a message attribute showing that it was truncated. messageAttributes["truncated"] = snstypes.MessageAttributeValue{DataType: aws.String("String"), StringValue: aws.String("true")} } publishInput.Message = aws.String(messageToSend) publishInput.MessageAttributes = messageAttributes if n.conf.Subject != "" { publishInput.Subject = aws.String(tmpl(n.conf.Subject)) if *tmplErr != nil { return nil, notify.NewErrorWithReason(notify.ClientErrorReason, fmt.Errorf("execute 'subject' template: %w", *tmplErr)) } } return publishInput, nil } func validateAndTruncateMessage(message string, maxMessageSizeInBytes int) (string, bool, error) { if !utf8.ValidString(message) { return "", false, fmt.Errorf("non utf8 encoded message string") } if len(message) <= maxMessageSizeInBytes { return message, false, nil } // If the message is larger than our specified size we have to truncate. truncated := make([]byte, maxMessageSizeInBytes) copy(truncated, message) return string(truncated), true, nil } func (n *Notifier) createMessageAttributes(tmpl func(string) string) map[string]snstypes.MessageAttributeValue { // Convert the given attributes map into the AWS Message Attributes Format. attributes := make(map[string]snstypes.MessageAttributeValue, len(n.conf.Attributes)) for k, v := range n.conf.Attributes { attributes[tmpl(k)] = snstypes.MessageAttributeValue{DataType: aws.String("String"), StringValue: aws.String(tmpl(v))} } return attributes } ================================================ FILE: notify/sns/sns_test.go ================================================ // Copyright 2021 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package sns import ( "context" "net/url" "testing" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/promslog" "github.com/prometheus/sigv4" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) var logger = promslog.NewNopLogger() func TestValidateAndTruncateMessage(t *testing.T) { sBuff := make([]byte, 257*1024) for i := range sBuff { sBuff[i] = byte(33) } truncatedMessage, isTruncated, err := validateAndTruncateMessage(string(sBuff), 256*1024) require.True(t, isTruncated) require.NoError(t, err) require.NotEqual(t, sBuff, truncatedMessage) require.Len(t, truncatedMessage, 256*1024) sBuff = make([]byte, 100) for i := range sBuff { sBuff[i] = byte(33) } truncatedMessage, isTruncated, err = validateAndTruncateMessage(string(sBuff), 100) require.False(t, isTruncated) require.NoError(t, err) require.Equal(t, string(sBuff), truncatedMessage) invalidUtf8String := "\xc3\x28" _, _, err = validateAndTruncateMessage(invalidUtf8String, 100) require.Error(t, err) } func TestNotifyWithInvalidTemplate(t *testing.T) { for _, tc := range []struct { title string errMsg string updateCfg func(*config.SNSConfig) }{ { title: "with invalid Attribute template", errMsg: "execute 'attributes' template", updateCfg: func(cfg *config.SNSConfig) { cfg.Attributes = map[string]string{ "attribName1": "{{ template \"unknown_template\" . }}", } }, }, { title: "with invalid TopicArn template", errMsg: "execute 'topic_arn' template", updateCfg: func(cfg *config.SNSConfig) { cfg.TopicARN = "{{ template \"unknown_template\" . }}" }, }, { title: "with invalid PhoneNumber template", errMsg: "execute 'phone_number' template", updateCfg: func(cfg *config.SNSConfig) { cfg.PhoneNumber = "{{ template \"unknown_template\" . }}" }, }, { title: "with invalid Message template", errMsg: "execute 'message' template", updateCfg: func(cfg *config.SNSConfig) { cfg.Message = "{{ template \"unknown_template\" . }}" }, }, { title: "with invalid Subject template", errMsg: "execute 'subject' template", updateCfg: func(cfg *config.SNSConfig) { cfg.Subject = "{{ template \"unknown_template\" . }}" }, }, { title: "with invalid APIUrl template", errMsg: "execute 'api_url' template", updateCfg: func(cfg *config.SNSConfig) { cfg.APIUrl = "{{ template \"unknown_template\" . }}" }, }, { title: "with invalid TargetARN template", errMsg: "execute 'target_arn' template", updateCfg: func(cfg *config.SNSConfig) { cfg.TargetARN = "{{ template \"unknown_template\" . }}" }, }, } { t.Run(tc.title, func(t *testing.T) { snsCfg := &config.SNSConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, TopicARN: "TestTopic", Sigv4: sigv4.SigV4Config{ Region: "us-west-2", }, } if tc.updateCfg != nil { tc.updateCfg(snsCfg) } notifier, err := New( snsCfg, createTmpl(t), logger, ) require.NoError(t, err) var alerts []*types.Alert _, err = notifier.Notify(context.Background(), alerts...) require.Error(t, err) require.Contains(t, err.Error(), "template \"unknown_template\" not defined") require.Contains(t, err.Error(), tc.errMsg) }) } } // CreateTmpl returns a ready-to-use template. func createTmpl(t *testing.T) *template.Template { tmpl, err := template.FromGlobs([]string{}) require.NoError(t, err) tmpl.ExternalURL, _ = url.Parse("http://am") return tmpl } ================================================ FILE: notify/telegram/telegram.go ================================================ // Copyright 2022 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package telegram import ( "context" "fmt" "log/slog" "net/http" "os" "strconv" "strings" commoncfg "github.com/prometheus/common/config" "gopkg.in/telebot.v3" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // Telegram supports 4096 chars max - from https://limits.tginfo.me/en. const maxMessageLenRunes = 4096 // Notifier implements a Notifier for telegram notifications. type Notifier struct { conf *config.TelegramConfig tmpl *template.Template logger *slog.Logger client *telebot.Bot retrier *notify.Retrier } // New returns a new Telegram notification handler. func New(conf *config.TelegramConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { httpclient, err := notify.NewClientWithTracing(*conf.HTTPConfig, "telegram", httpOpts...) if err != nil { return nil, err } client, err := createTelegramClient(conf.APIUrl.String(), conf.ParseMode, httpclient) if err != nil { return nil, err } return &Notifier{ conf: conf, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, }, nil } func (n *Notifier) Notify(ctx context.Context, alert ...*types.Alert) (bool, error) { key, ok := notify.GroupKey(ctx) if !ok { return false, fmt.Errorf("group key missing") } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") var ( err error data = notify.GetTemplateData(ctx, n.tmpl, alert, logger) tmpl = notify.TmplText(n.tmpl, data, &err) messageText string truncated bool ) switch n.conf.ParseMode { case "HTML": tmpl = notify.TmplHTML(n.tmpl, data, &err) messageText = tmpl(n.conf.Message) if err != nil { return false, err } if len([]rune(messageText)) > maxMessageLenRunes { messageText = `Alertmanager notification could not be sent: message length exceeds Telegram limits. Please check the template used for producing the message content.` } default: messageText, truncated = notify.TruncateInRunes(tmpl(n.conf.Message), maxMessageLenRunes) if err != nil { return false, err } if truncated { logger.Warn("Truncated message", "max_runes", maxMessageLenRunes) } } n.client.Token, err = n.getBotToken() if err != nil { return true, err } chatID, err := n.getChatID() if err != nil { return true, err } message, err := n.client.Send(telebot.ChatID(chatID), messageText, &telebot.SendOptions{ DisableNotification: n.conf.DisableNotifications, DisableWebPagePreview: true, ThreadID: n.conf.MessageThreadID, ParseMode: n.conf.ParseMode, }) if err != nil { return true, err } logger.Debug("Telegram message successfully published", "message_id", message.ID, "chat_id", message.Chat.ID) return false, nil } func createTelegramClient(apiURL, parseMode string, httpClient *http.Client) (*telebot.Bot, error) { bot, err := telebot.NewBot(telebot.Settings{ URL: apiURL, ParseMode: parseMode, Client: httpClient, Offline: true, }) if err != nil { return nil, err } return bot, nil } func (n *Notifier) getBotToken() (string, error) { if len(n.conf.BotTokenFile) > 0 { content, err := os.ReadFile(n.conf.BotTokenFile) if err != nil { return "", fmt.Errorf("could not read %s: %w", n.conf.BotTokenFile, err) } return strings.TrimSpace(string(content)), nil } return string(n.conf.BotToken), nil } func (n *Notifier) getChatID() (int64, error) { if len(n.conf.ChatIDFile) > 0 { content, err := os.ReadFile(n.conf.ChatIDFile) if err != nil { return 0, fmt.Errorf("could not read %s: %w", n.conf.ChatIDFile, err) } chatID, err := strconv.ParseInt(strings.TrimSpace(string(content)), 10, 64) if err != nil { return 0, fmt.Errorf("could not parse chat_id from %s: %w", n.conf.ChatIDFile, err) } return chatID, nil } return n.conf.ChatID, nil } ================================================ FILE: notify/telegram/telegram_test.go ================================================ // Copyright 2022 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package telegram import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) func TestTelegramUnmarshal(t *testing.T) { in := ` route: receiver: test receivers: - name: test telegram_configs: - chat_id: 1234 bot_token: secret message_thread_id: 1357 ` var c config.Config err := yaml.Unmarshal([]byte(in), &c) require.NoError(t, err) require.Len(t, c.Receivers, 1) require.Len(t, c.Receivers[0].TelegramConfigs, 1) require.Equal(t, "https://api.telegram.org", c.Receivers[0].TelegramConfigs[0].APIUrl.String()) require.Equal(t, commoncfg.Secret("secret"), c.Receivers[0].TelegramConfigs[0].BotToken) require.Equal(t, int64(1234), c.Receivers[0].TelegramConfigs[0].ChatID) require.Equal(t, 1357, c.Receivers[0].TelegramConfigs[0].MessageThreadID) require.Equal(t, "HTML", c.Receivers[0].TelegramConfigs[0].ParseMode) } func TestTelegramRetry(t *testing.T) { // Fake url for testing purposes fakeURL := amcommoncfg.URL{ URL: &url.URL{ Scheme: "https", Host: "FAKE_API", }, } notifier, err := New( &config.TelegramConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, APIUrl: &fakeURL, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "error on status %d", statusCode) } } func TestTelegramNotify(t *testing.T) { token := "secret" fileWithToken, err := os.CreateTemp(t.TempDir(), "telegram-bot-token") require.NoError(t, err, "creating temp file failed") _, err = fileWithToken.WriteString(token) require.NoError(t, err, "writing to temp file failed") for _, tc := range []struct { name string cfg config.TelegramConfig expText string }{ { name: "No escaping by default", cfg: config.TelegramConfig{ Message: "x < y", HTTPConfig: &commoncfg.HTTPClientConfig{}, BotToken: commoncfg.Secret(token), }, expText: "x < y", }, { name: "Characters escaped in HTML mode", cfg: config.TelegramConfig{ ParseMode: "HTML", Message: "x < y", HTTPConfig: &commoncfg.HTTPClientConfig{}, BotToken: commoncfg.Secret(token), }, expText: "x < y", }, { name: "Bot token from file", cfg: config.TelegramConfig{ Message: "test", HTTPConfig: &commoncfg.HTTPClientConfig{}, BotTokenFile: fileWithToken.Name(), }, expText: "test", }, { name: "HTML mode with too-large message", cfg: config.TelegramConfig{ ParseMode: "HTML", Message: strings.Repeat("x", 5000), HTTPConfig: &commoncfg.HTTPClientConfig{}, BotToken: commoncfg.Secret(token), }, expText: `Alertmanager notification could not be sent: message length exceeds Telegram limits. Please check the template used for producing the message content.`, }, { name: "Default mode with too-large message", cfg: config.TelegramConfig{ Message: strings.Repeat("y", 5000), HTTPConfig: &commoncfg.HTTPClientConfig{}, BotToken: commoncfg.Secret(token), }, expText: strings.Repeat("y", maxMessageLenRunes-1) + "…", }, { name: "HTML mode with message smaller than limit", cfg: config.TelegramConfig{ ParseMode: "HTML", Message: strings.Repeat("a", 100), HTTPConfig: &commoncfg.HTTPClientConfig{}, BotToken: commoncfg.Secret(token), }, expText: strings.Repeat("a", 100), }, { name: "Default mode with message smaller than limit", cfg: config.TelegramConfig{ Message: strings.Repeat("b", 100), HTTPConfig: &commoncfg.HTTPClientConfig{}, BotToken: commoncfg.Secret(token), }, expText: strings.Repeat("b", 100), }, } { t.Run(tc.name, func(t *testing.T) { var out []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/bot"+token+"/sendMessage", r.URL.Path) var err error out, err = io.ReadAll(r.Body) require.NoError(t, err) w.Write([]byte(`{"ok":true,"result":{"chat":{}}}`)) })) defer srv.Close() u, _ := url.Parse(srv.URL) tc.cfg.APIUrl = &amcommoncfg.URL{URL: u} notifier, err := New(&tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() ctx = notify.WithGroupKey(ctx, "1") retry, err := notifier.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", "lbl3": "val3", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) require.False(t, retry) require.NoError(t, err) req := map[string]string{} err = json.Unmarshal(out, &req) require.NoError(t, err) require.Equal(t, tc.expText, req["text"]) }) } } ================================================ FILE: notify/test/test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "context" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // RetryTests returns a map of HTTP status codes to bool indicating whether the notifier should retry or not. func RetryTests(retryCodes []int) map[int]bool { tests := map[int]bool{ // 1xx http.StatusContinue: false, http.StatusSwitchingProtocols: false, http.StatusProcessing: false, // 2xx http.StatusOK: false, http.StatusCreated: false, http.StatusAccepted: false, http.StatusNonAuthoritativeInfo: false, http.StatusNoContent: false, http.StatusResetContent: false, http.StatusPartialContent: false, http.StatusMultiStatus: false, http.StatusAlreadyReported: false, http.StatusIMUsed: false, // 3xx http.StatusMultipleChoices: false, http.StatusMovedPermanently: false, http.StatusFound: false, http.StatusSeeOther: false, http.StatusNotModified: false, http.StatusUseProxy: false, http.StatusTemporaryRedirect: false, http.StatusPermanentRedirect: false, // 4xx http.StatusBadRequest: false, http.StatusUnauthorized: false, http.StatusPaymentRequired: false, http.StatusForbidden: false, http.StatusNotFound: false, http.StatusMethodNotAllowed: false, http.StatusNotAcceptable: false, http.StatusProxyAuthRequired: false, http.StatusRequestTimeout: false, http.StatusConflict: false, http.StatusGone: false, http.StatusLengthRequired: false, http.StatusPreconditionFailed: false, http.StatusRequestEntityTooLarge: false, http.StatusRequestURITooLong: false, http.StatusUnsupportedMediaType: false, http.StatusRequestedRangeNotSatisfiable: false, http.StatusExpectationFailed: false, http.StatusTeapot: false, http.StatusUnprocessableEntity: false, http.StatusLocked: false, http.StatusFailedDependency: false, http.StatusUpgradeRequired: false, http.StatusPreconditionRequired: false, http.StatusTooManyRequests: false, http.StatusRequestHeaderFieldsTooLarge: false, http.StatusUnavailableForLegalReasons: false, // 5xx http.StatusInternalServerError: false, http.StatusNotImplemented: false, http.StatusBadGateway: false, http.StatusServiceUnavailable: false, http.StatusGatewayTimeout: false, http.StatusHTTPVersionNotSupported: false, http.StatusVariantAlsoNegotiates: false, http.StatusInsufficientStorage: false, http.StatusLoopDetected: false, http.StatusNotExtended: false, http.StatusNetworkAuthenticationRequired: false, } for _, statusCode := range retryCodes { tests[statusCode] = true } return tests } // DefaultRetryCodes returns the list of HTTP status codes that need to be retried. func DefaultRetryCodes() []int { return []int{ http.StatusInternalServerError, http.StatusNotImplemented, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout, http.StatusHTTPVersionNotSupported, http.StatusVariantAlsoNegotiates, http.StatusInsufficientStorage, http.StatusLoopDetected, http.StatusNotExtended, http.StatusNetworkAuthenticationRequired, } } // CreateTmpl returns a ready-to-use template. func CreateTmpl(t *testing.T) *template.Template { tmpl, err := template.FromGlobs([]string{}) require.NoError(t, err) tmpl.ExternalURL, _ = url.Parse("http://am") return tmpl } // AssertNotifyLeaksNoSecret calls the Notify() method of the notifier, expects // it to fail because the context is canceled by the server and checks that no // secret data is leaked in the error message returned by Notify(). func AssertNotifyLeaksNoSecret(ctx context.Context, t *testing.T, n notify.Notifier, secret ...string) { t.Helper() require.NotEmpty(t, secret) ctx = notify.WithGroupKey(ctx, "1") ok, err := n.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) require.Error(t, err) require.Contains(t, err.Error(), context.Canceled.Error()) for _, s := range secret { require.NotContains(t, err.Error(), s) } require.True(t, ok) } // GetContextWithCancelingURL returns a context that gets canceled when a // client does a GET request to the returned URL. // Handlers passed to the function will be invoked in order before the context gets canceled. // The last argument is a function that needs to be called before the caller returns. func GetContextWithCancelingURL(h ...func(w http.ResponseWriter, r *http.Request)) (context.Context, *url.URL, func()) { done := make(chan struct{}) ctx, cancel := context.WithCancel(context.Background()) i := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if i < len(h) { h[i](w, r) } else { cancel() <-done } i++ })) // No need to check the error since httptest.NewServer always return a valid URL. u, _ := url.Parse(srv.URL) return ctx, u, func() { close(done) srv.Close() } } ================================================ FILE: notify/util.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package notify import ( "context" "crypto/sha256" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "slices" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/version" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/tracing" "github.com/prometheus/alertmanager/types" ) // truncationMarker is the character used to represent a truncation. const truncationMarker = "…" // UserAgentHeader is the default User-Agent for notification requests. var UserAgentHeader = version.ComponentUserAgent("Alertmanager") // NewClientWithTracing creates a new HTTP client with tracing included // Clients are reused across requests, so tracing is configured once at creation // rather than on each request. func NewClientWithTracing(cfg commoncfg.HTTPClientConfig, name string, httpOpts ...commoncfg.HTTPClientOption) (*http.Client, error) { client, err := commoncfg.NewClientFromConfig(cfg, name, httpOpts...) if err != nil { return nil, err } client.Transport = tracing.Transport(client.Transport) return client, nil } // RedactURL removes the URL part from an error of *url.Error type. func RedactURL(err error) error { var e *url.Error if !errors.As(err, &e) { return err } e.URL = "" return e } // Get sends a GET request to the given URL. func Get(ctx context.Context, client *http.Client, url string) (*http.Response, error) { return request(ctx, client, http.MethodGet, url, "", nil) } // PostJSON sends a POST request with JSON payload to the given URL. func PostJSON(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) { return post(ctx, client, url, "application/json", body) } // PostText sends a POST request with text payload to the given URL. func PostText(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error) { return post(ctx, client, url, "text/plain", body) } func post(ctx context.Context, client *http.Client, url, bodyType string, body io.Reader) (*http.Response, error) { return request(ctx, client, http.MethodPost, url, bodyType, body) } func request(ctx context.Context, client *http.Client, method, url, bodyType string, body io.Reader) (*http.Response, error) { req, err := http.NewRequest(method, url, body) if err != nil { return nil, err } req.Header.Set("User-Agent", UserAgentHeader) if bodyType != "" { req.Header.Set("Content-Type", bodyType) } return client.Do(req.WithContext(ctx)) } // Drain consumes and closes the response's body to make sure that the // HTTP client can reuse existing connections. func Drain(r *http.Response) { io.Copy(io.Discard, r.Body) r.Body.Close() } // TruncateInRunes truncates a string to fit the given size in Runes. func TruncateInRunes(s string, n int) (string, bool) { r := []rune(s) if len(r) <= n { return s, false } if n <= 3 { return string(r[:n]), true } return string(r[:n-1]) + truncationMarker, true } // TruncateInBytes truncates a string to fit the given size in Bytes. func TruncateInBytes(s string, n int) (string, bool) { // First, measure the string the w/o a to-rune conversion. if len(s) <= n { return s, false } // The truncationMarker itself is 3 bytes, we can't return any part of the string when it's less than 3. if n <= 3 { switch n { case 3: return truncationMarker, true default: return strings.Repeat(".", n), true } } // Now, to ensure we don't butcher the string we need to remove using runes. r := []rune(s) truncationTarget := n - 3 // Next, let's truncate the runes to the lower possible number. truncatedRunes := r[:truncationTarget] for len(string(truncatedRunes)) > truncationTarget { truncatedRunes = r[:len(truncatedRunes)-1] } return string(truncatedRunes) + truncationMarker, true } // TmplText is using monadic error handling in order to make string templating // less verbose. Use with care as the final error checking is easily missed. func TmplText(tmpl *template.Template, data *template.Data, err *error) func(string) string { return func(name string) (s string) { if *err != nil { return s } s, *err = tmpl.ExecuteTextString(name, data) return s } } // TmplHTML is using monadic error handling in order to make string templating // less verbose. Use with care as the final error checking is easily missed. func TmplHTML(tmpl *template.Template, data *template.Data, err *error) func(string) string { return func(name string) (s string) { if *err != nil { return s } s, *err = tmpl.ExecuteHTMLString(name, data) return s } } // Key is a string that can be hashed. type Key string // ExtractGroupKey gets the group key from the context. func ExtractGroupKey(ctx context.Context) (Key, error) { key, ok := GroupKey(ctx) if !ok { return "", fmt.Errorf("group key missing") } return Key(key), nil } // Hash returns the sha256 for a group key as integrations may have // maximum length requirements on deduplication keys. func (k Key) Hash() string { h := sha256.New() // hash.Hash.Write never returns an error. //nolint: errcheck h.Write([]byte(string(k))) return fmt.Sprintf("%x", h.Sum(nil)) } func (k Key) String() string { return string(k) } // GetTemplateData creates the template data from the context and the alerts. func GetTemplateData(ctx context.Context, tmpl *template.Template, alerts []*types.Alert, l *slog.Logger) *template.Data { recv, ok := ReceiverName(ctx) if !ok { l.Error("Missing receiver") } groupLabels, ok := GroupLabels(ctx) if !ok { l.Error("Missing group labels") } notificationReason, ok := NotificationReason(ctx) if !ok { l.Error("Missing notification reason") notificationReason = ReasonUnknown } return tmpl.Data(recv, groupLabels, notificationReason.String(), alerts...) } func readAll(r io.Reader) string { if r == nil { return "" } bs, err := io.ReadAll(r) if err != nil { return "" } return string(bs) } // Retrier knows when to retry an HTTP request to a receiver. 2xx status codes // are successful, anything else is a failure and only 5xx status codes should // be retried. type Retrier struct { // Function to return additional information in the error message. CustomDetailsFunc func(code int, body io.Reader) string // Additional HTTP status codes that should be retried. RetryCodes []int } // Check returns a boolean indicating whether the request should be retried // and an optional error if the request has failed. If body is not nil, it will // be included in the error message. func (r *Retrier) Check(statusCode int, body io.Reader) (bool, error) { // 2xx responses are considered to be always successful. if statusCode/100 == 2 { return false, nil } // 5xx responses are considered to be always retried. retry := statusCode/100 == 5 || slices.Contains(r.RetryCodes, statusCode) s := fmt.Sprintf("unexpected status code %v", statusCode) var details string if r.CustomDetailsFunc != nil { details = r.CustomDetailsFunc(statusCode, body) } else { details = readAll(body) } if details != "" { s = fmt.Sprintf("%s: %s", s, details) } return retry, errors.New(s) } type ErrorWithReason struct { Err error Reason Reason } func NewErrorWithReason(reason Reason, err error) *ErrorWithReason { return &ErrorWithReason{ Err: err, Reason: reason, } } func (e *ErrorWithReason) Error() string { return e.Err.Error() } // Reason is the failure reason. type Reason int const ( DefaultReason Reason = iota ClientErrorReason ServerErrorReason ContextCanceledReason ContextDeadlineExceededReason ) func (s Reason) String() string { switch s { case DefaultReason: return "other" case ClientErrorReason: return "clientError" case ServerErrorReason: return "serverError" case ContextCanceledReason: return "contextCanceled" case ContextDeadlineExceededReason: return "contextDeadlineExceeded" default: panic(fmt.Sprintf("unknown Reason: %d", s)) } } // possibleFailureReasonCategory is a list of possible failure reason. var possibleFailureReasonCategory = []string{DefaultReason.String(), ClientErrorReason.String(), ServerErrorReason.String(), ContextCanceledReason.String(), ContextDeadlineExceededReason.String()} // GetFailureReasonFromStatusCode returns the reason for the failure based on the status code provided. func GetFailureReasonFromStatusCode(statusCode int) Reason { if statusCode/100 == 4 { return ClientErrorReason } if statusCode/100 == 5 { return ServerErrorReason } return DefaultReason } ================================================ FILE: notify/util_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package notify import ( "bytes" "fmt" "io" "net/http" "path" "reflect" "runtime" "testing" "github.com/stretchr/testify/require" ) func TestTruncate(t *testing.T) { type expect struct { out string trunc bool } testCases := []struct { in string n int runes expect bytes expect }{ { in: "", n: 5, runes: expect{out: "", trunc: false}, bytes: expect{out: "", trunc: false}, }, { in: "abcde", n: 2, runes: expect{out: "ab", trunc: true}, bytes: expect{out: "..", trunc: true}, }, { in: "abcde", n: 4, runes: expect{out: "abc…", trunc: true}, bytes: expect{out: "a…", trunc: true}, }, { in: "abcde", n: 5, runes: expect{out: "abcde", trunc: false}, bytes: expect{out: "abcde", trunc: false}, }, { in: "abcdefgh", n: 5, runes: expect{out: "abcd…", trunc: true}, bytes: expect{out: "ab…", trunc: true}, }, { in: "a⌘cde", n: 5, runes: expect{out: "a⌘cde", trunc: false}, bytes: expect{out: "a…", trunc: true}, }, { in: "a⌘cdef", n: 5, runes: expect{out: "a⌘cd…", trunc: true}, bytes: expect{out: "a…", trunc: true}, }, { in: "世界cdef", n: 3, runes: expect{out: "世界c", trunc: true}, bytes: expect{out: "…", trunc: true}, }, { in: "❤️✅🚀🔥❌❤️✅🚀🔥❌❤️✅🚀🔥❌❤️✅🚀🔥❌", n: 19, runes: expect{out: "❤️✅🚀🔥❌❤️✅🚀🔥❌❤️✅🚀🔥❌…", trunc: true}, bytes: expect{out: "❤️✅🚀…", trunc: true}, }, } type truncateFunc func(string, int) (string, bool) for _, tc := range testCases { for _, fn := range []truncateFunc{TruncateInBytes, TruncateInRunes} { var truncated bool var out string fnPath := runtime.FuncForPC(reflect.ValueOf(fn).Pointer()).Name() fnName := path.Base(fnPath) switch fnName { case "notify.TruncateInRunes": truncated = tc.runes.trunc out = tc.runes.out case "notify.TruncateInBytes": truncated = tc.bytes.trunc out = tc.bytes.out default: t.Fatalf("unknown function") } t.Run(fmt.Sprintf("%s(%s,%d)", fnName, tc.in, tc.n), func(t *testing.T) { s, trunc := fn(tc.in, tc.n) require.Equal(t, out, s) require.Equal(t, truncated, trunc) }) } } } type brokenReader struct{} func (b brokenReader) Read([]byte) (int, error) { return 0, fmt.Errorf("some error") } func TestRetrierCheck(t *testing.T) { for _, tc := range []struct { retrier Retrier status int body io.Reader retry bool expectedErr string }{ { retrier: Retrier{}, status: http.StatusOK, body: bytes.NewBuffer([]byte("ok")), retry: false, }, { retrier: Retrier{}, status: http.StatusNoContent, retry: false, }, { retrier: Retrier{}, status: http.StatusBadRequest, retry: false, expectedErr: "unexpected status code 400", }, { retrier: Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, status: http.StatusBadRequest, body: bytes.NewBuffer([]byte("invalid request")), retry: false, expectedErr: "unexpected status code 400: invalid request", }, { retrier: Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, status: http.StatusTooManyRequests, retry: true, expectedErr: "unexpected status code 429", }, { retrier: Retrier{}, status: http.StatusServiceUnavailable, body: bytes.NewBuffer([]byte("retry later")), retry: true, expectedErr: "unexpected status code 503: retry later", }, { retrier: Retrier{}, status: http.StatusBadGateway, body: &brokenReader{}, retry: true, expectedErr: "unexpected status code 502", }, { retrier: Retrier{CustomDetailsFunc: func(status int, b io.Reader) string { if status != http.StatusServiceUnavailable { return "invalid" } bs, _ := io.ReadAll(b) return fmt.Sprintf("server response is %q", string(bs)) }}, status: http.StatusServiceUnavailable, body: bytes.NewBuffer([]byte("retry later")), retry: true, expectedErr: "unexpected status code 503: server response is \"retry later\"", }, } { t.Run("", func(t *testing.T) { retry, err := tc.retrier.Check(tc.status, tc.body) require.Equal(t, tc.retry, retry) if tc.expectedErr == "" { require.NoError(t, err) return } require.EqualError(t, err, tc.expectedErr) }) } } ================================================ FILE: notify/victorops/victorops.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package victorops import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // https://help.victorops.com/knowledge-base/incident-fields-glossary/ - 20480 characters. const maxMessageLenRunes = 20480 // Notifier implements a Notifier for VictorOps notifications. type Notifier struct { conf *config.VictorOpsConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier } // New returns a new VictorOps notifier. func New(c *config.VictorOpsConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "victorops", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: c, tmpl: t, logger: l, client: client, // Missing documentation therefore assuming only 5xx response codes are // recoverable. retrier: ¬ify.Retrier{}, }, nil } const ( victorOpsEventTrigger = "CRITICAL" victorOpsEventResolve = "RECOVERY" ) // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { var err error var ( data = notify.GetTemplateData(ctx, n.tmpl, as, n.logger) tmpl = notify.TmplText(n.tmpl, data, &err) apiURL = n.conf.APIURL.Copy() ) var apiKey string if n.conf.APIKey != "" { apiKey = string(n.conf.APIKey) } else { content, fileErr := os.ReadFile(n.conf.APIKeyFile) if fileErr != nil { return false, fmt.Errorf("failed to read API key from file: %w", fileErr) } apiKey = strings.TrimSpace(string(content)) } apiURL.Path += fmt.Sprintf("%s/%s", apiKey, tmpl(n.conf.RoutingKey)) if err != nil { return false, fmt.Errorf("templating error: %w", err) } buf, err := n.createVictorOpsPayload(ctx, as...) if err != nil { return true, err } resp, err := notify.PostJSON(ctx, n.client, apiURL.String(), buf) if err != nil { return true, notify.RedactURL(err) } defer notify.Drain(resp) shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } return shouldRetry, err } // Create the JSON payload to be sent to the VictorOps API. func (n *Notifier) createVictorOpsPayload(ctx context.Context, as ...*types.Alert) (*bytes.Buffer, error) { victorOpsAllowedEvents := map[string]bool{ "INFO": true, "WARNING": true, "CRITICAL": true, } key, err := notify.ExtractGroupKey(ctx) if err != nil { return nil, err } var ( alerts = types.Alerts(as...) data = notify.GetTemplateData(ctx, n.tmpl, as, n.logger) tmpl = notify.TmplText(n.tmpl, data, &err) messageType = tmpl(n.conf.MessageType) stateMessage = tmpl(n.conf.StateMessage) ) if alerts.Status() == model.AlertFiring && !victorOpsAllowedEvents[messageType] { messageType = victorOpsEventTrigger } if alerts.Status() == model.AlertResolved { messageType = victorOpsEventResolve } stateMessage, truncated := notify.TruncateInRunes(stateMessage, maxMessageLenRunes) if truncated { n.logger.Warn("Truncated state_message", "group_key", key, "max_runes", maxMessageLenRunes) } msg := map[string]string{ "message_type": messageType, "entity_id": key.Hash(), "entity_display_name": tmpl(n.conf.EntityDisplayName), "state_message": stateMessage, "monitoring_tool": tmpl(n.conf.MonitoringTool), } if err != nil { return nil, fmt.Errorf("templating error: %w", err) } // Add custom fields to the payload. for k, v := range n.conf.CustomFields { msg[k] = tmpl(v) if err != nil { return nil, fmt.Errorf("templating error: %w", err) } } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(msg); err != nil { return nil, err } return &buf, nil } ================================================ FILE: notify/victorops/victorops_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package victorops import ( "context" "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) func TestVictorOpsCustomFields(t *testing.T) { logger := promslog.NewNopLogger() tmpl := test.CreateTmpl(t) url, err := url.Parse("http://nowhere.com") require.NoError(t, err, "unexpected error parsing mock url") conf := &config.VictorOpsConfig{ APIKey: `12345`, APIURL: &amcommoncfg.URL{URL: url}, EntityDisplayName: `{{ .CommonLabels.Message }}`, StateMessage: `{{ .CommonLabels.Message }}`, RoutingKey: `test`, MessageType: ``, MonitoringTool: `AM`, CustomFields: map[string]string{ "Field_A": "{{ .CommonLabels.Message }}", }, HTTPConfig: &commoncfg.HTTPClientConfig{}, } notifier, err := New(conf, tmpl, logger) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") alert := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "Message": "message", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, } msg, err := notifier.createVictorOpsPayload(ctx, alert) require.NoError(t, err) var m map[string]string err = json.Unmarshal(msg.Bytes(), &m) require.NoError(t, err) // Verify that a custom field was added to the payload and templatized. require.Equal(t, "message", m["Field_A"]) } func TestVictorOpsRetry(t *testing.T) { notifier, err := New( &config.VictorOpsConfig{ APIKey: commoncfg.Secret("secret"), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "error on status %d", statusCode) } } func TestVictorOpsRedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() secret := "secret" notifier, err := New( &config.VictorOpsConfig{ APIURL: &amcommoncfg.URL{URL: u}, APIKey: commoncfg.Secret(secret), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret) } func TestVictorOpsReadingApiKeyFromFile(t *testing.T) { key := "key" f, err := os.CreateTemp(t.TempDir(), "victorops_test") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(key) require.NoError(t, err, "writing to temp file failed") ctx, u, fn := test.GetContextWithCancelingURL() defer fn() notifier, err := New( &config.VictorOpsConfig{ APIURL: &amcommoncfg.URL{URL: u}, APIKeyFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, key) } func TestVictorOpsTemplating(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(r.Body) out := make(map[string]any) err := dec.Decode(&out) if err != nil { panic(err) } })) defer srv.Close() u, _ := url.Parse(srv.URL) tests := []struct { name string cfg *config.VictorOpsConfig errMsg string }{ { name: "default valid templates", cfg: &config.VictorOpsConfig{}, }, { name: "invalid message_type", cfg: &config.VictorOpsConfig{ MessageType: "{{ .CommonLabels.alertname }", }, errMsg: "templating error", }, { name: "invalid entity_display_name", cfg: &config.VictorOpsConfig{ EntityDisplayName: "{{ .CommonLabels.alertname }", }, errMsg: "templating error", }, { name: "invalid state_message", cfg: &config.VictorOpsConfig{ StateMessage: "{{ .CommonLabels.alertname }", }, errMsg: "templating error", }, { name: "invalid monitoring tool", cfg: &config.VictorOpsConfig{ MonitoringTool: "{{ .CommonLabels.alertname }", }, errMsg: "templating error", }, { name: "invalid routing_key", cfg: &config.VictorOpsConfig{ RoutingKey: "{{ .CommonLabels.alertname }", }, errMsg: "templating error", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{} tc.cfg.APIURL = &amcommoncfg.URL{URL: u} tc.cfg.APIKey = "test" vo, err := New(tc.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "1") _, err = vo.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) if tc.errMsg == "" { require.NoError(t, err) } else { require.Contains(t, err.Error(), tc.errMsg) } }) } } ================================================ FILE: notify/webex/webex.go ================================================ // Copyright 2022 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package webex import ( "bytes" "context" "encoding/json" "log/slog" "net/http" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) const ( // nolint:godot // maxMessageSize represents the maximum message length that Webex supports. maxMessageSize = 7439 ) type Notifier struct { conf *config.WebexConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier } // New returns a new Webex notifier. func New(c *config.WebexConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "webex", httpOpts...) if err != nil { return nil, err } n := &Notifier{ conf: c, tmpl: t, logger: l, client: client, retrier: ¬ify.Retrier{}, } return n, nil } type webhook struct { Markdown string `json:"markdown"` RoomID string `json:"roomId,omitempty"` } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") data := notify.GetTemplateData(ctx, n.tmpl, as, logger) tmpl := notify.TmplText(n.tmpl, data, &err) if err != nil { return false, err } message := tmpl(n.conf.Message) if err != nil { return false, err } message, truncated := notify.TruncateInBytes(message, maxMessageSize) if truncated { logger.Debug("message truncated due to exceeding maximum allowed length by webex", "truncated_message", message) } w := webhook{ Markdown: message, RoomID: tmpl(n.conf.RoomID), } var payload bytes.Buffer if err = json.NewEncoder(&payload).Encode(w); err != nil { return false, err } resp, err := notify.PostJSON(ctx, n.client, n.conf.APIURL.String(), &payload) if err != nil { return true, notify.RedactURL(err) } shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return shouldRetry, err } return false, nil } ================================================ FILE: notify/webex/webex_test.go ================================================ // Copyright 2022 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package webex import ( "context" "io" "net/http" "net/http/httptest" "net/url" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) func TestWebexRetry(t *testing.T) { testWebhookURL, err := url.Parse("https://api.ciscospark.com/v1/message") require.NoError(t, err) notifier, err := New( &config.WebexConfig{ HTTPConfig: &commoncfg.HTTPClientConfig{}, APIURL: &amcommoncfg.URL{URL: testWebhookURL}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "error on status %d", statusCode) } } func TestWebexTemplating(t *testing.T) { tc := []struct { name string cfg *config.WebexConfig Message string expJSON string commonCfg *commoncfg.HTTPClientConfig retry bool errMsg string expHeader string }{ { name: "with a valid message and a set http_config.authorization, it is formatted as expected", cfg: &config.WebexConfig{ Message: `{{ template "webex.default.message" . }}`, }, commonCfg: &commoncfg.HTTPClientConfig{ Authorization: &commoncfg.Authorization{Type: "Bearer", Credentials: "anewsecret"}, }, expJSON: `{"markdown":"\n\nAlerts Firing:\nLabels:\n - lbl1 = val1\n - lbl3 = val3\nAnnotations:\nSource: \nLabels:\n - lbl1 = val1\n - lbl2 = val2\nAnnotations:\nSource: \n\n\n\n"}`, retry: false, expHeader: "Bearer anewsecret", }, { name: "with message templating errors, it fails.", cfg: &config.WebexConfig{ Message: "{{ ", }, commonCfg: &commoncfg.HTTPClientConfig{}, errMsg: "template: :1: unclosed action", }, { name: "with a valid roomID set, the roomID is used accordingly.", cfg: &config.WebexConfig{ RoomID: "my-room-id", }, commonCfg: &commoncfg.HTTPClientConfig{}, expJSON: `{"markdown":"", "roomId":"my-room-id"}`, retry: false, }, { name: "with a valid roomID template, the roomID is used accordingly.", cfg: &config.WebexConfig{ RoomID: "{{.GroupLabels.webex_room_id}}", }, commonCfg: &commoncfg.HTTPClientConfig{}, expJSON: `{"markdown":"", "roomId":"group-label-room-id"}`, retry: false, }, } for _, tt := range tc { t.Run(tt.name, func(t *testing.T) { var out []byte var header http.Header srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error out, err = io.ReadAll(r.Body) header = r.Header.Clone() require.NoError(t, err) })) defer srv.Close() u, _ := url.Parse(srv.URL) tt.cfg.APIURL = &amcommoncfg.URL{URL: u} tt.cfg.HTTPConfig = tt.commonCfg notifierWebex, err := New(tt.cfg, test.CreateTmpl(t), promslog.NewNopLogger()) require.NoError(t, err) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() ctx = notify.WithGroupKey(ctx, "1") ctx = notify.WithGroupLabels(ctx, model.LabelSet{"webex_room_id": "group-label-room-id"}) ok, err := notifierWebex.Notify(ctx, []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", "lbl3": "val3", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, { Alert: model.Alert{ Labels: model.LabelSet{ "lbl1": "val1", "lbl2": "val2", }, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, }...) if tt.errMsg == "" { require.NoError(t, err) require.Equal(t, tt.expHeader, header.Get("Authorization")) require.JSONEq(t, tt.expJSON, string(out)) } else { require.Error(t, err) require.Contains(t, err.Error(), tt.errMsg) } require.Equal(t, tt.retry, ok) }) } } ================================================ FILE: notify/webhook/webhook.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package webhook import ( "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "os" "strings" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // Notifier implements a Notifier for generic webhooks. type Notifier struct { conf *config.WebhookConfig tmpl *template.Template logger *slog.Logger client *http.Client retrier *notify.Retrier } // New returns a new Webhook. func New(conf *config.WebhookConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*conf.HTTPConfig, "webhook", httpOpts...) if err != nil { return nil, err } return &Notifier{ conf: conf, tmpl: t, logger: l, client: client, // Webhooks are assumed to respond with 2xx response codes on a successful // request and 5xx response codes are assumed to be recoverable. retrier: ¬ify.Retrier{}, }, nil } // Message defines the JSON object send to webhook endpoints. type Message struct { *template.Data // The protocol version. Version string `json:"version"` GroupKey string `json:"groupKey"` TruncatedAlerts uint64 `json:"truncatedAlerts"` } func truncateAlerts(maxAlerts uint64, alerts []*types.Alert) ([]*types.Alert, uint64) { if maxAlerts != 0 && uint64(len(alerts)) > maxAlerts { return alerts[:maxAlerts], uint64(len(alerts)) - maxAlerts } return alerts, 0 } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, alerts ...*types.Alert) (bool, error) { alerts, numTruncated := truncateAlerts(n.conf.MaxAlerts, alerts) data := notify.GetTemplateData(ctx, n.tmpl, alerts, n.logger) groupKey, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", groupKey) logger.Debug("extracted group key") msg := &Message{ Version: "4", Data: data, GroupKey: groupKey.String(), TruncatedAlerts: numTruncated, } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(msg); err != nil { return false, err } // Override the payload if a custom one is configured. if n.conf.Payload != nil { buf, err = n.renderPayload(msg) if err != nil { return false, fmt.Errorf("failed to render custom payload: %w", err) } } var url string var tmplErr error tmpl := notify.TmplText(n.tmpl, data, &tmplErr) if n.conf.URL != "" { url = tmpl(string(n.conf.URL)) } else { content, err := os.ReadFile(n.conf.URLFile) if err != nil { return false, fmt.Errorf("read url_file: %w", err) } url = tmpl(strings.TrimSpace(string(content))) } if tmplErr != nil { return false, fmt.Errorf("failed to template webhook URL: %w", tmplErr) } if url == "" { return false, errors.New("webhook URL is empty after templating") } if n.conf.Timeout > 0 { postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured webhook timeout reached (%s)", n.conf.Timeout)) defer cancel() ctx = postCtx } resp, err := notify.PostJSON(ctx, n.client, url, &buf) if err != nil { if ctx.Err() != nil { err = fmt.Errorf("%w: %w", err, context.Cause(ctx)) } return true, notify.RedactURL(err) } defer notify.Drain(resp) shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) if err != nil { return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } return shouldRetry, err } func (n *Notifier) renderPayload( data *Message, ) (bytes.Buffer, error) { var ( tmplTextErr error tmplText = notify.TmplText(n.tmpl, data.Data, &tmplTextErr) tmplTextFunc = func(tmpl string) (string, error) { return tmplText(tmpl), tmplTextErr } ) var err error rendered := make(map[string]any, len(n.conf.Payload)) for k, v := range n.conf.Payload { rendered[k], err = template.DeepCopyWithTemplate(v, tmplTextFunc) if err != nil { return bytes.Buffer{}, err } } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(rendered); err != nil { return bytes.Buffer{}, err } return buf, nil } ================================================ FILE: notify/webhook/webhook_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package webhook import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "os" "testing" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/test" "github.com/prometheus/alertmanager/types" ) func TestWebhookRetry(t *testing.T) { notifier, err := New( &config.WebhookConfig{ URL: config.SecretTemplateURL("http://example.com"), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) if err != nil { require.NoError(t, err) } t.Run("test retry status code", func(t *testing.T) { for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) { actual, _ := notifier.retrier.Check(statusCode, nil) require.Equal(t, expected, actual, "error on status %d", statusCode) } }) t.Run("test retry error details", func(t *testing.T) { for _, tc := range []struct { status int body io.Reader exp string }{ { status: http.StatusBadRequest, body: bytes.NewBuffer([]byte( `{"status":"invalid event"}`, )), exp: fmt.Sprintf(`unexpected status code %d: {"status":"invalid event"}`, http.StatusBadRequest), }, { status: http.StatusBadRequest, exp: fmt.Sprintf(`unexpected status code %d`, http.StatusBadRequest), }, } { t.Run("", func(t *testing.T) { _, err = notifier.retrier.Check(tc.status, tc.body) require.Equal(t, tc.exp, err.Error()) }) } }) } func TestWebhookTruncateAlerts(t *testing.T) { alerts := make([]*types.Alert, 10) truncatedAlerts, numTruncated := truncateAlerts(0, alerts) require.Len(t, truncatedAlerts, 10) require.EqualValues(t, 0, numTruncated) truncatedAlerts, numTruncated = truncateAlerts(4, alerts) require.Len(t, truncatedAlerts, 4) require.EqualValues(t, 6, numTruncated) truncatedAlerts, numTruncated = truncateAlerts(100, alerts) require.Len(t, truncatedAlerts, 10) require.EqualValues(t, 0, numTruncated) } func TestWebhookRedactedURL(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() secret := "secret" notifier, err := New( &config.WebhookConfig{ URL: config.SecretTemplateURL(u.String()), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret) } func TestWebhookReadingURLFromFile(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() f, err := os.CreateTemp(t.TempDir(), "webhook_url") require.NoError(t, err, "creating temp file failed") _, err = f.WriteString(u.String() + "\n") require.NoError(t, err, "writing to temp file failed") notifier, err := New( &config.WebhookConfig{ URLFile: f.Name(), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String()) } func TestWebhookURLTemplating(t *testing.T) { var calledURL string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calledURL = r.URL.Path w.WriteHeader(http.StatusOK) })) defer srv.Close() tests := []struct { name string url string groupLabels model.LabelSet alertLabels model.LabelSet expectError bool expectedErrMsg string expectedPath string }{ { name: "templating with alert labels", url: srv.URL + "/{{ .GroupLabels.alertname }}/{{ .CommonLabels.severity }}", groupLabels: model.LabelSet{"alertname": "TestAlert"}, alertLabels: model.LabelSet{"alertname": "TestAlert", "severity": "critical"}, expectError: false, expectedPath: "/TestAlert/critical", }, { name: "invalid template field", url: srv.URL + "/{{ .InvalidField }}", groupLabels: model.LabelSet{"alertname": "TestAlert"}, alertLabels: model.LabelSet{"alertname": "TestAlert"}, expectError: true, expectedErrMsg: "failed to template webhook URL", }, { name: "template renders to empty string", url: "{{ if .CommonLabels.nonexistent }}http://example.com{{ end }}", groupLabels: model.LabelSet{"alertname": "TestAlert"}, alertLabels: model.LabelSet{"alertname": "TestAlert"}, expectError: true, expectedErrMsg: "webhook URL is empty after templating", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { calledURL = "" // Reset for each test notifier, err := New( &config.WebhookConfig{ URL: config.SecretTemplateURL(tc.url), HTTPConfig: &commoncfg.HTTPClientConfig{}, }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) ctx := context.Background() ctx = notify.WithGroupKey(ctx, "test-group") if tc.groupLabels != nil { ctx = notify.WithGroupLabels(ctx, tc.groupLabels) } alerts := []*types.Alert{ { Alert: model.Alert{ Labels: tc.alertLabels, StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour), }, }, } _, err = notifier.Notify(ctx, alerts...) if tc.expectError { require.Error(t, err) require.Contains(t, err.Error(), tc.expectedErrMsg) } else { require.NoError(t, err) require.Equal(t, tc.expectedPath, calledURL) } }) } } type roundTripFunc func(req *http.Request) *http.Response func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req), nil } // TestWebhookDefaultPayload tests that the default payload sent by the webhook notifier matches // the behaviour before introducing templating. func TestWebhookDefaultPayload(t *testing.T) { var capturedPayload []byte mockTransport := roundTripFunc(func(req *http.Request) *http.Response { var err error capturedPayload, err = io.ReadAll(req.Body) if err != nil { t.Fatal(err) } return &http.Response{ StatusCode: http.StatusOK, Body: http.NoBody, } }) u, err := url.Parse("http://localhost") require.NoError(t, err) conf := &config.WebhookConfig{ URL: config.SecretTemplateURL(u.String()), HTTPConfig: &commoncfg.HTTPClientConfig{}, } alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "TestAlert"}, Annotations: model.LabelSet{"summary": "Test summary"}, StartsAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC), GeneratorURL: "http://generator.url", }, }, } tmpl := test.CreateTmpl(t) ctx := notify.WithGroupKey(context.Background(), "{}:{alertname=\"test1\"}") ctx = notify.WithReceiverName(ctx, "test_receiver") data := notify.GetTemplateData(ctx, tmpl, alerts, promslog.NewNopLogger()) msg := &Message{ Version: "4", Data: data, GroupKey: "{}:{alertname=\"test1\"}", } var buf bytes.Buffer json.NewEncoder(&buf).Encode(msg) n, err := New(conf, tmpl, promslog.NewNopLogger()) require.NoError(t, err) n.client.Transport = mockTransport _, err = n.Notify(ctx, alerts...) require.NoError(t, err) require.NotEmpty(t, capturedPayload) require.JSONEq(t, buf.String(), string(capturedPayload)) } func TestWebhookCustomPayload(t *testing.T) { var capturedPayload []byte mockTransport := roundTripFunc(func(req *http.Request) *http.Response { var err error capturedPayload, err = io.ReadAll(req.Body) if err != nil { t.Fatal(err) } return &http.Response{ StatusCode: http.StatusOK, Body: http.NoBody, } }) u, err := url.Parse("http://localhost") require.NoError(t, err) conf := &config.WebhookConfig{ URL: config.SecretTemplateURL(u.String()), HTTPConfig: &commoncfg.HTTPClientConfig{}, Payload: map[string]any{ "custom": `some custom content`, "commonLabels": "{{ .CommonLabels | toJson }}", }, } alerts := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{"alertname": "TestAlert"}, Annotations: model.LabelSet{"summary": "Test summary"}, StartsAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), EndsAt: time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC), GeneratorURL: "http://generator.url", }, }, } tmpl := test.CreateTmpl(t) ctx := notify.WithGroupKey(context.Background(), "{}:{alertname=\"test1\"}") ctx = notify.WithReceiverName(ctx, "test_receiver") msg := map[string]any{ "custom": `some custom content`, "commonLabels": map[string]string{"alertname": "TestAlert"}, } var buf bytes.Buffer json.NewEncoder(&buf).Encode(msg) n, err := New(conf, tmpl, promslog.NewNopLogger()) require.NoError(t, err) n.client.Transport = mockTransport _, err = n.Notify(ctx, alerts...) require.NoError(t, err) require.NotEmpty(t, capturedPayload) require.JSONEq(t, buf.String(), string(capturedPayload)) } ================================================ FILE: notify/wechat/wechat.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package wechat import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "os" "strings" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/template" "github.com/prometheus/alertmanager/types" ) // Notifier implements a Notifier for wechat notifications. type Notifier struct { conf *config.WechatConfig tmpl *template.Template logger *slog.Logger client *http.Client accessToken string accessTokenAt time.Time } // token is the AccessToken with corpid and corpsecret. type token struct { AccessToken string `json:"access_token"` } type weChatMessage struct { Text weChatMessageContent `yaml:"text,omitempty" json:"text,omitempty"` ToUser string `yaml:"touser,omitempty" json:"touser,omitempty"` ToParty string `yaml:"toparty,omitempty" json:"toparty,omitempty"` Totag string `yaml:"totag,omitempty" json:"totag,omitempty"` AgentID string `yaml:"agentid,omitempty" json:"agentid,omitempty"` Safe string `yaml:"safe,omitempty" json:"safe,omitempty"` Type string `yaml:"msgtype,omitempty" json:"msgtype,omitempty"` Markdown weChatMessageContent `yaml:"markdown,omitempty" json:"markdown,omitempty"` } type weChatMessageContent struct { Content string `json:"content"` } type weChatResponse struct { Code int `json:"errcode"` Error string `json:"errmsg"` } // New returns a new Wechat notifier. func New(c *config.WechatConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, "wechat", httpOpts...) if err != nil { return nil, err } return &Notifier{conf: c, tmpl: t, logger: l, client: client}, nil } // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { key, err := notify.ExtractGroupKey(ctx) if err != nil { return false, err } logger := n.logger.With("group_key", key) logger.Debug("extracted group key") data := notify.GetTemplateData(ctx, n.tmpl, as, logger) tmpl := notify.TmplText(n.tmpl, data, &err) if err != nil { return false, err } // Refresh AccessToken over 2 hours if n.accessToken == "" || time.Since(n.accessTokenAt) > 2*time.Hour { parameters := url.Values{} apiSecret, err := n.getApiSecret() if err != nil { return false, err } parameters.Add("corpsecret", tmpl(apiSecret)) parameters.Add("corpid", tmpl(string(n.conf.CorpID))) if err != nil { return false, fmt.Errorf("templating error: %w", err) } u := n.conf.APIURL.Copy() u.Path += "gettoken" u.RawQuery = parameters.Encode() resp, err := notify.Get(ctx, n.client, u.String()) if err != nil { return true, notify.RedactURL(err) } defer notify.Drain(resp) var wechatToken token if err := json.NewDecoder(resp.Body).Decode(&wechatToken); err != nil { return false, err } if wechatToken.AccessToken == "" { return false, fmt.Errorf("invalid APISecret for CorpID: %s", n.conf.CorpID) } // Cache accessToken n.accessToken = wechatToken.AccessToken n.accessTokenAt = time.Now() } msg := &weChatMessage{ ToUser: tmpl(n.conf.ToUser), ToParty: tmpl(n.conf.ToParty), Totag: tmpl(n.conf.ToTag), AgentID: tmpl(n.conf.AgentID), Type: n.conf.MessageType, Safe: "0", } if msg.Type == "markdown" { msg.Markdown = weChatMessageContent{ Content: tmpl(n.conf.Message), } } else { msg.Text = weChatMessageContent{ Content: tmpl(n.conf.Message), } } if err != nil { return false, fmt.Errorf("templating error: %w", err) } var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(msg); err != nil { return false, err } postMessageURL := n.conf.APIURL.Copy() postMessageURL.Path += "message/send" q := postMessageURL.Query() q.Set("access_token", n.accessToken) postMessageURL.RawQuery = q.Encode() resp, err := notify.PostJSON(ctx, n.client, postMessageURL.String(), &buf) if err != nil { return true, notify.RedactURL(err) } defer notify.Drain(resp) if resp.StatusCode != 200 { return true, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), fmt.Errorf("unexpected status code %v", resp.StatusCode)) } body, err := io.ReadAll(resp.Body) if err != nil { return true, err } logger.Debug(string(body)) var weResp weChatResponse if err := json.Unmarshal(body, &weResp); err != nil { return true, err } // https://work.weixin.qq.com/api/doc#10649 if weResp.Code == 0 { return false, nil } // AccessToken is expired if weResp.Code == 42001 { n.accessToken = "" return true, errors.New(weResp.Error) } return false, errors.New(weResp.Error) } func (n *Notifier) getApiSecret() (string, error) { if len(n.conf.APISecretFile) > 0 { content, err := os.ReadFile(n.conf.APISecretFile) if err != nil { return "", err } return strings.TrimSpace(string(content)), nil } return string(n.conf.APISecret), nil } ================================================ FILE: notify/wechat/wechat_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package wechat import ( "fmt" "net/http" "os" "testing" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" amcommoncfg "github.com/prometheus/alertmanager/config/common" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/notify/test" ) func TestWechatRedactedURLOnInitialAuthentication(t *testing.T) { ctx, u, fn := test.GetContextWithCancelingURL() defer fn() secret := "secret_key" notifier, err := New( &config.WechatConfig{ APIURL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, CorpID: "corpid", APISecret: commoncfg.Secret(secret), }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret) } func TestWechatRedactedURLOnNotify(t *testing.T) { secret, token := "secret", "token" ctx, u, fn := test.GetContextWithCancelingURL(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"access_token":"%s"}`, token) }) defer fn() notifier, err := New( &config.WechatConfig{ APIURL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, CorpID: "corpid", APISecret: commoncfg.Secret(secret), }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret, token) } func TestWechatMessageTypeSelector(t *testing.T) { secret, token := "secret", "token" ctx, u, fn := test.GetContextWithCancelingURL(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, `{"access_token":"%s"}`, token) }) defer fn() notifier, err := New( &config.WechatConfig{ APIURL: &amcommoncfg.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, CorpID: "corpid", APISecret: commoncfg.Secret(secret), MessageType: "markdown", }, test.CreateTmpl(t), promslog.NewNopLogger(), ) require.NoError(t, err) test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret, token) } func TestGetApiSecretFromSecret(t *testing.T) { n := &Notifier{conf: &config.WechatConfig{APISecret: commoncfg.Secret("shhh")}} s, err := n.getApiSecret() require.NoError(t, err) require.Equal(t, "shhh", s) } func TestGetApiSecretFromFile(t *testing.T) { tmpFile, err := os.CreateTemp(t.TempDir(), "wechat-secret-*") require.NoError(t, err) secretContent := "file-secret\n" _, err = tmpFile.WriteString(secretContent) require.NoError(t, err) require.NoError(t, tmpFile.Close()) n := &Notifier{conf: &config.WechatConfig{APISecretFile: tmpFile.Name()}} s, err := n.getApiSecret() require.NoError(t, err) require.Equal(t, "file-secret", s) } func TestGetApiSecretFromMissingFile(t *testing.T) { n := &Notifier{conf: &config.WechatConfig{APISecretFile: "/non/existent/wechat-secret.txt"}} s, err := n.getApiSecret() var pathErr *os.PathError require.ErrorAs(t, err, &pathErr) require.Equal(t, "/non/existent/wechat-secret.txt", pathErr.Path) require.ErrorIs(t, err, os.ErrNotExist) require.Empty(t, s) } ================================================ FILE: pkg/README.md ================================================ The `pkg` directory is deprecated. Please do not add new packages to this directory. Existing packages will be moved elsewhere eventually. ================================================ FILE: pkg/labels/matcher.go ================================================ // Copyright 2017 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package labels import ( "bytes" "encoding/json" "fmt" "regexp" "strconv" "strings" "unicode" "github.com/prometheus/common/model" ) // MatchType is an enum for label matching types. type MatchType int // Possible MatchTypes. const ( MatchEqual MatchType = iota MatchNotEqual MatchRegexp MatchNotRegexp ) func (m MatchType) String() string { typeToStr := map[MatchType]string{ MatchEqual: "=", MatchNotEqual: "!=", MatchRegexp: "=~", MatchNotRegexp: "!~", } if str, ok := typeToStr[m]; ok { return str } panic("unknown match type") } // Matcher models the matching of a label. type Matcher struct { Type MatchType Name string Value string re *regexp.Regexp } // NewMatcher returns a matcher object. func NewMatcher(t MatchType, n, v string) (*Matcher, error) { m := &Matcher{ Type: t, Name: n, Value: v, } if t == MatchRegexp || t == MatchNotRegexp { re, err := regexp.Compile("^(?:" + v + ")$") if err != nil { return nil, err } m.re = re } return m, nil } func (m *Matcher) String() string { if strings.ContainsFunc(m.Name, isReserved) { return fmt.Sprintf(`%s%s%s`, strconv.Quote(m.Name), m.Type, strconv.Quote(m.Value)) } return fmt.Sprintf(`%s%s"%s"`, m.Name, m.Type, openMetricsEscape(m.Value)) } // Matches returns whether the matcher matches the given string value. func (m *Matcher) Matches(s string) bool { switch m.Type { case MatchEqual: return s == m.Value case MatchNotEqual: return s != m.Value case MatchRegexp: return m.re.MatchString(s) case MatchNotRegexp: return !m.re.MatchString(s) } panic("labels.Matcher.Matches: invalid match type") } type apiV1Matcher struct { Name string `json:"name"` Value string `json:"value"` IsRegex bool `json:"isRegex"` IsEqual bool `json:"isEqual"` } // MarshalJSON retains backwards compatibility with types.Matcher for the v1 API. func (m Matcher) MarshalJSON() ([]byte, error) { return json.Marshal(apiV1Matcher{ Name: m.Name, Value: m.Value, IsRegex: m.Type == MatchRegexp || m.Type == MatchNotRegexp, IsEqual: m.Type == MatchRegexp || m.Type == MatchEqual, }) } func (m *Matcher) UnmarshalJSON(data []byte) error { v1m := apiV1Matcher{ IsEqual: true, } if err := json.Unmarshal(data, &v1m); err != nil { return err } var t MatchType switch { case v1m.IsEqual && !v1m.IsRegex: t = MatchEqual case !v1m.IsEqual && !v1m.IsRegex: t = MatchNotEqual case v1m.IsEqual && v1m.IsRegex: t = MatchRegexp case !v1m.IsEqual && v1m.IsRegex: t = MatchNotRegexp } matcher, err := NewMatcher(t, v1m.Name, v1m.Value) if err != nil { return err } *m = *matcher return nil } // openMetricsEscape is similar to the usual string escaping, but more // restricted. It merely replaces a new-line character with '\n', a double-quote // character with '\"', and a backslash with '\\', which is the escaping used by // OpenMetrics. func openMetricsEscape(s string) string { r := strings.NewReplacer( `\`, `\\`, "\n", `\n`, `"`, `\"`, ) return r.Replace(s) } // Matchers is a slice of Matchers that is sortable, implements Stringer, and // provides a Matches method to match a LabelSet against all Matchers in the // slice. Note that some users of Matchers might require it to be sorted. type Matchers []*Matcher func (ms Matchers) Len() int { return len(ms) } func (ms Matchers) Swap(i, j int) { ms[i], ms[j] = ms[j], ms[i] } func (ms Matchers) Less(i, j int) bool { if ms[i].Name > ms[j].Name { return false } if ms[i].Name < ms[j].Name { return true } if ms[i].Value > ms[j].Value { return false } if ms[i].Value < ms[j].Value { return true } return ms[i].Type < ms[j].Type } // Matches checks whether all matchers are fulfilled against the given label set. func (ms Matchers) Matches(lset model.LabelSet) bool { for _, m := range ms { if !m.Matches(string(lset[model.LabelName(m.Name)])) { return false } } return true } func (ms Matchers) String() string { var buf bytes.Buffer buf.WriteByte('{') for i, m := range ms { if i > 0 { buf.WriteByte(',') } buf.WriteString(m.String()) } buf.WriteByte('}') return buf.String() } // MatcherSet is a slice of Matchers pointers that implements OR logic across // multiple matcher sets. At least one matcher set must match for the MatcherSet // to match. type MatcherSet []*Matchers // Matches checks whether at least one matcher set is fulfilled against the given // label set (OR logic across matcher sets, AND logic within each set). func (ms MatcherSet) Matches(lset model.LabelSet) bool { for _, matchers := range ms { if (*matchers).Matches(lset) { return true } } return false } // This is copied from matcher/parse/lexer.go. It will be removed when // the transition window from classic matchers to UTF-8 matchers is complete, // as then we can use double quotes when printing the label name for all // matchers. Until then, the classic parser does not understand double quotes // around the label name, so we use this function as a heuristic to tell if // the matcher was parsed with the UTF-8 parser or the classic parser. func isReserved(r rune) bool { return unicode.IsSpace(r) || strings.ContainsRune("{}!=~,\\\"'`", r) } ================================================ FILE: pkg/labels/matcher_test.go ================================================ // Copyright 2017 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package labels import ( "encoding/json" "testing" ) func mustNewMatcher(t *testing.T, mType MatchType, value string) *Matcher { m, err := NewMatcher(mType, "", value) if err != nil { t.Fatal(err) } return m } func TestMatcher(t *testing.T) { tests := []struct { matcher *Matcher value string match bool }{ { matcher: mustNewMatcher(t, MatchEqual, "bar"), value: "bar", match: true, }, { matcher: mustNewMatcher(t, MatchEqual, "bar"), value: "foo-bar", match: false, }, { matcher: mustNewMatcher(t, MatchNotEqual, "bar"), value: "bar", match: false, }, { matcher: mustNewMatcher(t, MatchNotEqual, "bar"), value: "foo-bar", match: true, }, { matcher: mustNewMatcher(t, MatchRegexp, "bar"), value: "bar", match: true, }, { matcher: mustNewMatcher(t, MatchRegexp, "bar"), value: "foo-bar", match: false, }, { matcher: mustNewMatcher(t, MatchRegexp, ".*bar"), value: "foo-bar", match: true, }, { matcher: mustNewMatcher(t, MatchNotRegexp, "bar"), value: "bar", match: false, }, { matcher: mustNewMatcher(t, MatchNotRegexp, "bar"), value: "foo-bar", match: true, }, { matcher: mustNewMatcher(t, MatchNotRegexp, ".*bar"), value: "foo-bar", match: false, }, { matcher: mustNewMatcher(t, MatchRegexp, `foo.bar`), value: "foo-bar", match: true, }, { matcher: mustNewMatcher(t, MatchRegexp, `foo\.bar`), value: "foo-bar", match: false, }, { matcher: mustNewMatcher(t, MatchRegexp, `foo\.bar`), value: "foo.bar", match: true, }, { matcher: mustNewMatcher(t, MatchEqual, "foo\nbar"), value: "foo\nbar", match: true, }, { matcher: mustNewMatcher(t, MatchRegexp, "foo.bar"), value: "foo\nbar", match: false, }, { matcher: mustNewMatcher(t, MatchRegexp, "(?s)foo.bar"), value: "foo\nbar", match: true, }, { matcher: mustNewMatcher(t, MatchEqual, "~!=\""), value: "~!=\"", match: true, }, } for _, test := range tests { if test.matcher.Matches(test.value) != test.match { t.Fatalf("Unexpected match result for matcher %v and value %q; want %v, got %v", test.matcher, test.value, test.match, !test.match) } } } func TestMatcherString(t *testing.T) { tests := []struct { name string op MatchType value string want string }{ { name: `foo`, op: MatchEqual, value: `bar`, want: `foo="bar"`, }, { name: `foo`, op: MatchNotEqual, value: `bar`, want: `foo!="bar"`, }, { name: `foo`, op: MatchRegexp, value: `bar`, want: `foo=~"bar"`, }, { name: `foo`, op: MatchNotRegexp, value: `bar`, want: `foo!~"bar"`, }, { name: `foo`, op: MatchEqual, value: `back\slash`, want: `foo="back\\slash"`, }, { name: `foo`, op: MatchEqual, value: `double"quote`, want: `foo="double\"quote"`, }, { name: `foo`, op: MatchEqual, value: `new line`, want: `foo="new\nline"`, }, { name: `foo`, op: MatchEqual, value: `tab stop`, want: `foo="tab stop"`, }, { name: `foo`, op: MatchEqual, value: `🙂`, want: `foo="🙂"`, }, { name: `foo!`, op: MatchNotEqual, value: `bar`, want: `"foo!"!="bar"`, }, { name: `foo🙂`, op: MatchEqual, value: `bar`, want: `foo🙂="bar"`, }, { name: `foo bar`, op: MatchEqual, value: `baz`, want: `"foo bar"="baz"`, }, } for _, test := range tests { m, err := NewMatcher(test.op, test.name, test.value) if err != nil { t.Fatal(err) } if got := m.String(); got != test.want { t.Errorf("Unexpected string representation of matcher; want %v, got %v", test.want, got) } } } func TestMatcherJSONMarshal(t *testing.T) { tests := []struct { name string op MatchType value string want string }{ { name: `foo`, op: MatchEqual, value: `bar`, want: `{"name":"foo","value":"bar","isRegex":false,"isEqual":true}`, }, { name: `foo`, op: MatchNotEqual, value: `bar`, want: `{"name":"foo","value":"bar","isRegex":false,"isEqual":false}`, }, { name: `foo`, op: MatchRegexp, value: `bar`, want: `{"name":"foo","value":"bar","isRegex":true,"isEqual":true}`, }, { name: `foo`, op: MatchNotRegexp, value: `bar`, want: `{"name":"foo","value":"bar","isRegex":true,"isEqual":false}`, }, } cmp := func(m1, m2 Matcher) bool { return m1.Name == m2.Name && m1.Value == m2.Value && m1.Type == m2.Type } for _, test := range tests { m, err := NewMatcher(test.op, test.name, test.value) if err != nil { t.Fatal(err) } b, err := json.Marshal(m) if err != nil { t.Fatal(err) } if got := string(b); got != test.want { t.Errorf("Unexpected JSON representation of matcher:\nwant:\t%v\ngot:\t%v", test.want, got) } var m2 Matcher if err := json.Unmarshal(b, &m2); err != nil { t.Fatal(err) } if !cmp(*m, m2) { t.Errorf("Doing Marshal and Unmarshal seems to be losing data; before %#v, after %#v", m, m2) } } } func TestMatcherJSONUnmarshal(t *testing.T) { tests := []struct { name string op MatchType value string want string }{ { name: "foo", op: MatchEqual, value: "bar", want: `{"name":"foo","value":"bar","isRegex":false}`, }, { name: `foo`, op: MatchEqual, value: `bar`, want: `{"name":"foo","value":"bar","isRegex":false,"isEqual":true}`, }, { name: `foo`, op: MatchNotEqual, value: `bar`, want: `{"name":"foo","value":"bar","isRegex":false,"isEqual":false}`, }, { name: `foo`, op: MatchRegexp, value: `bar`, want: `{"name":"foo","value":"bar","isRegex":true,"isEqual":true}`, }, { name: `foo`, op: MatchNotRegexp, value: `bar`, want: `{"name":"foo","value":"bar","isRegex":true,"isEqual":false}`, }, } cmp := func(m1, m2 Matcher) bool { return m1.Name == m2.Name && m1.Value == m2.Value && m1.Type == m2.Type } for _, test := range tests { var m Matcher if err := json.Unmarshal([]byte(test.want), &m); err != nil { t.Fatal(err) } m2, err := NewMatcher(test.op, test.name, test.value) if err != nil { t.Fatal(err) } if !cmp(m, *m2) { t.Errorf("Unmarshaling seems to be producing unexpected matchers; got %#v, expected %#v", m, m2) } } } ================================================ FILE: pkg/labels/parse.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package labels import ( "fmt" "regexp" "strings" "unicode/utf8" ) var ( // '=~' has to come before '=' because otherwise only the '=' // will be consumed, and the '~' will be part of the 3rd token. re = regexp.MustCompile(`^\s*([a-zA-Z_:][a-zA-Z0-9_:]*)\s*(=~|=|!=|!~)\s*((?s).*?)\s*$`) typeMap = map[string]MatchType{ "=": MatchEqual, "!=": MatchNotEqual, "=~": MatchRegexp, "!~": MatchNotRegexp, } ) // ParseMatchers parses a comma-separated list of Matchers. A leading '{' and/or // a trailing '}' is optional and will be trimmed before further // parsing. Individual Matchers are separated by commas outside of quoted parts // of the input string. Those commas may be surrounded by whitespace. Parts of the // string inside unescaped double quotes ('"…"') are considered quoted (and // commas don't act as separators there). If double quotes are escaped with a // single backslash ('\"'), they are ignored for the purpose of identifying // quoted parts of the input string. If the input string, after trimming the // optional trailing '}', ends with a comma, followed by optional whitespace, // this comma and whitespace will be trimmed. // // Examples for valid input strings: // // {foo = "bar", dings != "bums", } // foo=bar,dings!=bums // foo=bar, dings!=bums // {quote="She said: \"Hi, ladies! That's gender-neutral…\""} // statuscode=~"5.." // // See ParseMatcher for details on how an individual Matcher is parsed. func ParseMatchers(s string) ([]*Matcher, error) { matchers := []*Matcher{} s = strings.TrimPrefix(s, "{") s = strings.TrimSuffix(s, "}") var ( insideQuotes bool escaped bool token strings.Builder tokens []string ) for _, r := range s { switch r { case ',': if !insideQuotes { tokens = append(tokens, token.String()) token.Reset() continue } case '"': if !escaped { insideQuotes = !insideQuotes } else { escaped = false } case '\\': escaped = !escaped default: escaped = false } token.WriteRune(r) } if s := strings.TrimSpace(token.String()); s != "" { tokens = append(tokens, s) } for _, token := range tokens { m, err := ParseMatcher(token) if err != nil { return nil, err } matchers = append(matchers, m) } return matchers, nil } // ParseMatcher parses a matcher with a syntax inspired by PromQL and // OpenMetrics. This syntax is convenient to describe filters and selectors in // UIs and config files. To support the interactive nature of the use cases, the // parser is in various aspects fairly tolerant. // // The syntax of a matcher consists of three tokens: (1) A valid Prometheus // label name. (2) One of '=', '!=', '=~', or '!~', with the same meaning as // known from PromQL selectors. (3) A UTF-8 string, which may be enclosed in // double quotes. Before or after each token, there may be any amount of // whitespace, which will be discarded. The 3rd token may be the empty // string. Within the 3rd token, OpenMetrics escaping rules apply: '\"' for a // double-quote, '\n' for a line feed, '\\' for a literal backslash. Unescaped // '"' must not occur inside the 3rd token (only as the 1st or last // character). However, literal line feed characters are tolerated, as are // single '\' characters not followed by '\', 'n', or '"'. They act as a literal // backslash in that case. func ParseMatcher(s string) (_ *Matcher, err error) { ms := re.FindStringSubmatch(s) if len(ms) == 0 { return nil, fmt.Errorf("bad matcher format: %s", s) } var ( rawValue = ms[3] value strings.Builder escaped bool expectTrailingQuote bool ) if after, ok := strings.CutPrefix(rawValue, "\""); ok { rawValue = after expectTrailingQuote = true } if !utf8.ValidString(rawValue) { return nil, fmt.Errorf("matcher value not valid UTF-8: %s", ms[3]) } // Unescape the rawValue. for i, r := range rawValue { if escaped { escaped = false switch r { case 'n': value.WriteByte('\n') case '"', '\\': value.WriteRune(r) default: // This was a spurious escape, so treat the '\' as literal. value.WriteByte('\\') value.WriteRune(r) } continue } switch r { case '\\': if i < len(rawValue)-1 { escaped = true continue } // '\' encountered as last byte. Treat it as literal. value.WriteByte('\\') case '"': if !expectTrailingQuote || i < len(rawValue)-1 { return nil, fmt.Errorf("matcher value contains unescaped double quote: %s", ms[3]) } expectTrailingQuote = false default: value.WriteRune(r) } } if expectTrailingQuote { return nil, fmt.Errorf("matcher value contains unescaped double quote: %s", ms[3]) } return NewMatcher(typeMap[ms[2]], ms[1], value.String()) } ================================================ FILE: pkg/labels/parse_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package labels import ( "reflect" "testing" ) func TestMatchers(t *testing.T) { for _, tc := range []struct { input string want []*Matcher err string }{ { input: `{}`, want: make([]*Matcher, 0), }, { input: `,`, err: "bad matcher format: ", }, { input: `{,}`, err: "bad matcher format: ", }, { input: `{foo='}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "'") return append(ms, m) }(), }, { input: "{foo=`}", want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "`") return append(ms, m) }(), }, { input: "{foo=\\\"}", want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "\"") return append(ms, m) }(), }, { input: `{foo=bar}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") return append(ms, m) }(), }, { input: `{foo="bar"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") return append(ms, m) }(), }, { input: `{foo=~bar.*}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchRegexp, "foo", "bar.*") return append(ms, m) }(), }, { input: `{foo=~"bar.*"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchRegexp, "foo", "bar.*") return append(ms, m) }(), }, { input: `{foo!=bar}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchNotEqual, "foo", "bar") return append(ms, m) }(), }, { input: `{foo!="bar"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchNotEqual, "foo", "bar") return append(ms, m) }(), }, { input: `{foo!~bar.*}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchNotRegexp, "foo", "bar.*") return append(ms, m) }(), }, { input: `{foo!~"bar.*"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchNotRegexp, "foo", "bar.*") return append(ms, m) }(), }, { input: `{foo="bar", baz!="quux"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") m2, _ := NewMatcher(MatchNotEqual, "baz", "quux") return append(ms, m, m2) }(), }, { input: `{foo="bar", baz!~"quux.*"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") m2, _ := NewMatcher(MatchNotRegexp, "baz", "quux.*") return append(ms, m, m2) }(), }, { input: `{foo="bar",baz!~".*quux", derp="wat"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") m2, _ := NewMatcher(MatchNotRegexp, "baz", ".*quux") m3, _ := NewMatcher(MatchEqual, "derp", "wat") return append(ms, m, m2, m3) }(), }, { input: `{foo="bar", baz!="quux", derp="wat"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") m2, _ := NewMatcher(MatchNotEqual, "baz", "quux") m3, _ := NewMatcher(MatchEqual, "derp", "wat") return append(ms, m, m2, m3) }(), }, { input: `{foo="bar", baz!~".*quux.*", derp="wat"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") m2, _ := NewMatcher(MatchNotRegexp, "baz", ".*quux.*") m3, _ := NewMatcher(MatchEqual, "derp", "wat") return append(ms, m, m2, m3) }(), }, { input: `{foo="bar", instance=~"some-api.*"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") m2, _ := NewMatcher(MatchRegexp, "instance", "some-api.*") return append(ms, m, m2) }(), }, { input: `{foo=""}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "") return append(ms, m) }(), }, { input: `{foo="bar,quux", job="job1"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar,quux") m2, _ := NewMatcher(MatchEqual, "job", "job1") return append(ms, m, m2) }(), }, { input: `{foo = "bar", dings != "bums", }`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") m2, _ := NewMatcher(MatchNotEqual, "dings", "bums") return append(ms, m, m2) }(), }, { input: `foo=bar,dings!=bums`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar") m2, _ := NewMatcher(MatchNotEqual, "dings", "bums") return append(ms, m, m2) }(), }, { input: `{quote="She said: \"Hi, ladies! That's gender-neutral…\""}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "quote", `She said: "Hi, ladies! That's gender-neutral…"`) return append(ms, m) }(), }, { input: `statuscode=~"5.."`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchRegexp, "statuscode", "5..") return append(ms, m) }(), }, { input: `tricky=~~~`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchRegexp, "tricky", "~~") return append(ms, m) }(), }, { input: `trickier==\\=\=\"`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "trickier", `=\=\="`) return append(ms, m) }(), }, { input: `contains_quote != "\"" , contains_comma !~ "foo,bar" , `, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchNotEqual, "contains_quote", `"`) m2, _ := NewMatcher(MatchNotRegexp, "contains_comma", "foo,bar") return append(ms, m, m2) }(), }, { input: `{foo=bar}}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar}") return append(ms, m) }(), }, { input: `{foo=bar}},}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar}}") return append(ms, m) }(), }, { input: `{foo=,bar=}}`, want: func() []*Matcher { ms := []*Matcher{} m1, _ := NewMatcher(MatchEqual, "foo", "") m2, _ := NewMatcher(MatchEqual, "bar", "}") return append(ms, m1, m2) }(), }, { input: `{foo=bar\t}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar\\t") return append(ms, m) }(), }, { input: `{foo=bar\n}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar\n") return append(ms, m) }(), }, { input: `{foo=bar\}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar\\") return append(ms, m) }(), }, { input: `{foo=bar\\}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar\\") return append(ms, m) }(), }, { input: `{foo=bar\"}`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "bar\"") return append(ms, m) }(), }, { input: `job=`, want: func() []*Matcher { m, _ := NewMatcher(MatchEqual, "job", "") return []*Matcher{m} }(), }, { input: `job="value`, err: `matcher value contains unescaped double quote: "value`, }, { input: `job=value"`, err: `matcher value contains unescaped double quote: value"`, }, { input: `trickier==\\=\=\""`, err: `matcher value contains unescaped double quote: =\\=\=\""`, }, { input: `contains_unescaped_quote = foo"bar`, err: `matcher value contains unescaped double quote: foo"bar`, }, { input: `{invalid-name = "valid label"}`, err: `bad matcher format: invalid-name = "valid label"`, }, { input: `{foo=~"invalid[regexp"}`, err: "error parsing regexp: missing closing ]: `[regexp)$`", }, // Double escaped strings. { input: `"{foo=\"bar"}`, err: `bad matcher format: "{foo=\"bar"`, }, { input: `"foo=\"bar"`, err: `bad matcher format: "foo=\"bar"`, }, { input: `"foo=\"bar\""`, err: `bad matcher format: "foo=\"bar\""`, }, { input: `"foo=\"bar\"`, err: `bad matcher format: "foo=\"bar\"`, }, { input: `"{foo=\"bar\"}"`, err: `bad matcher format: "{foo=\"bar\"}"`, }, { input: `"foo="bar""`, err: `bad matcher format: "foo="bar""`, }, { input: `{{foo=`, err: `bad matcher format: {foo=`, }, { input: `{foo=`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "") return append(ms, m) }(), }, { input: `{foo=}b`, want: func() []*Matcher { ms := []*Matcher{} m, _ := NewMatcher(MatchEqual, "foo", "}b") return append(ms, m) }(), }, } { t.Run(tc.input, func(t *testing.T) { got, err := ParseMatchers(tc.input) if err != nil && tc.err == "" { t.Fatalf("got error where none expected: %v", err) } if err == nil && tc.err != "" { t.Fatalf("expected error but got none: %v", tc.err) } if err != nil && err.Error() != tc.err { t.Fatalf("error not equal:\ngot %v\nwant %v", err, tc.err) } if !reflect.DeepEqual(got, tc.want) { t.Fatalf("labels not equal:\ngot %v\nwant %v", got, tc.want) } }) } } ================================================ FILE: pkg/modtimevfs/modtimevfs.go ================================================ // Copyright 2018 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package modtimevfs implements a virtual file system that returns a fixed // modification time for all files and directories. package modtimevfs import ( "net/http" "os" "time" ) type timefs struct { fs http.FileSystem t time.Time } // New returns a file system that returns constant modification time for all files. func New(fs http.FileSystem, t time.Time) http.FileSystem { return &timefs{fs: fs, t: t} } type file struct { http.File os.FileInfo t time.Time } func (t *timefs) Open(name string) (http.File, error) { f, err := t.fs.Open(name) if err != nil { return f, err } defer func() { if err != nil { f.Close() } }() fstat, err := f.Stat() if err != nil { return nil, err } return &file{f, fstat, t.t}, nil } // Stat implements the http.File interface. func (f *file) Stat() (os.FileInfo, error) { return f, nil } // ModTime implements the os.FileInfo interface. func (f *file) ModTime() time.Time { return f.t } ================================================ FILE: provider/mem/mem.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package mem import ( "context" "errors" "log/slog" "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/common/model" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/provider" "github.com/prometheus/alertmanager/store" "github.com/prometheus/alertmanager/types" ) const alertChannelLength = 200 var tracer = otel.Tracer("github.com/prometheus/alertmanager/provider/mem") // Alerts gives access to a set of alerts. All methods are goroutine-safe. type Alerts struct { cancel context.CancelFunc mtx sync.Mutex alerts *store.Alerts marker types.AlertMarker listeners map[int]listeningAlerts next int callback AlertStoreCallback logger *slog.Logger propagator propagation.TextMapPropagator flagger featurecontrol.Flagger alertsLimit prometheus.Gauge alertsLimitedTotal *prometheus.CounterVec subscriberChannelWrites *prometheus.CounterVec } type AlertStoreCallback interface { // PreStore is called before alert is stored into the store. If this method returns error, // alert is not stored. // Existing flag indicates whether alert has existed before (and is only updated) or not. // If alert has existed before, then alert passed to PreStore is result of merging existing alert with new alert. PreStore(alert *types.Alert, existing bool) error // PostStore is called after alert has been put into store. PostStore(alert *types.Alert, existing bool) // PostDelete is called after alert have been removed from the store due to alert garbage collection. PostDelete(alert *types.Alert) // PostGC is called after alerts have been removed from the store due to alert garbage collection. PostGC(fingerprints model.Fingerprints) } type listeningAlerts struct { name string alerts chan *provider.Alert done chan struct{} } func (a *Alerts) registerMetrics(r prometheus.Registerer) { r.MustRegister(&alertsCollector{alerts: a}) a.alertsLimit = promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_alerts_per_alert_limit", Help: "Current limit on number of alerts per alert name", }) labels := []string{} if a.flagger.EnableAlertNamesInMetrics() { labels = append(labels, "alertname") } a.alertsLimitedTotal = promauto.With(r).NewCounterVec( prometheus.CounterOpts{ Name: "alertmanager_alerts_limited_total", Help: "Total number of alerts that were dropped due to per alert name limit", }, labels, ) a.subscriberChannelWrites = promauto.With(r).NewCounterVec( prometheus.CounterOpts{ Name: "alertmanager_alerts_subscriber_channel_writes_total", Help: "Number of times alerts were written to subscriber channels", }, []string{"subscriber"}, ) } // NewAlerts returns a new alert provider. func NewAlerts( ctx context.Context, m types.AlertMarker, intervalGC time.Duration, perAlertNameLimit int, alertCallback AlertStoreCallback, l *slog.Logger, r prometheus.Registerer, flagger featurecontrol.Flagger, ) (*Alerts, error) { if alertCallback == nil { alertCallback = noopCallback{} } if perAlertNameLimit > 0 { l.Info("per alert name limit enabled", "limit", perAlertNameLimit) } if flagger == nil { flagger = featurecontrol.NoopFlags{} } ctx, cancel := context.WithCancel(ctx) a := &Alerts{ marker: m, alerts: store.NewAlerts().WithPerAlertLimit(perAlertNameLimit), cancel: cancel, listeners: map[int]listeningAlerts{}, next: 0, logger: l.With("component", "provider"), propagator: otel.GetTextMapPropagator(), callback: alertCallback, flagger: flagger, } if r != nil { a.registerMetrics(r) a.alertsLimit.Set(float64(perAlertNameLimit)) } go a.gcLoop(ctx, intervalGC) return a, nil } func (a *Alerts) gcLoop(ctx context.Context, interval time.Duration) { t := time.NewTicker(interval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: a.gc() } } } func (a *Alerts) gc() { a.gcListeners() // As we don't persist alerts, we no longer consider them after // they are resolved. Alerts waiting for resolved notifications are // held in memory in aggregation groups redundantly. deleted := a.gcAlerts() // If there are no deleted alerts, there is nothing to do. if len(deleted) == 0 { return } // Delete markers for deleted alerts. ff := make(model.Fingerprints, len(deleted)) for i, alert := range deleted { ff[i] = alert.Fingerprint() a.callback.PostDelete(alert) } a.marker.Delete(ff...) a.callback.PostGC(ff) } func (a *Alerts) gcAlerts() []*types.Alert { a.mtx.Lock() defer a.mtx.Unlock() return a.alerts.GC() } func (a *Alerts) gcListeners() { a.mtx.Lock() defer a.mtx.Unlock() for i, l := range a.listeners { select { case <-l.done: delete(a.listeners, i) close(l.alerts) default: // listener is not closed yet, hence proceed. } } } // Close the alert provider. func (a *Alerts) Close() { if a.cancel != nil { a.cancel() } } // Subscribe returns an iterator over active alerts that have not been // resolved and successfully notified about. // They are not guaranteed to be in chronological order. func (a *Alerts) Subscribe(name string) provider.AlertIterator { a.mtx.Lock() defer a.mtx.Unlock() var ( done = make(chan struct{}) alerts = a.alerts.List() ch = make(chan *provider.Alert, max(len(alerts), alertChannelLength)) ) for _, a := range alerts { ch <- &provider.Alert{ Header: map[string]string{}, Data: a, } } a.listeners[a.next] = listeningAlerts{name: name, alerts: ch, done: done} a.next++ return provider.NewAlertIterator(ch, done, nil) } func (a *Alerts) SlurpAndSubscribe(name string) ([]*types.Alert, provider.AlertIterator) { a.mtx.Lock() defer a.mtx.Unlock() var ( done = make(chan struct{}) alerts = a.alerts.List() ch = make(chan *provider.Alert, alertChannelLength) ) a.listeners[a.next] = listeningAlerts{name: name, alerts: ch, done: done} a.next++ return alerts, provider.NewAlertIterator(ch, done, nil) } // GetPending returns an iterator over all the alerts that have // pending notifications. func (a *Alerts) GetPending() provider.AlertIterator { var ( ch = make(chan *provider.Alert, alertChannelLength) done = make(chan struct{}) ) a.mtx.Lock() defer a.mtx.Unlock() alerts := a.alerts.List() go func() { defer close(ch) for _, a := range alerts { select { case ch <- &provider.Alert{ Header: map[string]string{}, Data: a, }: case <-done: return } } }() return provider.NewAlertIterator(ch, done, nil) } // Get returns the alert for a given fingerprint. func (a *Alerts) Get(fp model.Fingerprint) (*types.Alert, error) { a.mtx.Lock() defer a.mtx.Unlock() return a.alerts.Get(fp) } // Put adds the given alert to the set. func (a *Alerts) Put(ctx context.Context, alerts ...*types.Alert) error { a.mtx.Lock() defer a.mtx.Unlock() ctx, span := tracer.Start(ctx, "provider.mem.Put", trace.WithAttributes( attribute.Int("alerting.alerts.count", len(alerts)), ), trace.WithSpanKind(trace.SpanKindProducer), ) defer span.End() for _, alert := range alerts { fp := alert.Fingerprint() existing := false // Check that there's an alert existing within the store before // trying to merge. if old, err := a.alerts.Get(fp); err == nil { existing = true // Merge alerts if there is an overlap in activity range. if (alert.EndsAt.After(old.StartsAt) && alert.EndsAt.Before(old.EndsAt)) || (alert.StartsAt.After(old.StartsAt) && alert.StartsAt.Before(old.EndsAt)) { alert = old.Merge(alert) } } if err := a.callback.PreStore(alert, existing); err != nil { a.logger.Error("pre-store callback returned error on set alert", "err", err) continue } if err := a.alerts.Set(alert); err != nil { a.logger.Warn("error on set alert", "alertname", alert.Name(), "err", err) if errors.Is(err, store.ErrLimited) { labels := []string{} if a.flagger.EnableAlertNamesInMetrics() { labels = append(labels, alert.Name()) } a.alertsLimitedTotal.WithLabelValues(labels...).Inc() } continue } a.callback.PostStore(alert, existing) metadata := map[string]string{} a.propagator.Inject(ctx, propagation.MapCarrier(metadata)) msg := &provider.Alert{ Data: alert, Header: metadata, } for _, l := range a.listeners { select { case l.alerts <- msg: a.subscriberChannelWrites.WithLabelValues(l.name).Inc() case <-l.done: } } } return nil } // countByState returns the number of non-resolved alerts by state. func (a *Alerts) countByState() (active, suppressed, unprocessed int) { for _, alert := range a.alerts.List() { if alert.Resolved() { continue } switch a.marker.Status(alert.Fingerprint()).State { case types.AlertStateActive: active++ case types.AlertStateSuppressed: suppressed++ case types.AlertStateUnprocessed: unprocessed++ } } return active, suppressed, unprocessed } // alertsCollector implements prometheus.Collector to collect all alert count metrics in a single pass. type alertsCollector struct { alerts *Alerts } var alertsDesc = prometheus.NewDesc( "alertmanager_alerts", "How many alerts by state.", []string{"state"}, nil, ) func (c *alertsCollector) Describe(ch chan<- *prometheus.Desc) { ch <- alertsDesc } func (c *alertsCollector) Collect(ch chan<- prometheus.Metric) { active, suppressed, unprocessed := c.alerts.countByState() ch <- prometheus.MustNewConstMetric(alertsDesc, prometheus.GaugeValue, float64(active), string(types.AlertStateActive)) ch <- prometheus.MustNewConstMetric(alertsDesc, prometheus.GaugeValue, float64(suppressed), string(types.AlertStateSuppressed)) ch <- prometheus.MustNewConstMetric(alertsDesc, prometheus.GaugeValue, float64(unprocessed), string(types.AlertStateUnprocessed)) } type noopCallback struct{} func (n noopCallback) PreStore(_ *types.Alert, _ bool) error { return nil } func (n noopCallback) PostStore(_ *types.Alert, _ bool) {} func (n noopCallback) PostDelete(_ *types.Alert) {} func (n noopCallback) PostGC(_ model.Fingerprints) {} ================================================ FILE: provider/mem/mem_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package mem import ( "context" "errors" "fmt" "reflect" "strconv" "sync" "sync/atomic" "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/store" "github.com/prometheus/alertmanager/types" ) var ( t0 = time.Now() t1 = t0.Add(100 * time.Millisecond) alert1 = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"bar": "foo"}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: t0, EndsAt: t1, GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: t0, Timeout: false, } alert2 = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"bar": "foo2"}, Annotations: model.LabelSet{"foo": "bar2"}, StartsAt: t0, EndsAt: t1, GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: t0, Timeout: false, } alert3 = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"bar": "foo3"}, Annotations: model.LabelSet{"foo": "bar3"}, StartsAt: t0, EndsAt: t1, GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: t0, Timeout: false, } ) // TestAlertsSubscribePutStarvation tests starvation of `iterator.Close` and // `alerts.Put`. Both `Subscribe` and `Put` use the Alerts.mtx lock. `Subscribe` // needs it to subscribe and more importantly unsubscribe `Alerts.listeners`. `Put` // uses the lock to add additional alerts and iterate the `Alerts.listeners` map. // If the channel of a listener is at its limit, `alerts.Lock` is blocked, whereby // a listener can not unsubscribe as the lock is hold by `alerts.Lock`. func TestAlertsSubscribePutStarvation(t *testing.T) { marker := types.NewMarker(prometheus.NewRegistry()) alerts, err := NewAlerts(context.Background(), marker, 30*time.Minute, 0, noopCallback{}, promslog.NewNopLogger(), prometheus.NewRegistry(), nil) if err != nil { t.Fatal(err) } iterator := alerts.Subscribe("test") alertsToInsert := []*types.Alert{} // Exhaust alert channel for i := range alertChannelLength + 1 { alertsToInsert = append(alertsToInsert, &types.Alert{ Alert: model.Alert{ // Make sure the fingerprints differ Labels: model.LabelSet{"iteration": model.LabelValue(strconv.Itoa(i))}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: t0, EndsAt: t1, GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: t0, Timeout: false, }) } putIsDone := make(chan struct{}) putsErr := make(chan error, 1) go func() { if err := alerts.Put(context.Background(), alertsToInsert...); err != nil { putsErr <- err return } putIsDone <- struct{}{} }() // Increase probability that `iterator.Close` is called after `alerts.Put`. time.Sleep(100 * time.Millisecond) iterator.Close() select { case <-putsErr: t.Fatal(err) case <-putIsDone: // continue case <-time.After(100 * time.Millisecond): t.Fatal("expected `alerts.Put` and `iterator.Close` not to starve each other") } } func TestDeadLock(t *testing.T) { t0 := time.Now() t1 := t0.Add(5 * time.Second) marker := types.NewMarker(prometheus.NewRegistry()) // Run gc every 5 milliseconds to increase the possibility of a deadlock with Subscribe() alerts, err := NewAlerts(context.Background(), marker, 5*time.Millisecond, 0, noopCallback{}, promslog.NewNopLogger(), prometheus.NewRegistry(), nil) if err != nil { t.Fatal(err) } alertsToInsert := []*types.Alert{} for i := range 200 + 1 { alertsToInsert = append(alertsToInsert, &types.Alert{ Alert: model.Alert{ // Make sure the fingerprints differ Labels: model.LabelSet{"iteration": model.LabelValue(strconv.Itoa(i))}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: t0, EndsAt: t1, GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: t0, Timeout: false, }) } if err := alerts.Put(context.Background(), alertsToInsert...); err != nil { t.Fatal("Unable to add alerts") } done := make(chan bool) // call subscribe repeatedly in a goroutine to increase // the possibility of a deadlock occurring go func() { tick := time.NewTicker(10 * time.Millisecond) defer tick.Stop() stopAfter := time.After(1 * time.Second) for { select { case <-tick.C: alerts.Subscribe("test") case <-stopAfter: done <- true break } } }() select { case <-done: // no deadlock alerts.Close() case <-time.After(10 * time.Second): t.Error("Deadlock detected") } } func TestAlertsPut(t *testing.T) { marker := types.NewMarker(prometheus.NewRegistry()) alerts, err := NewAlerts(context.Background(), marker, 30*time.Minute, 0, noopCallback{}, promslog.NewNopLogger(), prometheus.NewRegistry(), nil) if err != nil { t.Fatal(err) } insert := []*types.Alert{alert1, alert2, alert3} if err := alerts.Put(context.Background(), insert...); err != nil { t.Fatalf("Insert failed: %s", err) } for i, a := range insert { res, err := alerts.Get(a.Fingerprint()) if err != nil { t.Fatalf("retrieval error: %s", err) } require.NoError(t, alertDiff(a, res), "unexpected alert: %d", i) } } func TestAlertsSubscribe(t *testing.T) { marker := types.NewMarker(prometheus.NewRegistry()) ctx := t.Context() alerts, err := NewAlerts(ctx, marker, 30*time.Minute, 0, noopCallback{}, promslog.NewNopLogger(), prometheus.NewRegistry(), nil) if err != nil { t.Fatal(err) } // Add alert1 to validate if pending alerts will be sent. if err := alerts.Put(ctx, alert1); err != nil { t.Fatalf("Insert failed: %s", err) } expectedAlerts := map[model.Fingerprint]*types.Alert{ alert1.Fingerprint(): alert1, alert2.Fingerprint(): alert2, alert3.Fingerprint(): alert3, } // Start many consumers and make sure that each receives all the subsequent alerts. var ( nb = 100 fatalc = make(chan string, nb) wg sync.WaitGroup ) wg.Add(nb) for i := range nb { go func(i int) { defer wg.Done() it := alerts.Subscribe("test") defer it.Close() received := make(map[model.Fingerprint]struct{}) for { select { case got, ok := <-it.Next(): if !ok { fatalc <- fmt.Sprintf("Iterator %d closed", i) return } if it.Err() != nil { fatalc <- fmt.Sprintf("Iterator %d: %v", i, it.Err()) return } expected := expectedAlerts[got.Data.Fingerprint()] if err := alertDiff(got.Data, expected); err != nil { fatalc <- fmt.Sprintf("Unexpected alert (iterator %d)\n%s", i, err.Error()) return } received[got.Data.Fingerprint()] = struct{}{} if len(received) == len(expectedAlerts) { return } case <-time.After(5 * time.Second): fatalc <- fmt.Sprintf("Unexpected number of alerts for iterator %d, got: %d, expected: %d", i, len(received), len(expectedAlerts)) return } } }(i) } // Add more alerts that should be received by the subscribers. if err := alerts.Put(ctx, alert2); err != nil { t.Fatalf("Insert failed: %s", err) } if err := alerts.Put(ctx, alert3); err != nil { t.Fatalf("Insert failed: %s", err) } wg.Wait() close(fatalc) fatal, ok := <-fatalc if ok { t.Fatal(fatal) } } func TestAlertsGetPending(t *testing.T) { marker := types.NewMarker(prometheus.NewRegistry()) alerts, err := NewAlerts(context.Background(), marker, 30*time.Minute, 0, noopCallback{}, promslog.NewNopLogger(), nil, nil) if err != nil { t.Fatal(err) } ctx := context.Background() if err := alerts.Put(ctx, alert1, alert2); err != nil { t.Fatalf("Insert failed: %s", err) } expectedAlerts := map[model.Fingerprint]*types.Alert{ alert1.Fingerprint(): alert1, alert2.Fingerprint(): alert2, } iterator := alerts.GetPending() for actual := range iterator.Next() { expected := expectedAlerts[actual.Data.Fingerprint()] require.NoError(t, alertDiff(actual.Data, expected)) } if err := alerts.Put(ctx, alert3); err != nil { t.Fatalf("Insert failed: %s", err) } expectedAlerts = map[model.Fingerprint]*types.Alert{ alert1.Fingerprint(): alert1, alert2.Fingerprint(): alert2, alert3.Fingerprint(): alert3, } iterator = alerts.GetPending() for actual := range iterator.Next() { expected := expectedAlerts[actual.Data.Fingerprint()] require.NoError(t, alertDiff(actual.Data, expected)) } } func TestAlertsGC(t *testing.T) { marker := types.NewMarker(prometheus.NewRegistry()) alerts, err := NewAlerts(context.Background(), marker, 200*time.Millisecond, 0, noopCallback{}, promslog.NewNopLogger(), nil, nil) if err != nil { t.Fatal(err) } insert := []*types.Alert{alert1, alert2, alert3} if err := alerts.Put(context.Background(), insert...); err != nil { t.Fatalf("Insert failed: %s", err) } for _, a := range insert { marker.SetActiveOrSilenced(a.Fingerprint(), nil) marker.SetInhibited(a.Fingerprint()) if !marker.Active(a.Fingerprint()) { t.Errorf("error setting status: %v", a) } } time.Sleep(300 * time.Millisecond) for i, a := range insert { _, err := alerts.Get(a.Fingerprint()) require.Error(t, err) require.Equal(t, store.ErrNotFound, err, "alert %d didn't get GC'd: %v", i, err) s := marker.Status(a.Fingerprint()) if s.State != types.AlertStateUnprocessed { t.Errorf("marker %d didn't get GC'd: %v", i, s) } } } func TestAlertsStoreCallback(t *testing.T) { cb := &limitCountCallback{limit: 3} marker := types.NewMarker(prometheus.NewRegistry()) alerts, err := NewAlerts(context.Background(), marker, 200*time.Millisecond, 0, cb, promslog.NewNopLogger(), nil, nil) if err != nil { t.Fatal(err) } ctx := context.Background() err = alerts.Put(ctx, alert1, alert2, alert3) if err != nil { t.Fatal(err) } if num := cb.alerts.Load(); num != 3 { t.Fatalf("unexpected number of alerts in the store, expected %v, got %v", 3, num) } alert1Mod := *alert1 alert1Mod.Annotations = model.LabelSet{"foo": "bar", "new": "test"} // Update annotations for alert1 alert4 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"bar4": "foo4"}, Annotations: model.LabelSet{"foo4": "bar4"}, StartsAt: t0, EndsAt: t1, GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: t0, Timeout: false, } err = alerts.Put(ctx, &alert1Mod, alert4) // Verify that we failed to put new alert into store (not reported via error, only checked using Load) if err != nil { t.Fatalf("unexpected error %v", err) } if num := cb.alerts.Load(); num != 3 { t.Fatalf("unexpected number of alerts in the store, expected %v, got %v", 3, num) } // But we still managed to update alert1, since callback doesn't report error when updating existing alert. a, err := alerts.Get(alert1.Fingerprint()) if err != nil { t.Fatal(err) } require.NoError(t, alertDiff(a, &alert1Mod)) // Now wait until existing alerts are GC-ed, and make sure that callback was called. time.Sleep(300 * time.Millisecond) if num := cb.alerts.Load(); num != 0 { t.Fatalf("unexpected number of alerts in the store, expected %v, got %v", 0, num) } err = alerts.Put(ctx, alert4) if err != nil { t.Fatal(err) } } func TestAlerts_CountByState(t *testing.T) { marker := types.NewMarker(prometheus.NewRegistry()) alerts, err := NewAlerts(context.Background(), marker, 200*time.Millisecond, 0, nil, promslog.NewNopLogger(), nil, nil) require.NoError(t, err) countTotal := func() int { active, suppressed, unprocessed := alerts.countByState() return active + suppressed + unprocessed } // First, there shouldn't be any alerts. require.Equal(t, 0, countTotal()) // When you insert a new alert that will eventually be active, it should be unprocessed first. now := time.Now() a1 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"bar": "foo"}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: now, EndsAt: now.Add(400 * time.Millisecond), GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: now, Timeout: false, } ctx := context.Background() alerts.Put(ctx, a1) _, _, unprocessed := alerts.countByState() require.Equal(t, 1, unprocessed) require.Equal(t, 1, countTotal()) require.Eventually(t, func() bool { // When the alert will eventually expire and is considered resolved - it won't count. return countTotal() == 0 }, 600*time.Millisecond, 100*time.Millisecond) now = time.Now() a2 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"bar": "foo"}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: now, EndsAt: now.Add(400 * time.Millisecond), GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: now, Timeout: false, } // When insert an alert, and then silence it. It shows up with the correct filter. alerts.Put(ctx, a2) marker.SetActiveOrSilenced(a2.Fingerprint(), []string{"1"}) _, suppressed, _ := alerts.countByState() require.Equal(t, 1, suppressed) require.Equal(t, 1, countTotal()) require.Eventually(t, func() bool { // When the alert will eventually expire and is considered resolved - it won't count. return countTotal() == 0 }, 600*time.Millisecond, 100*time.Millisecond) } func alertDiff(left, right *types.Alert) error { if left == nil || right == nil { return errors.New("should not be nil") } comparisons := []struct { name string isEqual bool expected any got any }{ {"Labels", reflect.DeepEqual(right.Labels, left.Labels), right.Labels, left.Labels}, {"Annotations", reflect.DeepEqual(right.Annotations, left.Annotations), right.Annotations, left.Annotations}, {"StartsAt", right.StartsAt.Equal(left.StartsAt), right.StartsAt, left.StartsAt}, {"EndsAt", right.EndsAt.Equal(left.EndsAt), right.EndsAt, left.EndsAt}, {"UpdatedAt", right.UpdatedAt.Equal(left.UpdatedAt), right.UpdatedAt, left.UpdatedAt}, {"GeneratorURL", right.GeneratorURL == left.GeneratorURL, right.GeneratorURL, left.GeneratorURL}, {"Timeout", right.Timeout == left.Timeout, right.Timeout, left.Timeout}, } var errs []error for _, comp := range comparisons { if !comp.isEqual { errs = append(errs, fmt.Errorf("field `%s` mismatch.\n Expected: %v\n Got: %v", comp.name, comp.expected, comp.got)) } } return errors.Join(errs...) } type limitCountCallback struct { alerts atomic.Int32 gcCount atomic.Int32 limit int } var errTooManyAlerts = fmt.Errorf("too many alerts") func (l *limitCountCallback) PreStore(_ *types.Alert, existing bool) error { if existing { return nil } if int(l.alerts.Load())+1 > l.limit { return errTooManyAlerts } return nil } func (l *limitCountCallback) PostStore(_ *types.Alert, existing bool) { if !existing { l.alerts.Add(1) l.gcCount.Add(1) } } func (l *limitCountCallback) PostDelete(_ *types.Alert) { l.alerts.Add(-1) } func (l *limitCountCallback) PostGC(fingerprints model.Fingerprints) { l.gcCount.Add(-int32(fingerprints.Len())) } func TestAlertsConcurrently(t *testing.T) { callback := &limitCountCallback{limit: 100} a, err := NewAlerts(context.Background(), types.NewMarker(prometheus.NewRegistry()), time.Millisecond, 0, callback, promslog.NewNopLogger(), nil, nil) require.NoError(t, err) stopc := make(chan struct{}) failc := make(chan struct{}) go func() { time.Sleep(2 * time.Second) close(stopc) }() expire := 10 * time.Millisecond wg := sync.WaitGroup{} for range 100 { wg.Go(func() { j := 0 for { select { case <-failc: return case <-stopc: return default: } now := time.Now() err := a.Put(context.Background(), &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"bar": model.LabelValue(strconv.Itoa(j))}, StartsAt: now, EndsAt: now.Add(expire), }, UpdatedAt: now, }) if err != nil && !errors.Is(err, errTooManyAlerts) { close(failc) return } j++ } }) } wg.Wait() select { case <-failc: t.Fatalf("unexpected error happened") default: } time.Sleep(expire) require.Eventually(t, func() bool { // When the alert will eventually expire and is considered resolved - it won't count. active, _, _ := a.countByState() return active == 0 }, 2*expire, expire) require.Equal(t, int32(0), callback.alerts.Load()) require.Equal(t, int32(0), callback.gcCount.Load()) } func TestSubscriberChannelMetrics(t *testing.T) { marker := types.NewMarker(prometheus.NewRegistry()) reg := prometheus.NewRegistry() alerts, err := NewAlerts(context.Background(), marker, 30*time.Minute, 0, noopCallback{}, promslog.NewNopLogger(), reg, nil) require.NoError(t, err) subscriberName := "test_subscriber" // Subscribe to alerts iterator := alerts.Subscribe(subscriberName) defer iterator.Close() // Consume alerts in the background go func() { for range iterator.Next() { // Just drain the channel } }() // Helper function to get counter value getCounterValue := func(name, labelName, labelValue string) float64 { metrics, err := reg.Gather() require.NoError(t, err) for _, mf := range metrics { if mf.GetName() == name { for _, m := range mf.GetMetric() { for _, label := range m.GetLabel() { if label.GetName() == labelName && label.GetValue() == labelValue { return m.GetCounter().GetValue() } } } } } return 0 } // Initially, the counter should be 0 writeCount := getCounterValue("alertmanager_alerts_subscriber_channel_writes_total", "subscriber", subscriberName) require.Equal(t, 0.0, writeCount, "subscriberChannelWrites should start at 0") // Put some alerts now := time.Now() alertsToSend := []*types.Alert{ { Alert: model.Alert{ Labels: model.LabelSet{"test": "1"}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: now, EndsAt: now.Add(1 * time.Hour), GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: now, Timeout: false, }, { Alert: model.Alert{ Labels: model.LabelSet{"test": "2"}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: now, EndsAt: now.Add(1 * time.Hour), GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: now, Timeout: false, }, { Alert: model.Alert{ Labels: model.LabelSet{"test": "3"}, Annotations: model.LabelSet{"foo": "bar"}, StartsAt: now, EndsAt: now.Add(1 * time.Hour), GeneratorURL: "http://example.com/prometheus", }, UpdatedAt: now, Timeout: false, }, } err = alerts.Put(context.Background(), alertsToSend...) require.NoError(t, err) // Verify the counter incremented for each successful write require.Eventually(t, func() bool { writeCount := getCounterValue("alertmanager_alerts_subscriber_channel_writes_total", "subscriber", subscriberName) return writeCount == float64(len(alertsToSend)) }, 1*time.Second, 10*time.Millisecond, "subscriberChannelWrites should equal the number of alerts sent") } ================================================ FILE: provider/provider.go ================================================ // Copyright 2015 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package provider import ( "context" "fmt" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/types" ) // ErrNotFound is returned if a provider cannot find a requested item. var ErrNotFound = fmt.Errorf("item not found") type Alert struct { // Header contains metadata, for example propagated tracing information. Header map[string]string Data *types.Alert } // Iterator provides the functions common to all iterators. To be useful, a // specific iterator interface (e.g. AlertIterator) has to be implemented that // provides a Next method. type Iterator interface { // Err returns the current error. It is not safe to call it concurrently // with other iterator methods or while reading from a channel returned // by the iterator. Err() error // Close must be called to release resources once the iterator is not // used anymore. Close() } // AlertIterator is an Iterator for Alerts. type AlertIterator interface { Iterator // Next returns a channel that will be closed once the iterator is // exhausted. It is not necessary to exhaust the iterator but Close must // be called in any case to release resources used by the iterator (even // if the iterator is exhausted). Next() <-chan *Alert } // NewAlertIterator returns a new AlertIterator based on the generic alertIterator type. func NewAlertIterator(ch <-chan *Alert, done chan struct{}, err error) AlertIterator { return &alertIterator{ ch: ch, done: done, err: err, } } // alertIterator implements AlertIterator. So far, this one fits all providers. type alertIterator struct { ch <-chan *Alert done chan struct{} err error } func (ai alertIterator) Next() <-chan *Alert { return ai.ch } func (ai alertIterator) Err() error { return ai.err } func (ai alertIterator) Close() { close(ai.done) } // Alerts gives access to a set of alerts. All methods are goroutine-safe. type Alerts interface { // Subscribe returns an iterator over active alerts that have not been // resolved and successfully notified about. // They are not guaranteed to be in chronological order. Subscribe(name string) AlertIterator // SlurpAndSubcribe returns a list of all active alerts which are available // in the provider before the call to SlurpAndSubcribe and an iterator // of all alerts available after the call to SlurpAndSubcribe. // SlurpAndSubcribe can be used by clients which need to build in memory state // to know when they've processed the 'initial' batch of alerts in a provider // after they reload their subscription. // Implementation of SlurpAndSubcribe is optional - providers may choose to // return an empty list for the first return value and the result of Subscribe // for the second return value. SlurpAndSubscribe(name string) ([]*types.Alert, AlertIterator) // GetPending returns an iterator over all alerts that have // pending notifications. GetPending() AlertIterator // Get returns the alert for a given fingerprint. Get(model.Fingerprint) (*types.Alert, error) // Put adds the given set of alerts to the set. Put(ctx context.Context, alerts ...*types.Alert) error } ================================================ FILE: scripts/genproto.sh ================================================ #!/usr/bin/env bash # Generate all protobuf bindings. set -euo pipefail shopt -s failglob if ! [[ "$0" = "scripts/genproto.sh" ]]; then echo "must be run from repository root" exit 255 fi echo "generating files" go tool -modfile=internal/tools/go.mod buf dep update go tool -modfile=internal/tools/go.mod buf generate ================================================ FILE: scripts/swagger.sh ================================================ #!/usr/bin/env bash # Generate api set -euo pipefail shopt -s failglob if ! [[ "$0" = "scripts/swagger.sh" ]]; then echo "must be run from repository root" exit 255 fi echo "generating files" rm -r api/v2/{client,models,restapi} ||: go tool -modfile=internal/tools/go.mod swagger generate server -f api/v2/openapi.yaml --copyright-file=COPYRIGHT.txt --exclude-main -A alertmanager --target api/v2/ go tool -modfile=internal/tools/go.mod swagger generate client -f api/v2/openapi.yaml --copyright-file=COPYRIGHT.txt --skip-models --target api/v2 ================================================ FILE: silence/cache.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package silence import ( "sync" "github.com/prometheus/common/model" ) // cacheEntry stores the IDs of silences that match an alert and the version of the silences state the // result is based on. type cacheEntry struct { silenceIDs []string version int } // newCacheEntry creates a new cacheEntry. func newCacheEntry(version int, silenceIDs ...string) *cacheEntry { return &cacheEntry{ silenceIDs: silenceIDs, version: version, } } // count returns the number of silence IDs in the cacheEntry. func (e *cacheEntry) count() int { return len(e.silenceIDs) } // cache stores the IDs of silences that match an alert and the version of the silences state the // result is based on. type cache struct { entries map[model.Fingerprint]*cacheEntry mtx sync.RWMutex } // delete removes the cacheEntry for the given fingerprint. func (c *cache) delete(fp model.Fingerprint) { c.mtx.Lock() defer c.mtx.Unlock() delete(c.entries, fp) } // get returns the cacheEntry for the given fingerprint. // The returned entry is not a copy, so it should not be modified. func (c *cache) get(fp model.Fingerprint) *cacheEntry { c.mtx.RLock() defer c.mtx.RUnlock() if e, found := c.entries[fp]; found { return e } return &cacheEntry{} } // set sets the cacheEntry for the given fingerprint. func (c *cache) set(fp model.Fingerprint, entry *cacheEntry) { c.mtx.Lock() defer c.mtx.Unlock() c.entries[fp] = entry } ================================================ FILE: silence/cache_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package silence import ( "sync" "testing" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" ) func newTestCache() *cache { return &cache{entries: map[model.Fingerprint]*cacheEntry{}} } func TestCacheEntryCount(t *testing.T) { tests := []struct { name string entry *cacheEntry expected int }{ { name: "zero for no silence IDs", entry: newCacheEntry(1), expected: 0, }, { name: "one entry", entry: newCacheEntry(2, "a"), expected: 1, }, { name: "multiple entries", entry: newCacheEntry(3, "a", "b", "c"), expected: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.expected, tt.entry.count()) }) } } func TestNewCacheEntry(t *testing.T) { e := newCacheEntry(42, "s1", "s2") require.Equal(t, []string{"s1", "s2"}, e.silenceIDs) require.Equal(t, 42, e.version) } func TestCacheSetAndGet(t *testing.T) { c := newTestCache() fp := model.Fingerprint(1) // Get on empty cache returns zero-value entry. entry := c.get(fp) require.Equal(t, 0, entry.count()) require.Equal(t, 0, entry.version) // Set and retrieve. c.set(fp, newCacheEntry(5, "s1")) entry = c.get(fp) require.Equal(t, []string{"s1"}, entry.silenceIDs) require.Equal(t, 5, entry.version) } func TestCacheOverwrite(t *testing.T) { c := newTestCache() fp := model.Fingerprint(1) c.set(fp, newCacheEntry(1, "s1")) c.set(fp, newCacheEntry(2, "s2", "s3")) entry := c.get(fp) require.Equal(t, []string{"s2", "s3"}, entry.silenceIDs) require.Equal(t, 2, entry.version) } func TestCacheDelete(t *testing.T) { c := newTestCache() fp := model.Fingerprint(1) c.set(fp, newCacheEntry(1, "s1")) before := c.get(fp) require.Positive(t, before.count()) c.delete(fp) entry := c.get(fp) require.Equal(t, 0, entry.count()) require.Equal(t, 0, entry.version) } func TestCacheDeleteNonExistent(t *testing.T) { c := newTestCache() // Deleting a key that doesn't exist should not panic. require.NotPanics(t, func() { c.delete(model.Fingerprint(999)) }) } func TestCacheDeleteIsolation(t *testing.T) { c := newTestCache() fp1 := model.Fingerprint(1) fp2 := model.Fingerprint(2) c.set(fp1, newCacheEntry(1, "s1")) c.set(fp2, newCacheEntry(2, "s2")) c.delete(fp1) // fp1 should be gone. entry1 := c.get(fp1) require.Equal(t, 0, entry1.count()) // fp2 should be untouched. entry2 := c.get(fp2) require.Equal(t, []string{"s2"}, entry2.silenceIDs) } func TestCacheMultipleFingerprints(t *testing.T) { c := newTestCache() for i := range 100 { fp := model.Fingerprint(i) c.set(fp, newCacheEntry(i, "s")) } for i := range 100 { fp := model.Fingerprint(i) entry := c.get(fp) require.Equal(t, 1, entry.count()) require.Equal(t, i, entry.version) } } func TestCacheConcurrentAccess(t *testing.T) { c := newTestCache() fp := model.Fingerprint(1) c.set(fp, newCacheEntry(0, "initial")) var wg sync.WaitGroup const goroutines = 50 // Concurrent readers. for range goroutines { wg.Go(func() { for range 100 { _ = c.get(fp) } }) } // Concurrent writers. for i := range goroutines { wg.Go(func() { for j := range 100 { c.set(fp, newCacheEntry(i*100+j, "w")) } }) } // Concurrent deleters. for range goroutines { wg.Go(func() { for range 100 { c.delete(fp) } }) } wg.Wait() } ================================================ FILE: silence/silence.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package silence provides a storage for silences, which can share its // state over a mesh network and snapshot it. package silence import ( "bufio" "bytes" "context" "errors" "fmt" "io" "log/slog" "math/rand" "os" "regexp" "slices" "sort" "sync" "time" "github.com/coder/quartz" uuid "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" "google.golang.org/protobuf/encoding/protodelim" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/prometheus/alertmanager/cluster" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/pkg/labels" pb "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/alertmanager/types" ) var tracer = otel.Tracer("github.com/prometheus/alertmanager/silence") // ErrNotFound is returned if a silence was not found. var ErrNotFound = errors.New("silence not found") // ErrInvalidState is returned if the state isn't valid. var ErrInvalidState = errors.New("invalid state") type matcherIndex map[string]labels.MatcherSet // get retrieves the matcher set for a given silence. func (c matcherIndex) get(s *pb.Silence) (labels.MatcherSet, error) { if m, ok := c[s.Id]; ok { return m, nil } return nil, ErrNotFound } // add compiles a silences' matchers and adds them to the cache. // It returns the compiled matcher set. func (c matcherIndex) add(s *pb.Silence) (labels.MatcherSet, error) { matcherSet := make(labels.MatcherSet, 0, len(s.MatcherSets)) for _, ms := range s.MatcherSets { matchers := make(labels.Matchers, len(ms.Matchers)) for i, m := range ms.Matchers { var mt labels.MatchType switch m.Type { case pb.Matcher_EQUAL: mt = labels.MatchEqual case pb.Matcher_NOT_EQUAL: mt = labels.MatchNotEqual case pb.Matcher_REGEXP: mt = labels.MatchRegexp case pb.Matcher_NOT_REGEXP: mt = labels.MatchNotRegexp default: return nil, fmt.Errorf("unknown matcher type %q", m.Type) } matcher, err := labels.NewMatcher(mt, m.Name, m.Pattern) if err != nil { return nil, err } matchers[i] = matcher } matcherSet = append(matcherSet, &matchers) } c[s.Id] = matcherSet return matcherSet, nil } // silenceVersion associates a silence with the Silences version when it was created. type silenceVersion struct { id string version int } // versionIndex is a index into Silences ordered by the version of Silences when the // silence was created. The index is always sorted from lowest to highest version. // // The versionIndex allows clients of Silences.Query to incrementally update local caches // of query results. Instead of a new version requiring the client to scan everything // again to get an up-to-date picture of Silences, they can use the versionIndex to figure // out which silences have been added since the last version they saw. This means they can // just scan the NEW silences, rather than all of them. type versionIndex []silenceVersion // add pushes a new silenceVersionMapping to the back of the silenceVersionIndex. It does not // validate the input. func (s *versionIndex) add(version int, sil string) { *s = append(*s, silenceVersion{version: version, id: sil}) } // findVersionGreaterThan uses a log(n) search to find the first index of the versionIndex // which has a version higher than version. If any entries with a higher version exist, // it returns true and the starting index (which is guaranteed to be a valid index into // the slice). Otherwise it returns false. func (s versionIndex) findVersionGreaterThan(version int) (index int, found bool) { startIdx := sort.Search(len(s), func(i int) bool { return s[i].version > version }) return startIdx, startIdx < len(s) } // Silencer binds together a AlertMarker and a Silences to implement the Muter // interface. type Silencer struct { silences *Silences cache *cache marker types.AlertMarker logger *slog.Logger } // NewSilencer returns a new Silencer. func NewSilencer(silences *Silences, marker types.AlertMarker, logger *slog.Logger) *Silencer { return &Silencer{ silences: silences, cache: &cache{entries: map[model.Fingerprint]*cacheEntry{}}, marker: marker, logger: logger, } } // Mutes implements the Muter interface. func (s *Silencer) Mutes(ctx context.Context, lset model.LabelSet) bool { fp := lset.Fingerprint() ctx, span := tracer.Start(ctx, "silence.Silencer.Mutes", trace.WithAttributes( attribute.String("alerting.alert.fingerprint", fp.String()), ), trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() // Get the cached entry for this fingerprint. cachedEntry := s.cache.get(fp) var ( oldSils []*pb.Silence newSils []*pb.Silence newVersion = cachedEntry.version ) cacheIsUpToDate := cachedEntry.version == s.silences.Version() if cacheIsUpToDate && cachedEntry.count() == 0 { // Very fast path: no new silences have been added and this lset was not // silenced last time we checked. span.AddEvent("No new silences to match since last check", trace.WithAttributes( attribute.Int("alerting.silences.cache.count", cachedEntry.count()), ), ) return false } // Either there are new silences and we need to check if those match lset or there were // silences last time we queried so we need to see if those are still active/have become // active. It's possible for there to be both old and new silences. if cachedEntry.count() > 0 { // there were old silences for this lset, we need to find them to check if they // are still active/pending, or have ended. var err error oldSils, _, err = s.silences.Query( ctx, QIDs(cachedEntry.silenceIDs...), QState(SilenceStateActive, SilenceStatePending), ) if err != nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) s.logger.Error( "Querying old silences failed, alerts might not get silenced correctly", "err", err, ) } } if !cacheIsUpToDate { // New silences have been added since the last time the marker was updated. Do a full // query for any silences newer than the markerVersion that match the lset. // On this branch we WILL update newVersion since we can be sure we've seen any silences // newer than markerVersion. var err error newSils, newVersion, err = s.silences.Query( ctx, QSince(cachedEntry.version), QState(SilenceStateActive, SilenceStatePending), QMatches(lset), ) if err != nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) s.logger.Error( "Querying silences failed, alerts might not get silenced correctly", "err", err, ) } } // Note: if cacheIsUpToDate, newVersion is left at cachedEntry.version because the Query call // might already return a newer version, which is not the version our old list of // applicable silences is based on. totalSilences := len(oldSils) + len(newSils) if totalSilences == 0 { // Easy case, neither active nor pending silences anymore. s.cache.set(fp, newCacheEntry(newVersion)) s.marker.SetActiveOrSilenced(fp, nil) span.AddEvent("No silences to match", trace.WithAttributes( attribute.Int("alerting.silences.count", totalSilences), )) return false } // It is still possible that nothing has changed, but finding out is not // much less effort than just recreating the IDs from the query // result. So let's do it in any case. Note that we cannot reuse the // current ID slices for concurrency reasons. activeIDs := make([]string, 0, totalSilences) allIDs := make([]string, 0, totalSilences) seen := make(map[string]struct{}, totalSilences) now := s.silences.nowUTC() // Categorize old and new silences by their current state. // oldSils and newSils may overlap if a cached silence was updated // (receiving a new version), so we deduplicate by ID. for _, sils := range [...][]*pb.Silence{oldSils, newSils} { for _, sil := range sils { if _, ok := seen[sil.Id]; ok { continue } seen[sil.Id] = struct{}{} switch getState(sil, now) { case SilenceStatePending: allIDs = append(allIDs, sil.Id) case SilenceStateActive: activeIDs = append(activeIDs, sil.Id) allIDs = append(allIDs, sil.Id) default: // Do nothing, silence has expired in the meantime. } } } s.logger.Debug( "determined current silences state", "now", now, "total", len(allIDs), "active", len(activeIDs), "pending", len(allIDs)-len(activeIDs), ) // TODO: remove this sort once the marker is removed. sort.Strings(activeIDs) s.cache.set(fp, newCacheEntry(newVersion, allIDs...)) s.marker.SetActiveOrSilenced(fp, activeIDs) t := trace.WithAttributes( attribute.Int("alerting.silences.active.count", len(activeIDs)), attribute.Int("alerting.silences.pending.count", len(allIDs)-len(activeIDs)), attribute.Int("alerting.silences.total.count", len(allIDs)), ) mutes := len(activeIDs) > 0 if mutes { span.AddEvent("Silencer mutes alert", t) } else { span.AddEvent("Silencer does not mute alert", t) } return mutes } // The following methods implement mem.AlertStoreCallback. func (s *Silencer) PreStore(_ *types.Alert, _ bool) error { return nil } func (s *Silencer) PostStore(_ *types.Alert, _ bool) {} func (s *Silencer) PostDelete(alert *types.Alert) {} func (s *Silencer) PostGC(ff model.Fingerprints) { for _, fp := range ff { s.cache.delete(fp) } } // Silences holds a silence state that can be modified, queried, and snapshot. type Silences struct { clock quartz.Clock logger *slog.Logger metrics *metrics retention time.Duration limits Limits mtx sync.RWMutex st state version int // Increments whenever silences are added. broadcast func([]byte) mi matcherIndex vi versionIndex } // Limits contains the limits for silences. type Limits struct { // MaxSilences limits the maximum number of silences, including expired // silences. MaxSilences func() int // MaxSilenceSizeBytes is the maximum size of an individual silence as // stored on disk. MaxSilenceSizeBytes func() int } // MaintenanceFunc represents the function to run as part of the periodic maintenance for silences. // It returns the size of the snapshot taken or an error if it failed. type MaintenanceFunc func() (int64, error) type metrics struct { gcDuration prometheus.Summary gcErrorsTotal prometheus.Counter snapshotDuration prometheus.Summary snapshotSize prometheus.Gauge queriesTotal prometheus.Counter queryErrorsTotal prometheus.Counter queryDuration prometheus.Histogram queryScannedTotal prometheus.Counter querySkippedTotal prometheus.Counter silencesActive prometheus.GaugeFunc silencesPending prometheus.GaugeFunc silencesExpired prometheus.GaugeFunc stateSize prometheus.Gauge matcherIndexSize prometheus.Gauge versionIndexSize prometheus.Gauge propagatedMessagesTotal prometheus.Counter maintenanceTotal prometheus.Counter maintenanceErrorsTotal prometheus.Counter matcherCompileIndexSilenceErrorsTotal prometheus.Counter matcherCompileLoadSnapshotErrorsTotal prometheus.Counter } func newSilenceMetricByState(r prometheus.Registerer, s *Silences, st SilenceState) prometheus.GaugeFunc { return promauto.With(r).NewGaugeFunc( prometheus.GaugeOpts{ Name: "alertmanager_silences", Help: "How many silences by state.", ConstLabels: prometheus.Labels{"state": string(st)}, }, func() float64 { count, err := s.CountState(context.Background(), st) if err != nil { s.logger.Error("Counting silences failed", "err", err) } return float64(count) }, ) } func newMetrics(r prometheus.Registerer, s *Silences) *metrics { m := &metrics{} m.gcDuration = promauto.With(r).NewSummary(prometheus.SummaryOpts{ Name: "alertmanager_silences_gc_duration_seconds", Help: "Duration of the last silence garbage collection cycle.", Objectives: map[float64]float64{}, }) m.gcErrorsTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_silences_gc_errors_total", Help: "How many silence GC errors were encountered.", }) m.snapshotDuration = promauto.With(r).NewSummary(prometheus.SummaryOpts{ Name: "alertmanager_silences_snapshot_duration_seconds", Help: "Duration of the last silence snapshot.", Objectives: map[float64]float64{}, }) m.snapshotSize = promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_silences_snapshot_size_bytes", Help: "Size of the last silence snapshot in bytes.", }) m.maintenanceTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_silences_maintenance_total", Help: "How many maintenances were executed for silences.", }) m.maintenanceErrorsTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_silences_maintenance_errors_total", Help: "How many maintenances were executed for silences that failed.", }) matcherCompileErrorsTotal := promauto.With(r).NewCounterVec( prometheus.CounterOpts{ Name: "alertmanager_silences_matcher_compile_errors_total", Help: "How many silence matcher compilations failed.", }, []string{"stage"}, ) m.matcherCompileIndexSilenceErrorsTotal = matcherCompileErrorsTotal.WithLabelValues("index") m.matcherCompileLoadSnapshotErrorsTotal = matcherCompileErrorsTotal.WithLabelValues("load_snapshot") m.queriesTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_silences_queries_total", Help: "How many silence queries were received.", }) m.queryErrorsTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_silences_query_errors_total", Help: "How many silence received queries did not succeed.", }) m.queryDuration = promauto.With(r).NewHistogram(prometheus.HistogramOpts{ Name: "alertmanager_silences_query_duration_seconds", Help: "Duration of silence query evaluation.", Buckets: prometheus.DefBuckets, NativeHistogramBucketFactor: 1.1, NativeHistogramMaxBucketNumber: 100, NativeHistogramMinResetDuration: 1 * time.Hour, }) m.queryScannedTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_silences_query_silences_scanned_total", Help: "How many silences were scanned during query evaluation.", }) m.querySkippedTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_silences_query_silences_skipped_total", Help: "How many silences were skipped during query evaluation using the version index.", }) m.propagatedMessagesTotal = promauto.With(r).NewCounter(prometheus.CounterOpts{ Name: "alertmanager_silences_gossip_messages_propagated_total", Help: "Number of received gossip messages that have been further gossiped.", }) if s != nil { m.silencesActive = newSilenceMetricByState(r, s, SilenceStateActive) m.silencesPending = newSilenceMetricByState(r, s, SilenceStatePending) m.silencesExpired = newSilenceMetricByState(r, s, SilenceStateExpired) m.stateSize = promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_silences_state_size", Help: "The number of silences in the state map.", }) m.matcherIndexSize = promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_silences_matcher_index_size", Help: "The number of entries in the matcher cache index.", }) m.versionIndexSize = promauto.With(r).NewGauge(prometheus.GaugeOpts{ Name: "alertmanager_silences_version_index_size", Help: "The number of entries in the version index.", }) } return m } // Options exposes configuration options for creating a new Silences object. // Its zero value is a safe default. type Options struct { // A snapshot file or reader from which the initial state is loaded. // None or only one of them must be set. SnapshotFile string SnapshotReader io.Reader // Retention time for newly created Silences. Silences may be // garbage collected after the given duration after they ended. Retention time.Duration Limits Limits // A logger used by background processing. Logger *slog.Logger Metrics prometheus.Registerer } func (o *Options) validate() error { if o.SnapshotFile != "" && o.SnapshotReader != nil { return errors.New("only one of SnapshotFile and SnapshotReader must be set") } return nil } // New returns a new Silences object with the given configuration. func New(o Options) (*Silences, error) { if err := o.validate(); err != nil { return nil, err } s := &Silences{ clock: quartz.NewReal(), mi: make(matcherIndex, 512), vi: make(versionIndex, 0, 512), logger: promslog.NewNopLogger(), retention: o.Retention, limits: o.Limits, broadcast: func([]byte) {}, st: state{}, } if o.Metrics == nil { return nil, errors.New("Options.Metrics is nil") } s.metrics = newMetrics(o.Metrics, s) if o.Logger != nil { s.logger = o.Logger } if o.SnapshotFile != "" { if r, err := os.Open(o.SnapshotFile); err != nil { if !os.IsNotExist(err) { return nil, err } s.logger.Debug("silences snapshot file doesn't exist", "err", err) } else { o.SnapshotReader = r defer r.Close() } } if o.SnapshotReader != nil { if err := s.loadSnapshot(o.SnapshotReader); err != nil { return s, err } } return s, nil } func (s *Silences) nowUTC() time.Time { return s.clock.Now().UTC() } // updateSizeMetrics updates the size metrics for state, matcher index, and version index. // Must be called while holding s.mtx. func (s *Silences) updateSizeMetrics() { if s.metrics != nil && s.metrics.stateSize != nil { s.metrics.stateSize.Set(float64(len(s.st))) s.metrics.matcherIndexSize.Set(float64(len(s.mi))) s.metrics.versionIndexSize.Set(float64(len(s.vi))) } } // Maintenance garbage collects the silence state at the given interval. If the snapshot // file is set, a snapshot is written to it afterwards. // Terminates on receiving from stopc. // If not nil, the last argument is an override for what to do as part of the maintenance - for advanced usage. func (s *Silences) Maintenance(interval time.Duration, snapf string, stopc <-chan struct{}, override MaintenanceFunc) { if interval == 0 || stopc == nil { s.logger.Error("interval or stop signal are missing - not running maintenance") return } t := s.clock.NewTicker(interval) defer t.Stop() var doMaintenance MaintenanceFunc doMaintenance = func() (int64, error) { var size int64 if _, err := s.GC(); err != nil { return size, err } if snapf == "" { return size, nil } f, err := openReplace(snapf) if err != nil { return size, err } if size, err = s.Snapshot(f); err != nil { f.Close() return size, err } return size, f.Close() } if override != nil { doMaintenance = override } runMaintenance := func(do MaintenanceFunc) error { s.metrics.maintenanceTotal.Inc() s.logger.Debug("Running maintenance") start := s.nowUTC() size, err := do() s.metrics.snapshotSize.Set(float64(size)) if err != nil { s.metrics.maintenanceErrorsTotal.Inc() return err } s.logger.Debug("Maintenance done", "duration", s.clock.Since(start), "size", size) return nil } Loop: for { select { case <-stopc: break Loop case <-t.C: if err := runMaintenance(doMaintenance); err != nil { s.logger.Error("Running maintenance failed", "err", err) } } } // No need for final maintenance if we don't want to snapshot. if snapf == "" { return } if err := runMaintenance(doMaintenance); err != nil { s.logger.Error("Creating shutdown snapshot failed", "err", err) } } // GC runs a garbage collection that removes silences that have ended longer // than the configured retention time ago. func (s *Silences) GC() (int, error) { start := time.Now() defer func() { s.metrics.gcDuration.Observe(time.Since(start).Seconds()) }() now := s.nowUTC() var n int var errs error s.mtx.Lock() defer s.mtx.Unlock() // During GC we will delete expired silences from the state map and the indices. // If between the last GC's deletion, and including any silences that were added // until now, we have more than 50% spare capacity, we want to reallocate to a smaller // slice, while still leaving some growth buffer. needsRealloc := cap(s.vi) > 1024 && len(s.vi) < cap(s.vi)/2 var targetVi versionIndex if needsRealloc { // Allocate new slice with growth buffer. newCap := max(len(s.vi)*5/4, 1024) targetVi = make(versionIndex, 0, newCap) } else { targetVi = s.vi[:0] } // Iterate state map directly (fast - no extra lookups). for _, sv := range s.vi { sil, ok := s.st[sv.id] expire := false if !ok { // Silence in version index but not in state - remove from version index and count error s.metrics.gcErrorsTotal.Inc() errs = errors.Join(errs, fmt.Errorf("silence %s in version index missing from state", sv.id)) // not adding to targetVi effectively removes it continue } if sil.ExpiresAt == nil || sil.ExpiresAt.AsTime().IsZero() { // Invalid expiration timestamp - remove silence and count error s.metrics.gcErrorsTotal.Inc() errs = errors.Join(errs, fmt.Errorf("silence %s has zero expiration timestamp", sil.Silence.Id)) expire = true } if expire || !sil.ExpiresAt.AsTime().After(now) { delete(s.st, sil.Silence.Id) delete(s.mi, sil.Silence.Id) n++ } else { targetVi = append(targetVi, sv) } } if !needsRealloc { // If we didn't reallocate, clear tail to prevent string pointer leaks clear(s.vi[len(targetVi):]) } s.vi = targetVi s.updateSizeMetrics() return n, errs } func validateMatcher(m *pb.Matcher) error { if !compat.IsValidLabelName(model.LabelName(m.Name)) { return fmt.Errorf("invalid label name %q", m.Name) } switch m.Type { case pb.Matcher_EQUAL, pb.Matcher_NOT_EQUAL: if !model.LabelValue(m.Pattern).IsValid() { return fmt.Errorf("invalid label value %q", m.Pattern) } case pb.Matcher_REGEXP, pb.Matcher_NOT_REGEXP: if _, err := regexp.Compile(m.Pattern); err != nil { return fmt.Errorf("invalid regular expression %q: %w", m.Pattern, err) } default: return fmt.Errorf("unknown matcher type %q", m.Type) } return nil } func matchesEmpty(m *pb.Matcher) bool { switch m.Type { case pb.Matcher_EQUAL: return m.Pattern == "" case pb.Matcher_REGEXP: matched, _ := regexp.MatchString(m.Pattern, "") return matched default: return false } } func validateSilence(s *pb.Silence) error { // Convert old-style Matchers to MatcherSets for backward compatibility postprocessUnmarshalledSilence(s) if len(s.MatcherSets) == 0 { return errors.New("at least one matcher set required") } for setIdx, ms := range s.MatcherSets { if len(ms.Matchers) == 0 { return fmt.Errorf("matcher set %d is empty", setIdx) } allMatchEmpty := true for i, m := range ms.Matchers { if err := validateMatcher(m); err != nil { return fmt.Errorf("invalid label matcher %d in set %d: %w", i, setIdx, err) } allMatchEmpty = allMatchEmpty && matchesEmpty(m) } if allMatchEmpty { return fmt.Errorf("matcher set %d: at least one matcher must not match the empty string", setIdx) } } if s.StartsAt == nil || s.StartsAt.AsTime().IsZero() { return errors.New("invalid zero start timestamp") } if s.EndsAt == nil || s.EndsAt.AsTime().IsZero() { return errors.New("invalid zero end timestamp") } if s.EndsAt.AsTime().Before(s.StartsAt.AsTime()) { return errors.New("end time must not be before start time") } return nil } // cloneSilence returns a copy of a silence. func cloneSilence(sil *pb.Silence) *pb.Silence { return proto.Clone(sil).(*pb.Silence) } func (s *Silences) checkSizeLimits(msil *pb.MeshSilence) error { if s.limits.MaxSilenceSizeBytes != nil { n := proto.Size(msil) if m := s.limits.MaxSilenceSizeBytes(); m > 0 && n > m { return fmt.Errorf("silence exceeded maximum size: %d bytes (limit: %d bytes)", n, m) } } return nil } func (s *Silences) indexSilence(sil *pb.Silence) { s.version++ s.vi.add(s.version, sil.Id) _, err := s.mi.add(sil) if err != nil { s.metrics.matcherCompileIndexSilenceErrorsTotal.Inc() s.logger.Error("Failed to compile silence matchers", "silence_id", sil.Id, "err", err) } } func (s *Silences) getSilence(id string) (*pb.Silence, bool) { msil, ok := s.st[id] if !ok { return nil, false } return msil.Silence, true } func (s *Silences) toMeshSilence(sil *pb.Silence) *pb.MeshSilence { return &pb.MeshSilence{ Silence: sil, ExpiresAt: timestamppb.New(sil.EndsAt.AsTime().Add(s.retention)), } } func (s *Silences) setSilence(msil *pb.MeshSilence, now time.Time) error { b, err := marshalMeshSilence(msil) if err != nil { return err } _, added := s.st.merge(msil, now) if added { s.indexSilence(msil.Silence) s.updateSizeMetrics() } s.broadcast(b) return nil } // Set the specified silence. If a silence with the ID already exists and the modification // modifies history, the old silence gets expired and a new one is created. func (s *Silences) Set(ctx context.Context, sil *pb.Silence) error { _, span := tracer.Start(ctx, "silences.Set") defer span.End() now := s.nowUTC() if sil.StartsAt == nil || sil.StartsAt.AsTime().IsZero() { sil.StartsAt = timestamppb.New(now) } if err := validateSilence(sil); err != nil { return fmt.Errorf("invalid silence: %w", err) } s.mtx.Lock() defer s.mtx.Unlock() prev, ok := s.getSilence(sil.Id) if sil.Id != "" && !ok { return ErrNotFound } if ok && canUpdate(prev, sil, now) { sil.UpdatedAt = timestamppb.New(now) msil := s.toMeshSilence(sil) if err := s.checkSizeLimits(msil); err != nil { return err } return s.setSilence(msil, now) } // If we got here it's either a new silence or a replacing one (which would // also create a new silence) so we need to make sure we have capacity for // the new silence. if s.limits.MaxSilences != nil { if m := s.limits.MaxSilences(); m > 0 && len(s.st)+1 > m { return fmt.Errorf("exceeded maximum number of silences: %d (limit: %d)", len(s.st), m) } } uid, err := uuid.NewRandom() if err != nil { return fmt.Errorf("generate uuid: %w", err) } sil.Id = uid.String() if sil.StartsAt.AsTime().Before(now) { sil.StartsAt = timestamppb.New(now) } sil.UpdatedAt = timestamppb.New(now) msil := s.toMeshSilence(sil) if err := s.checkSizeLimits(msil); err != nil { return err } if ok && getState(prev, s.nowUTC()) != SilenceStateExpired { // We cannot update the silence, expire the old one to leave a history of // the silence before modification. if err := s.expire(prev.Id); err != nil { return fmt.Errorf("expire previous silence: %w", err) } } return s.setSilence(msil, now) } // canUpdate returns true if silence a can be updated to b without // affecting the historic view of silencing. func canUpdate(a, b *pb.Silence, now time.Time) bool { if !slices.EqualFunc(a.MatcherSets, b.MatcherSets, func(x, y *pb.MatcherSet) bool { return proto.Equal(x, y) }) { return false } // Allowed timestamp modifications depend on the current time. switch st := getState(a, now); st { case SilenceStateActive: if a.StartsAt.AsTime().Unix() != b.StartsAt.AsTime().Unix() { return false } if b.EndsAt.AsTime().Before(now) { return false } case SilenceStatePending: if b.StartsAt.AsTime().Before(now) { return false } case SilenceStateExpired: return false default: panic("unknown silence state") } return true } // Expire the silence with the given ID immediately. func (s *Silences) Expire(ctx context.Context, id string) error { s.mtx.Lock() defer s.mtx.Unlock() _, span := tracer.Start(ctx, "silences.Expire", trace.WithAttributes( attribute.String("alerting.silence.id", id), )) defer span.End() return s.expire(id) } // Expire the silence with the given ID immediately. // It is idempotent, nil is returned if the silence already expired before it is GC'd. // If the silence is not found an error is returned. func (s *Silences) expire(id string) error { sil, ok := s.getSilence(id) if !ok { return ErrNotFound } sil = cloneSilence(sil) now := s.nowUTC() switch getState(sil, now) { case SilenceStateExpired: return nil case SilenceStateActive: sil.EndsAt = timestamppb.New(now) case SilenceStatePending: // Set both to now to make Silence move to "expired" state sil.StartsAt = timestamppb.New(now) sil.EndsAt = timestamppb.New(now) } sil.UpdatedAt = timestamppb.New(now) return s.setSilence(s.toMeshSilence(sil), now) } // QueryParam expresses parameters along which silences are queried. type QueryParam func(*query) error type query struct { ids []string since *int filters []silenceFilter } // silenceFilter is a function that returns true if a silence // should be dropped from a result set for a given time. type silenceFilter func(*pb.Silence, *Silences, time.Time) (bool, error) // QIDs configures a query to select the given silence IDs. func QIDs(ids ...string) QueryParam { return func(q *query) error { if len(ids) == 0 { return errors.New("QIDs filter must have at least one id") } if q.since != nil { return fmt.Errorf("QSince cannot be used with QIDs") } q.ids = append(q.ids, ids...) return nil } } // QSince filters silences to those created after the provided version. This can be used to // scan all silences which have been added after the provided version to incrementally update // a cache. func QSince(version int) QueryParam { return func(q *query) error { if len(q.ids) != 0 { return fmt.Errorf("QSince cannot be used with QIDs") } q.since = &version return nil } } // QMatches returns silences that match the given label set. func QMatches(set model.LabelSet) QueryParam { return func(q *query) error { f := func(sil *pb.Silence, s *Silences, _ time.Time) (bool, error) { m, err := s.mi.get(sil) if err != nil { return true, err } return m.Matches(set), nil } q.filters = append(q.filters, f) return nil } } // getState returns a silence's SilenceState at the given timestamp. func getState(sil *pb.Silence, ts time.Time) SilenceState { if ts.Before(sil.StartsAt.AsTime()) { return SilenceStatePending } if ts.After(sil.EndsAt.AsTime()) { return SilenceStateExpired } return SilenceStateActive } // QState filters queried silences by the given states. func QState(states ...SilenceState) QueryParam { return func(q *query) error { f := func(sil *pb.Silence, _ *Silences, now time.Time) (bool, error) { s := getState(sil, now) if slices.Contains(states, s) { return true, nil } return false, nil } q.filters = append(q.filters, f) return nil } } // QueryOne queries with the given parameters and returns the first result. // Returns ErrNotFound if the query result is empty. func (s *Silences) QueryOne(ctx context.Context, params ...QueryParam) (*pb.Silence, error) { _, span := tracer.Start(ctx, "silence.Silences.QueryOne", trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() res, _, err := s.Query(ctx, params...) if err != nil { return nil, err } if len(res) == 0 { return nil, ErrNotFound } return res[0], nil } // Query for silences based on the given query parameters. It returns the // resulting silences and the state version the result is based on. func (s *Silences) Query(ctx context.Context, params ...QueryParam) ([]*pb.Silence, int, error) { _, span := tracer.Start(ctx, "silence.Silences.Query", trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() s.metrics.queriesTotal.Inc() defer prometheus.NewTimer(s.metrics.queryDuration).ObserveDuration() q := &query{} for _, p := range params { if err := p(q); err != nil { s.metrics.queryErrorsTotal.Inc() return nil, s.Version(), err } } sils, version, err := s.query(q, s.nowUTC()) if err != nil { s.metrics.queryErrorsTotal.Inc() } return sils, version, err } // Version of the silence state. func (s *Silences) Version() int { s.mtx.RLock() defer s.mtx.RUnlock() return s.version } // CountState counts silences by state. func (s *Silences) CountState(ctx context.Context, states ...SilenceState) (int, error) { _, span := tracer.Start(ctx, "silence.Silences.CountState", trace.WithSpanKind(trace.SpanKindInternal), ) defer span.End() // This could probably be optimized. sils, _, err := s.Query(ctx, QState(states...)) if err != nil { return -1, err } return len(sils), nil } // query executes the given query and returns the resulting silences. func (s *Silences) query(q *query, now time.Time) ([]*pb.Silence, int, error) { var res []*pb.Silence var err error scannedCount := 0 defer func() { s.metrics.queryScannedTotal.Add(float64(scannedCount)) }() // appendIfFiltersMatch appends the given silence to the result set // if it matches all filters in the query. In case of a filter error, the error is returned. appendIfFiltersMatch := func(res []*pb.Silence, sil *pb.Silence) ([]*pb.Silence, error) { for _, f := range q.filters { matches, err := f(sil, s, now) // In case of error return it immediately and don't process further filters. if err != nil { return res, err } // If one filter doesn't match, return the result unchanged, immediately. if !matches { return res, nil } } // All filters matched, append the silence to the result. return append(res, cloneSilence(sil)), nil } // Preallocate result slice if we have IDs (if not this will be a no-op) res = make([]*pb.Silence, 0, len(q.ids)) // Take a read lock on Silences: we can read but not modify the Silences struct. s.mtx.RLock() defer s.mtx.RUnlock() // If we have IDs, only consider the silences with the given IDs, if they exist. if q.ids != nil { for _, id := range q.ids { if sil, ok := s.st[id]; ok { scannedCount++ // append the silence to the results if it satisfies the query. res, err = appendIfFiltersMatch(res, sil.Silence) if err != nil { return nil, s.version, err } } } } else { start := 0 if q.since != nil { var found bool start, found = s.vi.findVersionGreaterThan(*q.since) // no new silences, nothing to do if !found { return res, s.version, nil } // Track how many silences we skipped using the version index. s.metrics.querySkippedTotal.Add(float64(start)) } // Preallocate result slice with a reasonable capacity. If we are // scanning less than 64 silences, we can allocate that many, // otherwise we just allocate 64 and let it grow as needed. res = make([]*pb.Silence, 0, min(64, len(s.vi)-start)) for _, sv := range s.vi[start:] { scannedCount++ sil := s.st[sv.id] // append the silence to the results if it satisfies the query. res, err = appendIfFiltersMatch(res, sil.Silence) if err != nil { return nil, s.version, err } } } return res, s.version, nil } // loadSnapshot loads a snapshot generated by Snapshot() into the state. // Any previous state is wiped. func (s *Silences) loadSnapshot(r io.Reader) error { st, err := decodeState(r) if err != nil { return err } mi := make(matcherIndex, len(st)) // for a map, len is ok as a size hint. // Choose new version index capacity with some growth buffer. vi := make(versionIndex, 0, max(len(st)*5/4, 1024)) for _, e := range st { // Comments list was moved to a single comment. Upgrade on loading the snapshot. if len(e.Silence.Comments) > 0 { e.Silence.Comment = e.Silence.Comments[0].Comment e.Silence.CreatedBy = e.Silence.Comments[0].Author e.Silence.Comments = nil } // Add to matcher index, and only if successful, to the new state. if _, err := mi.add(e.Silence); err != nil { s.metrics.matcherCompileLoadSnapshotErrorsTotal.Inc() s.logger.Error("Failed to compile silence matchers during snapshot load", "silence_id", e.Silence.Id, "err", err) } else { st[e.Silence.Id] = e vi.add(s.version+1, e.Silence.Id) } } s.mtx.Lock() s.st = st s.mi = mi s.vi = vi s.version++ s.updateSizeMetrics() s.mtx.Unlock() return nil } // Snapshot writes the full internal state into the writer and returns the number of bytes // written. func (s *Silences) Snapshot(w io.Writer) (int64, error) { start := time.Now() defer func() { s.metrics.snapshotDuration.Observe(time.Since(start).Seconds()) }() s.mtx.RLock() defer s.mtx.RUnlock() b, err := s.st.MarshalBinary() if err != nil { return 0, err } return io.Copy(w, bytes.NewReader(b)) } // MarshalBinary serializes all silences. func (s *Silences) MarshalBinary() ([]byte, error) { s.mtx.RLock() defer s.mtx.RUnlock() return s.st.MarshalBinary() } // Merge merges silence state received from the cluster with the local state. func (s *Silences) Merge(b []byte) error { st, err := decodeState(bytes.NewReader(b)) if err != nil { return err } s.mtx.Lock() defer s.mtx.Unlock() now := s.nowUTC() for _, e := range st { merged, added := s.st.merge(e, now) if merged { if added { s.indexSilence(e.Silence) } if !cluster.OversizedMessage(b) { // If this is the first we've seen the message and it's // not oversized, gossip it to other nodes. We don't // propagate oversized messages because they're sent to // all nodes already. s.broadcast(b) s.metrics.propagatedMessagesTotal.Inc() s.logger.Debug("Gossiping new silence", "silence", e) } } } s.updateSizeMetrics() return nil } // SetBroadcast sets the provided function as the one creating data to be // broadcast. func (s *Silences) SetBroadcast(f func([]byte)) { s.mtx.Lock() s.broadcast = f s.mtx.Unlock() } type state map[string]*pb.MeshSilence // merge returns two bools: the first is true when merge caused a state change. The second // is true if that state change added a new silence. In other words, the second return is // true whenever a silence with a new ID has been added to the state as a result of merge. func (s state) merge(e *pb.MeshSilence, now time.Time) (bool, bool) { id := e.Silence.Id if e.ExpiresAt.AsTime().Before(now) { return false, false } // Comments list was moved to a single comment. Apply upgrade // on silences received from peers. if len(e.Silence.Comments) > 0 { e.Silence.Comment = e.Silence.Comments[0].Comment e.Silence.CreatedBy = e.Silence.Comments[0].Author e.Silence.Comments = nil } prev, ok := s[id] if !ok || prev.Silence.UpdatedAt.AsTime().Before(e.Silence.UpdatedAt.AsTime()) { s[id] = e return true, !ok } return false, false } func (s state) MarshalBinary() ([]byte, error) { var buf bytes.Buffer for _, e := range s { if _, err := protodelim.MarshalTo(&buf, e); err != nil { return nil, err } } return buf.Bytes(), nil } func decodeState(r io.Reader) (state, error) { st := state{} br := bufio.NewReader(r) for { var s pb.MeshSilence err := protodelim.UnmarshalFrom(br, &s) if err == nil { if s.Silence == nil { return nil, ErrInvalidState } postprocessUnmarshalledSilence(s.Silence) st[s.Silence.Id] = &s continue } if errors.Is(err, io.EOF) { break } return nil, err } return st, nil } // prepareSilenceForMarshalling prepares a silence for marshalling by copying // the first matcher set to the matchers field for backward compatibility with // older alertmanager versions. func prepareSilenceForMarshalling(sil *pb.Silence) { if len(sil.MatcherSets) > 0 { sil.Matchers = sil.MatcherSets[0].Matchers } } // postprocessUnmarshalledSilence processes a silence after unmarshalling by // moving matchers to MatcherSets if needed for backward compatibility. func postprocessUnmarshalledSilence(sil *pb.Silence) { // maintain compatibility with older versions of Alertmanager // if the silence was serialized with the old format we need to move the matchers from sil.Matchers // to sil.MatcherSets if len(sil.MatcherSets) == 0 && len(sil.Matchers) > 0 { sil.MatcherSets = append(sil.MatcherSets, &pb.MatcherSet{Matchers: sil.Matchers}) } sil.Matchers = nil } func marshalMeshSilence(e *pb.MeshSilence) ([]byte, error) { // Make a copy to avoid modifying the original silence meshCopy := &pb.MeshSilence{ Silence: cloneSilence(e.Silence), ExpiresAt: e.ExpiresAt, } prepareSilenceForMarshalling(meshCopy.Silence) var buf bytes.Buffer if _, err := protodelim.MarshalTo(&buf, meshCopy); err != nil { return nil, err } return buf.Bytes(), nil } // replaceFile wraps a file that is moved to another filename on closing. type replaceFile struct { *os.File filename string } func (f *replaceFile) Close() error { if err := f.Sync(); err != nil { return err } if err := f.File.Close(); err != nil { return err } return os.Rename(f.Name(), f.filename) } // openReplace opens a new temporary file that is moved to filename on closing. func openReplace(filename string) (*replaceFile, error) { tmpFilename := fmt.Sprintf("%s.%x", filename, uint64(rand.Int63())) f, err := os.Create(tmpFilename) if err != nil { return nil, err } rf := &replaceFile{ File: f, filename: filename, } return rf, nil } ================================================ FILE: silence/silence_bench_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package silence import ( "context" "fmt" "math/rand" "strconv" "sync" "testing" "time" "github.com/coder/quartz" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/alertmanager/types" ) // BenchmarkMutes benchmarks the Mutes method for the Muter interface for // different numbers of silences with varying match ratios. func BenchmarkMutes(b *testing.B) { b.Run("0 total, 0 matching", func(b *testing.B) { benchmarkMutes(b, 0, 0) }) b.Run("1 total, 1 matching", func(b *testing.B) { benchmarkMutes(b, 1, 1) }) b.Run("100 total, 10 matching", func(b *testing.B) { benchmarkMutes(b, 100, 10) }) b.Run("1000 total, 1 matching", func(b *testing.B) { benchmarkMutes(b, 1000, 1) }) b.Run("1000 total, 10 matching", func(b *testing.B) { benchmarkMutes(b, 1000, 10) }) b.Run("1000 total, 100 matching", func(b *testing.B) { benchmarkMutes(b, 1000, 100) }) b.Run("10000 total, 0 matching", func(b *testing.B) { benchmarkMutes(b, 10000, 10) }) b.Run("10000 total, 10 matching", func(b *testing.B) { benchmarkMutes(b, 10000, 10) }) b.Run("10000 total, 1000 matching", func(b *testing.B) { benchmarkMutes(b, 10000, 1000) }) } func benchmarkMutes(b *testing.B, totalSilences, matchingSilences int) { require.LessOrEqual(b, matchingSilences, totalSilences) silences, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(b, err) clock := quartz.NewMock(b).WithLogger(quartz.NoOpLogger) silences.clock = clock now := clock.Now() // Calculate interval to intersperse matching silences var interval int if matchingSilences > 0 { interval = totalSilences / matchingSilences } // Create silences with matching ones interspersed throughout matchingCreated := 0 for i := range totalSilences { var s *silencepb.Silence // Create matching silences at calculated intervals, but make sure there are always enough if matchingCreated < matchingSilences && (i%interval == 0 || i == totalSilences-matchingSilences+matchingCreated) { // Create a matching silence s = &silencepb.Silence{ Matchers: []*silencepb.Matcher{{ Type: silencepb.Matcher_EQUAL, Name: "foo", Pattern: "bar", }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Minute)), } matchingCreated++ } else { // Create a non-matching silence s = &silencepb.Silence{ Matchers: []*silencepb.Matcher{{ Type: silencepb.Matcher_EQUAL, Name: "job", Pattern: "job" + strconv.Itoa(i), }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Minute)), } } require.NoError(b, silences.Set(b.Context(), s)) } m := types.NewMarker(prometheus.NewRegistry()) s := NewSilencer(silences, m, promslog.NewNopLogger()) for b.Loop() { s.Mutes(context.Background(), model.LabelSet{"foo": "bar"}) } b.StopTimer() // The alert should be marked as silenced for each matching silence. activeIDs, silenced := m.Silenced(model.LabelSet{"foo": "bar"}.Fingerprint()) require.True(b, silenced || matchingSilences == 0) require.Len(b, activeIDs, matchingSilences) } // BenchmarkMutesIncremental tests the incremental query optimization when a small // number of silences are added to a large existing set. This measures the real-world // scenario that the QSince optimization is designed for. func BenchmarkMutesIncremental(b *testing.B) { cases := []struct { name string baseSize int }{ {"1000 base silences", 1000}, {"3000 base silences", 3000}, {"7000 base silences", 7000}, {"10000 base silences", 10000}, } for _, tc := range cases { b.Run(tc.name, func(b *testing.B) { silences, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(b, err) clock := quartz.NewMock(b).WithLogger(quartz.NoOpLogger) silences.clock = clock now := clock.Now() // Create base set of silences - most don't match, some do // This simulates a realistic production scenario // Intersperse matching silences throughout the base set for i := 0; i < tc.baseSize; i++ { var s *silencepb.Silence if i%2000 == 0 && i > 0 { // Sprinkle 1 silence matching every 2000 s = &silencepb.Silence{ Matchers: []*silencepb.Matcher{ { Type: silencepb.Matcher_EQUAL, Name: "service", Pattern: "test", }, { Type: silencepb.Matcher_EQUAL, Name: "instance", Pattern: "instance1", }, }, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Hour)), } } else { s = &silencepb.Silence{ Matchers: []*silencepb.Matcher{{ Type: silencepb.Matcher_EQUAL, Name: "job", Pattern: "job" + strconv.Itoa(i), }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Hour)), } } require.NoError(b, silences.Set(b.Context(), s)) } marker := types.NewMarker(prometheus.NewRegistry()) silencer := NewSilencer(silences, marker, promslog.NewNopLogger()) // Warm up: Establish cache state (cachedEntry.version = current version) // This simulates a system that has been running for a while lset := model.LabelSet{"service": "test", "instance": "instance1"} silencer.Mutes(context.Background(), lset) // Benchmark: Measure Mutes() performance with incremental additions // Every other iteration adds 1 new silence, all iterations call Mutes() // This simulates realistic traffic with a mix of incremental and cached queries // With QSince optimization, this should only scan new silences when added b.ResetTimer() iteration := 0 for b.Loop() { // Don't measure the Set() time, only Mutes() b.StopTimer() // Add one new silence every other iteration to simulate realistic traffic // where Mutes() is sometimes called without new silences if iteration%2 == 0 { var s *silencepb.Silence if iteration%20 == 0 && iteration > 0 { // Only 1 in 20 silences matches the labelset s = &silencepb.Silence{ Matchers: []*silencepb.Matcher{ { Type: silencepb.Matcher_EQUAL, Name: "service", Pattern: "test", }, { Type: silencepb.Matcher_EQUAL, Name: "instance", Pattern: "instance1", }, }, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Hour)), } } else { // Most don't match s = &silencepb.Silence{ Matchers: []*silencepb.Matcher{{ Type: silencepb.Matcher_EQUAL, Name: "instance", Pattern: "host" + strconv.Itoa(iteration), }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Hour)), } } require.NoError(b, silences.Set(b.Context(), s)) } b.StartTimer() // Now query - should use incremental path or cached paths silencer.Mutes(context.Background(), lset) iteration++ } }) } } // BenchmarkQuery benchmarks the Query method for the Silences struct // for different numbers of silences. Not all silences match the query // to prevent compiler and runtime optimizations from affecting the benchmarks. func BenchmarkQuery(b *testing.B) { b.Run("100 silences", func(b *testing.B) { benchmarkQuery(b, 100) }) b.Run("1000 silences", func(b *testing.B) { benchmarkQuery(b, 1000) }) b.Run("10000 silences", func(b *testing.B) { benchmarkQuery(b, 10000) }) } func benchmarkQuery(b *testing.B, numSilences int) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(b, err) clock := quartz.NewMock(b).WithLogger(quartz.NoOpLogger) s.clock = clock now := clock.Now() lset := model.LabelSet{"aaaa": "AAAA", "bbbb": "BBBB", "cccc": "CCCC"} // Create silences using Set() to properly populate indices for i := range numSilences { id := strconv.Itoa(i) // Include an offset to avoid optimizations. patA := "A{4}|" + id patB := id // Does not match. if i%10 == 0 { // Every 10th time, have an actually matching pattern. patB = "B(B|C)B.|" + id } sil := &silencepb.Silence{ Matchers: []*silencepb.Matcher{ {Type: silencepb.Matcher_REGEXP, Name: "aaaa", Pattern: patA}, {Type: silencepb.Matcher_REGEXP, Name: "bbbb", Pattern: patB}, }, StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } require.NoError(b, s.Set(b.Context(), sil)) } // Run things once to populate the matcherCache. sils, _, err := s.Query( b.Context(), QState(SilenceStateActive), QMatches(lset), ) require.NoError(b, err) require.Len(b, sils, numSilences/10) for b.Loop() { sils, _, err := s.Query( b.Context(), QState(SilenceStateActive), QMatches(lset), ) require.NoError(b, err) require.Len(b, sils, numSilences/10) } } // BenchmarkQueryParallel benchmarks concurrent queries to demonstrate // the performance improvement from using read locks (RLock) instead of // write locks (Lock). With the pre-compiled matcher cache, multiple // queries can now execute in parallel. func BenchmarkQueryParallel(b *testing.B) { b.Run("100 silences", func(b *testing.B) { benchmarkQueryParallel(b, 100) }) b.Run("1000 silences", func(b *testing.B) { benchmarkQueryParallel(b, 1000) }) b.Run("10000 silences", func(b *testing.B) { benchmarkQueryParallel(b, 10000) }) } func benchmarkQueryParallel(b *testing.B, numSilences int) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(b, err) clock := quartz.NewMock(b).WithLogger(quartz.NoOpLogger) s.clock = clock now := clock.Now() lset := model.LabelSet{"aaaa": "AAAA", "bbbb": "BBBB", "cccc": "CCCC"} // Create silences with pre-compiled matchers for i := range numSilences { id := strconv.Itoa(i) patA := "A{4}|" + id patB := id if i%10 == 0 { patB = "B(B|C)B.|" + id } sil := &silencepb.Silence{ Matchers: []*silencepb.Matcher{ {Type: silencepb.Matcher_REGEXP, Name: "aaaa", Pattern: patA}, {Type: silencepb.Matcher_REGEXP, Name: "bbbb", Pattern: patB}, }, StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } require.NoError(b, s.Set(b.Context(), sil)) } // Verify initial query works sils, _, err := s.Query( b.Context(), QState(SilenceStateActive), QMatches(lset), ) require.NoError(b, err) require.Len(b, sils, numSilences/10) b.ResetTimer() // Run queries in parallel across multiple goroutines b.RunParallel(func(pb *testing.PB) { for pb.Next() { sils, _, err := s.Query( b.Context(), QState(SilenceStateActive), QMatches(lset), ) if err != nil { b.Error(err) } if len(sils) != numSilences/10 { b.Errorf("expected %d silences, got %d", numSilences/10, len(sils)) } } }) } // BenchmarkQueryWithConcurrentAdds benchmarks the behavior when queries // are running concurrently with silence additions. This demonstrates how // the system handles read-heavy workloads with occasional writes. func BenchmarkQueryWithConcurrentAdds(b *testing.B) { b.Run("1000 initial silences, 10% add rate", func(b *testing.B) { benchmarkQueryWithConcurrentAdds(b, 1000, 0.1) }) b.Run("1000 initial silences, 1% add rate", func(b *testing.B) { benchmarkQueryWithConcurrentAdds(b, 1000, 0.01) }) b.Run("1000 initial silences, 0.1% add rate", func(b *testing.B) { benchmarkQueryWithConcurrentAdds(b, 1000, 0.001) }) b.Run("10000 initial silences, 1% add rate", func(b *testing.B) { benchmarkQueryWithConcurrentAdds(b, 10000, 0.01) }) b.Run("10000 initial silences, 0.1% add rate", func(b *testing.B) { benchmarkQueryWithConcurrentAdds(b, 10000, 0.001) }) } func benchmarkQueryWithConcurrentAdds(b *testing.B, initialSilences int, addRatio float64) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(b, err) clock := quartz.NewMock(b).WithLogger(quartz.NoOpLogger) s.clock = clock now := clock.Now() lset := model.LabelSet{"aaaa": "AAAA", "bbbb": "BBBB", "cccc": "CCCC"} // Create initial silences for i := range initialSilences { id := strconv.Itoa(i) patA := "A{4}|" + id patB := id if i%10 == 0 { patB = "B(B|C)B.|" + id } sil := &silencepb.Silence{ Matchers: []*silencepb.Matcher{ {Type: silencepb.Matcher_REGEXP, Name: "aaaa", Pattern: patA}, {Type: silencepb.Matcher_REGEXP, Name: "bbbb", Pattern: patB}, }, StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } require.NoError(b, s.Set(b.Context(), sil)) } var addCounter int var mu sync.Mutex b.ResetTimer() // Run parallel operations b.RunParallel(func(pb *testing.PB) { for pb.Next() { // Determine if this iteration should add a silence mu.Lock() shouldAdd := float64(addCounter) < float64(b.N)*addRatio if shouldAdd { addCounter++ } localCounter := addCounter + initialSilences mu.Unlock() if shouldAdd { // Add a new silence id := strconv.Itoa(localCounter) patA := "A{4}|" + id patB := "B(B|C)B.|" + id sil := &silencepb.Silence{ Matchers: []*silencepb.Matcher{ {Type: silencepb.Matcher_REGEXP, Name: "aaaa", Pattern: patA}, {Type: silencepb.Matcher_REGEXP, Name: "bbbb", Pattern: patB}, }, StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } if err := s.Set(b.Context(), sil); err != nil { b.Error(err) } } else { // Query silences (the common operation) _, _, err := s.Query( b.Context(), QState(SilenceStateActive), QMatches(lset), ) if err != nil { b.Error(err) } } } }) } // BenchmarkMutesParallel benchmarks concurrent Mutes calls to demonstrate // the performance improvement from parallel query execution. func BenchmarkMutesParallel(b *testing.B) { b.Run("100 silences", func(b *testing.B) { benchmarkMutesParallel(b, 100) }) b.Run("1000 silences", func(b *testing.B) { benchmarkMutesParallel(b, 1000) }) b.Run("10000 silences", func(b *testing.B) { benchmarkMutesParallel(b, 10000) }) } func benchmarkMutesParallel(b *testing.B, numSilences int) { silences, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(b, err) clock := quartz.NewMock(b).WithLogger(quartz.NoOpLogger) silences.clock = clock now := clock.Now() // Create silences that will match the alert for range numSilences { s := &silencepb.Silence{ Matchers: []*silencepb.Matcher{{ Type: silencepb.Matcher_EQUAL, Name: "foo", Pattern: "bar", }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Minute)), } require.NoError(b, silences.Set(b.Context(), s)) } m := types.NewMarker(prometheus.NewRegistry()) silencer := NewSilencer(silences, m, promslog.NewNopLogger()) b.ResetTimer() // Run Mutes in parallel b.RunParallel(func(pb *testing.PB) { for pb.Next() { silencer.Mutes(b.Context(), model.LabelSet{"foo": "bar"}) } }) } // BenchmarkGC benchmarks the garbage collection performance for different // numbers of silences and different ratios of expired silences. func BenchmarkGC(b *testing.B) { b.Run("1000 silences, 0% expired", func(b *testing.B) { benchmarkGC(b, 1000, 0.0) }) b.Run("1000 silences, 30% expired", func(b *testing.B) { benchmarkGC(b, 1000, 0.3) }) b.Run("1000 silences, 80% expired", func(b *testing.B) { benchmarkGC(b, 1000, 0.8) }) b.Run("10000 silences, 0% expired", func(b *testing.B) { benchmarkGC(b, 10000, 0.0) }) b.Run("10000 silences, 10% expired", func(b *testing.B) { benchmarkGC(b, 10000, 0.1) }) b.Run("10000 silences, 50% expired", func(b *testing.B) { benchmarkGC(b, 10000, 0.5) }) b.Run("10000 silences, 80% expired", func(b *testing.B) { benchmarkGC(b, 10000, 0.8) }) } func benchmarkGC(b *testing.B, numSilences int, expiredRatio float64) { b.ReportAllocs() clock := quartz.NewMock(b).WithLogger(quartz.NoOpLogger) now := clock.Now() numExpired := int(float64(numSilences) * expiredRatio) numActive := numSilences - numExpired matchers := []*silencepb.Matcher{{ Type: silencepb.Matcher_EQUAL, Name: "foo", Pattern: "bar", }} startTime := timestamppb.New(now.Add(-2 * time.Hour)) updateTime := timestamppb.New(now.Add(-2 * time.Hour)) endTime := timestamppb.New(now.Add(-time.Hour)) expireTime := timestamppb.New(now.Add(-time.Minute)) activeTime := timestamppb.New(now.Add(2 * time.Hour)) sils := make([]*silencepb.MeshSilence, 0, numSilences) for _, j := range rand.Perm(numSilences) { if j < numExpired { sil := &silencepb.MeshSilence{ Silence: &silencepb.Silence{ Id: fmt.Sprintf("expired-%d", j), Matchers: matchers, StartsAt: startTime, EndsAt: endTime, UpdatedAt: updateTime, }, ExpiresAt: expireTime, } sils = append(sils, sil) } else { sil := &silencepb.MeshSilence{ Silence: &silencepb.Silence{ Id: fmt.Sprintf("active-%d", j), Matchers: matchers, StartsAt: startTime, EndsAt: endTime, UpdatedAt: updateTime, }, ExpiresAt: activeTime, } sils = append(sils, sil) } } b.ResetTimer() for b.Loop() { b.StopTimer() s, err := New(Options{ Metrics: prometheus.NewRegistry(), }) require.NoError(b, err) s.clock = clock for _, sil := range sils { s.st[sil.Silence.Id] = sil s.indexSilence(sil.Silence) } b.StartTimer() n1, err := s.GC() require.NoError(b, err) n2, err := s.GC() require.NoError(b, err) b.StopTimer() require.NoError(b, err) require.Equal(b, numExpired, n1) require.Equal(b, 0, n2) require.Len(b, s.st, numActive) require.Len(b, s.mi, numActive) b.StartTimer() } } ================================================ FILE: silence/silence_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package silence import ( "bytes" "fmt" "math/rand" "os" "runtime" "sort" "strings" "sync" "sync/atomic" "testing" "time" "github.com/coder/quartz" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/testutil" "github.com/prometheus/common/model" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "google.golang.org/protobuf/encoding/protodelim" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/prometheus/alertmanager/featurecontrol" "github.com/prometheus/alertmanager/matcher/compat" pb "github.com/prometheus/alertmanager/silence/silencepb" "github.com/prometheus/alertmanager/types" ) func checkErr(t *testing.T, expected string, got error) { t.Helper() if expected == "" { require.NoError(t, got) return } if got == nil { t.Errorf("expected error containing %q but got none", expected) return } require.Contains(t, got.Error(), expected) } // requireStatesEqual compares two silence states using proto.Equal for proper protobuf comparison. func requireStatesEqual(t *testing.T, expected, actual state, msgAndArgs ...any) { t.Helper() require.Len(t, actual, len(expected), msgAndArgs...) for id, expectedSil := range expected { actualSil, ok := actual[id] require.True(t, ok, "silence %s missing from actual state", id) require.True(t, proto.Equal(expectedSil, actualSil), "silence %s mismatch: expected %v, got %v", id, expectedSil, actualSil) } } func TestOptionsValidate(t *testing.T) { cases := []struct { options *Options err string }{ { options: &Options{ Metrics: prometheus.NewRegistry(), SnapshotReader: &bytes.Buffer{}, }, }, { options: &Options{ Metrics: prometheus.NewRegistry(), SnapshotFile: "test.bkp", }, }, { options: &Options{ Metrics: prometheus.NewRegistry(), SnapshotFile: "test bkp", SnapshotReader: &bytes.Buffer{}, }, err: "only one of SnapshotFile and SnapshotReader must be set", }, } for _, c := range cases { checkErr(t, c.err, c.options.validate()) } } func TestSilenceGCOverTime(t *testing.T) { t.Run("GC does not remove active silences", func(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) s.clock = quartz.NewMock(t) now := s.nowUTC() initialState := state{ "1": &pb.MeshSilence{Silence: &pb.Silence{Id: "1"}, ExpiresAt: timestamppb.New(now)}, "2": &pb.MeshSilence{Silence: &pb.Silence{Id: "2"}, ExpiresAt: timestamppb.New(now.Add(-time.Second))}, "3": &pb.MeshSilence{Silence: &pb.Silence{Id: "3"}, ExpiresAt: timestamppb.New(now.Add(time.Second))}, } for _, sil := range initialState { s.st[sil.Silence.Id] = sil s.indexSilence(sil.Silence) } want := state{ "3": &pb.MeshSilence{Silence: &pb.Silence{Id: "3"}, ExpiresAt: timestamppb.New(now.Add(time.Second))}, } n, err := s.GC() require.NoError(t, err) require.Equal(t, 2, n) requireStatesEqual(t, want, s.st) }) t.Run("GC does not leak cache entries", func(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock sil1 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{ Type: pb.Matcher_EQUAL, Name: "foo", Pattern: "bar", }}, }}, StartsAt: timestamppb.New(clock.Now()), EndsAt: timestamppb.New(clock.Now().Add(time.Minute)), } require.NoError(t, s.Set(t.Context(), sil1)) require.Len(t, s.st, 1) require.Len(t, s.mi, 1) // Move time forward and both silence and cache entry should be garbage // collected. clock.Advance(time.Minute) n, err := s.GC() require.NoError(t, err) require.Equal(t, 1, n) require.Empty(t, s.st) require.Empty(t, s.mi) }) t.Run("replacing a silences does not leak cache entries", func(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock sil1 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{ Type: pb.Matcher_EQUAL, Name: "foo", Pattern: "bar", }}, }}, StartsAt: timestamppb.New(clock.Now()), EndsAt: timestamppb.New(clock.Now().Add(time.Minute)), } require.NoError(t, s.Set(t.Context(), sil1)) require.Len(t, s.st, 1) require.Len(t, s.mi, 1) // must clone sil1 before replacing it. sil2 := cloneSilence(sil1) sil2.MatcherSets = []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{ Type: pb.Matcher_EQUAL, Name: "bar", Pattern: "baz", }}, }} require.NoError(t, s.Set(t.Context(), sil2)) require.Len(t, s.st, 2) require.Len(t, s.mi, 2) // Move time forward and both silence and cache entry should be garbage // collected. clock.Advance(time.Minute) n, err := s.GC() require.NoError(t, err) require.Equal(t, 2, n) require.Empty(t, s.st) require.Empty(t, s.mi) }) // This test checks for a memory leak that occurred in the matcher cache when // updating an existing silence. t.Run("updating a silence does not leak cache entries", func(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock sil1 := &pb.Silence{ Id: "1", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{ Type: pb.Matcher_EQUAL, Name: "foo", Pattern: "bar", }}, }}, StartsAt: timestamppb.New(clock.Now()), EndsAt: timestamppb.New(clock.Now().Add(time.Minute)), } s.st["1"] = &pb.MeshSilence{Silence: sil1, ExpiresAt: timestamppb.New(clock.Now().Add(time.Minute))} s.indexSilence(sil1) require.Len(t, s.mi, 1) // must clone sil1 before updating it. sil2 := cloneSilence(sil1) require.NoError(t, s.Set(t.Context(), sil2)) // The memory leak occurred because updating a silence would add a new // entry in the matcher cache even though no new silence was created. // This check asserts that this no longer happens. s.Query(t.Context(), QMatches(model.LabelSet{"foo": "bar"})) require.Len(t, s.st, 1) require.Len(t, s.mi, 1) // Move time forward and both silence and cache entry should be garbage // collected. clock.Advance(time.Minute) n, err := s.GC() require.NoError(t, err) require.Equal(t, 1, n) require.Empty(t, s.st) require.Empty(t, s.mi) }) t.Run("GC collects silences in multiple rounds", func(t *testing.T) { s, err := New(Options{ Metrics: prometheus.NewRegistry(), Retention: time.Hour, }) clock := quartz.NewMock(t) s.clock = clock require.NoError(t, err) now := s.nowUTC().UTC() matcher := &pb.Matcher{ Type: pb.Matcher_EQUAL, Name: "job", Pattern: "test", } // Create silences that expire at different times. // Directly set them in state to create pre-expired silences. // Group 1: expires at now+30min (with retention: now+90min) // Group 2: expires at now+45min (with retention: now+105min) // Group 3: expires at now+60min (with retention: now+120min) // Group 4: active, expires at now+3hours (with retention: now+4hours) sils := make([]*pb.Silence, 0, 60) for i := range 10 { sil := &pb.Silence{ Id: fmt.Sprintf("group1-%d", i), MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{matcher}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(30 * time.Minute)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } sils = append(sils, sil) } for i := range 10 { sil := &pb.Silence{ Id: fmt.Sprintf("group2-%d", i), MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{matcher}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(45 * time.Minute)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } sils = append(sils, sil) } for i := range 10 { sil := &pb.Silence{ Id: fmt.Sprintf("group3-%d", i), MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{matcher}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(60 * time.Minute)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } sils = append(sils, sil) } for i := range 30 { sil := &pb.Silence{ Id: fmt.Sprintf("active-%d", i), MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{matcher}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(3 * time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } sils = append(sils, sil) } // Shuffle silences to ensure GC order is not dependent on insertion order. rand.Shuffle(len(sils), func(i, j int) { sils[i], sils[j] = sils[j], sils[i] }) for _, sil := range sils { ms := s.toMeshSilence(sil) s.st[ms.Silence.Id] = ms s.indexSilence(ms.Silence) } require.Len(t, s.st, 60) require.Len(t, s.mi, 60) // First GC: nothing should be collected yet n, err := s.GC() require.NoError(t, err) require.Equal(t, 0, n) require.Len(t, s.st, 60) require.Len(t, s.mi, 60) // Advance time to 91 minutes - Group 1 should be GC'd clock.Advance(91 * time.Minute) n, err = s.GC() require.NoError(t, err) require.Equal(t, 10, n) require.Len(t, s.st, 50) require.Len(t, s.mi, 50) // Advance time to 106 minutes - Group 2 should be GC'd clock.Advance(15 * time.Minute) n, err = s.GC() require.NoError(t, err) require.Equal(t, 10, n) require.Len(t, s.st, 40) require.Len(t, s.mi, 40) // Advance time to 121 minutes - Group 3 should be GC'd clock.Advance(15 * time.Minute) n, err = s.GC() require.NoError(t, err) require.Equal(t, 10, n) require.Len(t, s.st, 30) require.Len(t, s.mi, 30) // Verify all remaining silences are active for id := range s.st { require.Contains(t, id, "active-") } }) t.Run("GC continues and removes erroneous silences", func(t *testing.T) { reg := prometheus.NewRegistry() s, err := New(Options{Metrics: reg}) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock now := clock.Now() // Create a valid silence validSil := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{ Type: pb.Matcher_EQUAL, Name: "foo", Pattern: "bar", }}, }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Minute)), } require.NoError(t, s.Set(t.Context(), validSil)) validID := validSil.Id // Manually add an erroneous silence with zero expiration erroneousSil := &pb.MeshSilence{ Silence: &pb.Silence{ Id: "erroneous", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{ Type: pb.Matcher_EQUAL, Name: "bar", Pattern: "baz", }}, }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Minute)), }, ExpiresAt: nil, // Zero expiration - invalid } s.st["erroneous"] = erroneousSil s.vi.add(s.version+1, "erroneous") s.version++ // Manually add an entry to version index that doesn't exist in state s.vi.add(s.version+1, "missing") s.version++ require.Len(t, s.st, 2) require.Len(t, s.vi, 3) // Run GC - should continue despite errors n, err := s.GC() require.Error(t, err) require.Contains(t, err.Error(), "zero expiration timestamp") require.Contains(t, err.Error(), "missing from state") // GC should have removed erroneous silences require.Equal(t, 1, n) // Only the erroneous silence with zero expiration require.Len(t, s.st, 1) require.Len(t, s.vi, 1) require.Contains(t, s.st, validID) require.NotContains(t, s.st, "erroneous") // Check that the error metric was incremented metricValue := testutil.ToFloat64(s.metrics.gcErrorsTotal) require.Equal(t, float64(2), metricValue) }) } func TestSilencesSnapshot(t *testing.T) { // Check whether storing and loading the snapshot is symmetric. now := quartz.NewMock(t).Now().UTC() cases := []struct { entries []*pb.MeshSilence }{ { entries: []*pb.MeshSilence{ { Silence: &pb.Silence{ Id: "3be80475-e219-4ee7-b6fc-4b65114e362f", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now), UpdatedAt: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, { Silence: &pb.Silence{ Id: "3dfb2528-59ce-41eb-b465-f875a4e744a4", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_NOT_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_NOT_REGEXP}, }, }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now), UpdatedAt: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, { Silence: &pb.Silence{ Id: "4b1e760d-182c-4980-b873-c1a6827c9817", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, }, }}, StartsAt: timestamppb.New(now.Add(time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now.Add(24 * time.Hour)), }, }, }, } for _, c := range cases { f, err := os.CreateTemp(t.TempDir(), "snapshot") require.NoError(t, err, "creating temp file failed") s1 := &Silences{st: state{}, metrics: newMetrics(nil, nil)} // Setup internal state manually. for _, e := range c.entries { s1.st[e.Silence.Id] = e } _, err = s1.Snapshot(f) require.NoError(t, err, "creating snapshot failed") require.NoError(t, f.Close(), "closing snapshot file failed") f, err = os.Open(f.Name()) require.NoError(t, err, "opening snapshot file failed") // Check again against new nlog instance. s2 := &Silences{mi: matcherIndex{}, st: state{}} err = s2.loadSnapshot(f) require.NoError(t, err, "error loading snapshot") require.Len(t, s2.st, len(s1.st), "state length mismatch after loading snapshot") for id, expected := range s1.st { actual, ok := s2.st[id] require.True(t, ok, "silence %s missing from loaded state", id) require.True(t, proto.Equal(expected, actual), "silence %s mismatch after loading snapshot", id) } require.NoError(t, f.Close(), "closing snapshot file failed") } } // This tests a regression introduced by https://github.com/prometheus/alertmanager/pull/2689. func TestSilences_Maintenance_DefaultMaintenanceFuncDoesntCrash(t *testing.T) { f, err := os.CreateTemp(t.TempDir(), "snapshot") require.NoError(t, err, "creating temp file failed") clock := quartz.NewMock(t) s := &Silences{st: state{}, logger: promslog.NewNopLogger(), clock: clock, metrics: newMetrics(nil, nil)} stopc := make(chan struct{}) done := make(chan struct{}) go func() { s.Maintenance(100*time.Millisecond, f.Name(), stopc, nil) close(done) }() runtime.Gosched() clock.Advance(100 * time.Millisecond) close(stopc) <-done } func TestSilences_Maintenance_SupportsCustomCallback(t *testing.T) { f, err := os.CreateTemp(t.TempDir(), "snapshot") require.NoError(t, err, "creating temp file failed") clock := quartz.NewMock(t) reg := prometheus.NewRegistry() s := &Silences{st: state{}, logger: promslog.NewNopLogger(), clock: clock} s.metrics = newMetrics(reg, s) stopc := make(chan struct{}) var calls atomic.Int32 var wg sync.WaitGroup wg.Go(func() { s.Maintenance(10*time.Second, f.Name(), stopc, func() (int64, error) { calls.Add(1) return 0, nil }) }) gosched() // Before the first tick, no maintenance executed. clock.Advance(9 * time.Second) require.EqualValues(t, 0, calls.Load()) // Tick once. clock.Advance(1 * time.Second) require.Eventually(t, func() bool { return calls.Load() == 1 }, 5*time.Second, time.Second) // Stop the maintenance loop. We should get exactly one more execution of the maintenance func. close(stopc) wg.Wait() require.EqualValues(t, 2, calls.Load()) // Check the maintenance metrics. require.NoError(t, testutil.GatherAndCompare(reg, bytes.NewBufferString(` # HELP alertmanager_silences_maintenance_errors_total How many maintenances were executed for silences that failed. # TYPE alertmanager_silences_maintenance_errors_total counter alertmanager_silences_maintenance_errors_total 0 # HELP alertmanager_silences_maintenance_total How many maintenances were executed for silences. # TYPE alertmanager_silences_maintenance_total counter alertmanager_silences_maintenance_total 2 `), "alertmanager_silences_maintenance_total", "alertmanager_silences_maintenance_errors_total")) } func TestSilencesSetSilence(t *testing.T) { s, err := New(Options{ Metrics: prometheus.NewRegistry(), Retention: time.Minute, }) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock nowpb := s.nowUTC() sil := &pb.Silence{ Id: "some_id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "abc", Pattern: "def"}}, }}, StartsAt: timestamppb.New(nowpb), EndsAt: timestamppb.New(nowpb), } want := state{ "some_id": &pb.MeshSilence{ Silence: sil, ExpiresAt: timestamppb.New(nowpb.Add(time.Minute)), }, } wantBroadcast := &pb.MeshSilence{ Silence: &pb.Silence{ Id: "some_id", Matchers: sil.MatcherSets[0].Matchers, // Backward compatibility MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "abc", Pattern: "def"}}, }}, StartsAt: timestamppb.New(nowpb), EndsAt: timestamppb.New(nowpb), }, ExpiresAt: timestamppb.New(nowpb.Add(time.Minute)), } done := make(chan struct{}) s.broadcast = func(b []byte) { var e pb.MeshSilence r := bytes.NewReader(b) err := protodelim.UnmarshalFrom(r, &e) require.NoError(t, err) require.True(t, proto.Equal(&e, wantBroadcast), "broadcast message mismatch") close(done) } // setSilence() is always called with s.mtx locked() in the application code func() { s.mtx.Lock() defer s.mtx.Unlock() require.NoError(t, s.setSilence(s.toMeshSilence(sil), nowpb)) }() // Ensure broadcast was called. if _, isOpen := <-done; isOpen { t.Fatal("broadcast was not called") } requireStatesEqual(t, want, s.st, "Unexpected silence state") } func TestSilenceSet(t *testing.T) { s, err := New(Options{ Metrics: prometheus.NewRegistry(), Retention: time.Hour, }) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock start1 := s.nowUTC() // Insert silence with fixed start time. sil1 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(start1.Add(2 * time.Minute)), EndsAt: timestamppb.New(start1.Add(5 * time.Minute)), } versionBeforeOp := s.Version() require.NoError(t, s.Set(t.Context(), sil1)) require.NotEmpty(t, sil1.Id) require.NotEqual(t, versionBeforeOp, s.Version()) want := state{ sil1.Id: &pb.MeshSilence{ Silence: &pb.Silence{ Id: sil1.Id, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(start1.Add(2 * time.Minute)), EndsAt: timestamppb.New(start1.Add(5 * time.Minute)), UpdatedAt: timestamppb.New(start1), }, ExpiresAt: timestamppb.New(start1.Add(5*time.Minute + s.retention)), }, } requireStatesEqual(t, want, s.st, "unexpected state after silence creation") // Insert silence with unset start time. Must be set to now. clock.Advance(time.Minute) start2 := s.nowUTC() sil2 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, EndsAt: timestamppb.New(start2.Add(1 * time.Minute)), } versionBeforeOp = s.Version() require.NoError(t, s.Set(t.Context(), sil2)) require.NotEmpty(t, sil2.Id) require.NotEqual(t, versionBeforeOp, s.Version()) want = state{ sil1.Id: want[sil1.Id], sil2.Id: &pb.MeshSilence{ Silence: &pb.Silence{ Id: sil2.Id, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(start2), EndsAt: timestamppb.New(start2.Add(1 * time.Minute)), UpdatedAt: timestamppb.New(start2), }, ExpiresAt: timestamppb.New(start2.Add(1*time.Minute + s.retention)), }, } requireStatesEqual(t, want, s.st, "unexpected state after silence creation") // Should be able to update silence without modifications. It is expected to // keep the same ID. sil3 := cloneSilence(sil2) versionBeforeOp = s.Version() require.NoError(t, s.Set(t.Context(), sil3)) require.Equal(t, sil2.Id, sil3.Id) require.Equal(t, versionBeforeOp, s.Version()) // Should be able to update silence with comment. It is also expected to // keep the same ID. sil4 := cloneSilence(sil3) sil4.Comment = "c" versionBeforeOp = s.Version() require.NoError(t, s.Set(t.Context(), sil4)) require.Equal(t, sil3.Id, sil4.Id) require.Equal(t, versionBeforeOp, s.Version()) // Extend sil4 to expire at a later time. This should not expire the // existing silence, and so should also keep the same ID. clock.Advance(time.Minute) start5 := s.nowUTC() sil5 := cloneSilence(sil4) sil5.EndsAt = timestamppb.New(start5.Add(100 * time.Minute)) versionBeforeOp = s.Version() require.NoError(t, s.Set(t.Context(), sil5)) require.Equal(t, sil4.Id, sil5.Id) want = state{ sil1.Id: want[sil1.Id], sil2.Id: &pb.MeshSilence{ Silence: &pb.Silence{ Id: sil2.Id, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(start2), EndsAt: timestamppb.New(start5.Add(100 * time.Minute)), UpdatedAt: timestamppb.New(start5), Comment: "c", }, ExpiresAt: timestamppb.New(start5.Add(100*time.Minute + s.retention)), }, } requireStatesEqual(t, want, s.st, "unexpected state after silence creation") require.Equal(t, versionBeforeOp, s.Version()) // Replace the silence sil5 with another silence with different matchers. // Unlike previous updates, changing the matchers for an existing silence // will expire the existing silence and create a new silence. The new // silence is expected to have a different ID to preserve the history of // the previous silence. clock.Advance(time.Minute) start6 := s.nowUTC() sil6 := cloneSilence(sil5) sil6.MatcherSets = []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "c"}}, }} versionBeforeOp = s.Version() require.NoError(t, s.Set(t.Context(), sil6)) require.NotEqual(t, sil5.Id, sil6.Id) want = state{ sil1.Id: want[sil1.Id], sil2.Id: &pb.MeshSilence{ Silence: &pb.Silence{ Id: sil2.Id, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(start2), EndsAt: timestamppb.New(start6), // Expired UpdatedAt: timestamppb.New(start6), Comment: "c", }, ExpiresAt: timestamppb.New(start6.Add(s.retention)), }, sil6.Id: &pb.MeshSilence{ Silence: &pb.Silence{ Id: sil6.Id, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "c"}}, }}, StartsAt: timestamppb.New(start6), EndsAt: timestamppb.New(start5.Add(100 * time.Minute)), UpdatedAt: timestamppb.New(start6), Comment: "c", }, ExpiresAt: timestamppb.New(start5.Add(100*time.Minute + s.retention)), }, } requireStatesEqual(t, want, s.st, "unexpected state after silence creation") require.NotEqual(t, versionBeforeOp, s.Version()) // Re-create the silence that we just replaced. Changing the start time, // just like changing the matchers, creates a new silence with a different // ID. This is again to preserve the history of the original silence. clock.Advance(time.Minute) start7 := s.nowUTC() sil7 := cloneSilence(sil5) sil7.StartsAt = timestamppb.New(start1) sil7.EndsAt = timestamppb.New(start1.Add(5 * time.Minute)) versionBeforeOp = s.Version() require.NoError(t, s.Set(t.Context(), sil7)) require.NotEqual(t, sil2.Id, sil7.Id) want = state{ sil1.Id: want[sil1.Id], sil2.Id: want[sil2.Id], sil6.Id: want[sil6.Id], sil7.Id: &pb.MeshSilence{ Silence: &pb.Silence{ Id: sil7.Id, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(start7), // New silences have their start time set to "now" when created. EndsAt: timestamppb.New(start1.Add(5 * time.Minute)), UpdatedAt: timestamppb.New(start7), Comment: "c", }, ExpiresAt: timestamppb.New(start1.Add(5*time.Minute + s.retention)), }, } requireStatesEqual(t, want, s.st, "unexpected state after silence creation") require.NotEqual(t, versionBeforeOp, s.Version()) // Updating an existing silence with an invalid silence should not expire // the original silence. clock.Advance(time.Millisecond) sil8 := cloneSilence(sil7) sil8.EndsAt = nil // nil represents zero timestamp versionBeforeOp = s.Version() require.EqualError(t, s.Set(t.Context(), sil8), "invalid silence: invalid zero end timestamp") // sil7 should not be expired because the update failed. clock.Advance(time.Millisecond) sil7, err = s.QueryOne(t.Context(), QIDs(sil7.Id)) require.NoError(t, err) require.Equal(t, SilenceStateActive, getState(sil7, s.nowUTC())) require.Equal(t, versionBeforeOp, s.Version()) } func TestSilenceLimits(t *testing.T) { s, err := New(Options{ Limits: Limits{ MaxSilences: func() int { return 1 }, MaxSilenceSizeBytes: func() int { return 2 << 11 }, // 4KB }, Metrics: prometheus.NewRegistry(), }) require.NoError(t, err) // Insert sil1 should succeed without error. sil1 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(5 * time.Minute)), } require.NoError(t, s.Set(t.Context(), sil1)) // Insert sil2 should fail because maximum number of silences has been // exceeded. sil2 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "c", Pattern: "d"}}, }}, StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(5 * time.Minute)), } require.EqualError(t, s.Set(t.Context(), sil2), "exceeded maximum number of silences: 1 (limit: 1)") // Expire sil1 and run the GC. This should allow sil2 to be inserted. require.NoError(t, s.Expire(t.Context(), sil1.Id)) n, err := s.GC() require.NoError(t, err) require.Equal(t, 1, n) require.NoError(t, s.Set(t.Context(), sil2)) // Expire sil2 and run the GC. require.NoError(t, s.Expire(t.Context(), sil2.Id)) n, err = s.GC() require.NoError(t, err) require.Equal(t, 1, n) // Insert sil3 should fail because it exceeds maximum size. sil3 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ { Name: strings.Repeat("e", 2<<9), Pattern: strings.Repeat("f", 2<<9), }, { Name: strings.Repeat("g", 2<<9), Pattern: strings.Repeat("h", 2<<9), }, }, }}, CreatedBy: strings.Repeat("i", 2<<9), Comment: strings.Repeat("j", 2<<9), StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(5 * time.Minute)), } require.EqualError(t, s.Set(t.Context(), sil3), fmt.Sprintf("silence exceeded maximum size: %d bytes (limit: 4096 bytes)", proto.Size(s.toMeshSilence(sil3)))) // Should be able to insert sil4. sil4 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "k", Pattern: "l"}}, }}, StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(5 * time.Minute)), } require.NoError(t, s.Set(t.Context(), sil4)) // Should be able to update sil4 without modifications. It is expected to // keep the same ID. sil5 := cloneSilence(sil4) require.NoError(t, s.Set(t.Context(), sil5)) require.Equal(t, sil4.Id, sil5.Id) // Should be able to update the comment. It is also expected to keep the // same ID. sil6 := cloneSilence(sil5) sil6.Comment = "m" require.NoError(t, s.Set(t.Context(), sil6)) require.Equal(t, sil5.Id, sil6.Id) // Should not be able to update the start and end time as this requires // sil6 to be expired and a new silence to be created. However, this would // exceed the maximum number of silences, which counts both active and // expired silences. sil7 := cloneSilence(sil6) sil7.StartsAt = timestamppb.New(time.Now().Add(1 * time.Minute)) sil7.EndsAt = timestamppb.New(time.Now().Add(10 * time.Minute)) require.EqualError(t, s.Set(t.Context(), sil7), "exceeded maximum number of silences: 1 (limit: 1)") // sil6 should not be expired because the update failed. sil6, err = s.QueryOne(t.Context(), QIDs(sil6.Id)) require.NoError(t, err) require.Equal(t, SilenceStateActive, getState(sil6, s.nowUTC())) // Should not be able to update with a comment that exceeds maximum size. // Need to increase the maximum number of silences to test this. s.limits.MaxSilences = func() int { return 2 } sil8 := cloneSilence(sil6) sil8.Comment = strings.Repeat("m", 2<<11) require.EqualError(t, s.Set(t.Context(), sil8), fmt.Sprintf("silence exceeded maximum size: %d bytes (limit: 4096 bytes)", proto.Size(s.toMeshSilence(sil8)))) // sil6 should not be expired because the update failed. sil6, err = s.QueryOne(t.Context(), QIDs(sil6.Id)) require.NoError(t, err) require.Equal(t, SilenceStateActive, getState(sil6, s.nowUTC())) // Should not be able to replace with a silence that exceeds maximum size. // This is different from the previous assertion as unlike when adding or // updating a comment, changing the matchers for a silence should expire // the existing silence, unless the silence that is replacing it exceeds // limits, in which case the operation should fail and the existing silence // should still be active. sil9 := cloneSilence(sil8) sil9.Matchers = []*pb.Matcher{{Name: "n", Pattern: "o"}} require.EqualError(t, s.Set(t.Context(), sil9), fmt.Sprintf("silence exceeded maximum size: %d bytes (limit: 4096 bytes)", proto.Size(s.toMeshSilence(sil9)))) // sil6 should not be expired because the update failed. sil6, err = s.QueryOne(t.Context(), QIDs(sil6.Id)) require.NoError(t, err) require.Equal(t, SilenceStateActive, getState(sil6, s.nowUTC())) } func TestSilenceNoLimits(t *testing.T) { s, err := New(Options{ Limits: Limits{}, Metrics: prometheus.NewRegistry(), }) require.NoError(t, err) // Insert sil should succeed without error. sil := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(5 * time.Minute)), Comment: strings.Repeat("c", 2<<9), } require.NoError(t, s.Set(t.Context(), sil)) require.NotEmpty(t, sil.Id) } func TestSetActiveSilence(t *testing.T) { s, err := New(Options{ Metrics: prometheus.NewRegistry(), Retention: time.Hour, }) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock now := clock.Now() startsAt := now.Add(-1 * time.Minute) endsAt := now.Add(5 * time.Minute) // Insert silence with fixed start time. sil1 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(startsAt), EndsAt: timestamppb.New(endsAt), } require.NoError(t, s.Set(t.Context(), sil1)) // Update silence with 2 extra nanoseconds so the "seconds" part should not change newStartsAt := now.Add(2 * time.Nanosecond) newEndsAt := endsAt.Add(2 * time.Minute) sil2 := cloneSilence(sil1) sil2.Id = sil1.Id sil2.StartsAt = timestamppb.New(newStartsAt) sil2.EndsAt = timestamppb.New(newEndsAt) clock.Advance(time.Minute) now = s.nowUTC() require.NoError(t, s.Set(t.Context(), sil2)) require.Equal(t, sil1.Id, sil2.Id) want := state{ sil2.Id: &pb.MeshSilence{ Silence: &pb.Silence{ Id: sil1.Id, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(newStartsAt), EndsAt: timestamppb.New(newEndsAt), UpdatedAt: timestamppb.New(now), }, ExpiresAt: timestamppb.New(newEndsAt.Add(s.retention)), }, } requireStatesEqual(t, want, s.st, "unexpected state after silence creation") } func TestSilencesSetFail(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock cases := []struct { s *pb.Silence err string }{ { s: &pb.Silence{ Id: "some_id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "a", Pattern: "b"}}, }}, EndsAt: timestamppb.New(clock.Now().Add(5 * time.Minute)), }, err: ErrNotFound.Error(), }, { s: &pb.Silence{}, // Silence without matcher. err: "invalid silence", }, } for _, c := range cases { checkErr(t, c.err, s.Set(t.Context(), c.s)) } } func TestQState(t *testing.T) { now := time.Now().UTC() cases := []struct { sil *pb.Silence states []SilenceState keep bool }{ { sil: &pb.Silence{ StartsAt: timestamppb.New(now.Add(time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), }, states: []SilenceState{SilenceStateActive, SilenceStateExpired}, keep: false, }, { sil: &pb.Silence{ StartsAt: timestamppb.New(now.Add(time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), }, states: []SilenceState{SilenceStatePending}, keep: true, }, { sil: &pb.Silence{ StartsAt: timestamppb.New(now.Add(time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), }, states: []SilenceState{SilenceStateExpired, SilenceStatePending}, keep: true, }, } for i, c := range cases { q := &query{} QState(c.states...)(q) f := q.filters[0] keep, err := f(c.sil, nil, now) require.NoError(t, err) require.Equal(t, c.keep, keep, "unexpected filter result for case %d", i) } } func TestQMatches(t *testing.T) { qp := QMatches(model.LabelSet{ "job": "test", "instance": "web-1", "path": "/user/profile", "method": "GET", }) q := &query{} qp(q) f := q.filters[0] cases := []struct { sil *pb.Silence drop bool }{ { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, }, }}, }, drop: true, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "job", Pattern: "test", Type: pb.Matcher_NOT_EQUAL}, }, }}, }, drop: false, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, {Name: "method", Pattern: "POST", Type: pb.Matcher_EQUAL}, }, }}, }, drop: false, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, {Name: "method", Pattern: "POST", Type: pb.Matcher_NOT_EQUAL}, }, }}, }, drop: true, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "path", Pattern: "/user/.+", Type: pb.Matcher_REGEXP}, }, }}, }, drop: true, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ { Name: "path", Pattern: "/user/.+", Type: pb.Matcher_NOT_REGEXP, }, }, }, }, }, drop: false, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "path", Pattern: "/user/.+", Type: pb.Matcher_REGEXP}, {Name: "path", Pattern: "/nothing/.+", Type: pb.Matcher_REGEXP}, }, }, }, }, drop: false, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "method", Pattern: "GET", Type: pb.Matcher_NOT_EQUAL}, }, }, { Matchers: []*pb.Matcher{ {Name: "method", Pattern: "GET|POST", Type: pb.Matcher_REGEXP}, {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, }, }, }, }, drop: true, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "method", Pattern: "GET", Type: pb.Matcher_EQUAL}, }, }, { Matchers: []*pb.Matcher{ {Name: "method", Pattern: "GET|POST", Type: pb.Matcher_REGEXP}, {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, }, }, }, }, drop: true, }, { sil: &pb.Silence{ MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "method", Pattern: "GET", Type: pb.Matcher_NOT_EQUAL}, }, }, { Matchers: []*pb.Matcher{ {Name: "method", Pattern: "GET|POST", Type: pb.Matcher_REGEXP}, {Name: "job", Pattern: "test", Type: pb.Matcher_NOT_EQUAL}, }, }, }, }, drop: false, }, } for _, c := range cases { silences := &Silences{mi: matcherIndex{}, st: state{}} silences.mi.add(c.sil) drop, err := f(c.sil, silences, time.Time{}) require.NoError(t, err) require.Equal(t, c.drop, drop, "unexpected filter result") } } func TestSilenceBackwardCompatibility(t *testing.T) { t.Run("postprocessUnmarshalledSilence converts old format to new", func(t *testing.T) { // Create a silence with only the old Matchers field (simulating old format) oldSilence := &pb.Silence{ Id: "test-id", Matchers: []*pb.Matcher{ {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, {Name: "instance", Pattern: "web-1", Type: pb.Matcher_EQUAL}, }, StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(time.Hour)), } // Process as if unmarshalled from old version postprocessUnmarshalledSilence(oldSilence) // Verify conversion to MatcherSets require.Len(t, oldSilence.MatcherSets, 1, "should have exactly one matcher set") require.Len(t, oldSilence.MatcherSets[0].Matchers, 2, "matcher set should have 2 matchers") require.Equal(t, "job", oldSilence.MatcherSets[0].Matchers[0].Name) require.Equal(t, "test", oldSilence.MatcherSets[0].Matchers[0].Pattern) require.Equal(t, "instance", oldSilence.MatcherSets[0].Matchers[1].Name) require.Equal(t, "web-1", oldSilence.MatcherSets[0].Matchers[1].Pattern) // Verify old Matchers field is cleared require.Nil(t, oldSilence.Matchers, "old Matchers field should be cleared") }) t.Run("prepareSilenceForMarshalling populates old format from new", func(t *testing.T) { // Create a silence with new MatcherSets field newSilence := &pb.Silence{ Id: "test-id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, {Name: "instance", Pattern: "web-1", Type: pb.Matcher_EQUAL}, }, }}, StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(time.Hour)), } // Prepare for marshalling (for backward compatibility) prepareSilenceForMarshalling(newSilence) // Verify old Matchers field is populated from first matcher set require.Len(t, newSilence.Matchers, 2, "old Matchers field should be populated") require.Equal(t, "job", newSilence.Matchers[0].Name) require.Equal(t, "test", newSilence.Matchers[0].Pattern) require.Equal(t, "instance", newSilence.Matchers[1].Name) require.Equal(t, "web-1", newSilence.Matchers[1].Pattern) // Verify MatcherSets is still intact require.Len(t, newSilence.MatcherSets, 1) }) t.Run("round-trip conversion preserves data", func(t *testing.T) { // Start with new format original := &pb.Silence{ Id: "test-id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, {Name: "method", Pattern: "GET", Type: pb.Matcher_REGEXP}, }, }}, StartsAt: timestamppb.New(time.Now().Truncate(time.Second)), EndsAt: timestamppb.New(time.Now().Add(time.Hour).Truncate(time.Second)), CreatedBy: "test-user", Comment: "test comment", } // Marshal (prepare for backward compatibility) prepareSilenceForMarshalling(original) require.Len(t, original.Matchers, 2, "should populate old Matchers field") // Simulate round-trip by creating a new silence with only Matchers field // (as if received from old client) received := &pb.Silence{ Id: original.Id, Matchers: original.Matchers, StartsAt: original.StartsAt, EndsAt: original.EndsAt, CreatedBy: original.CreatedBy, Comment: original.Comment, } // Unmarshal (convert to new format) postprocessUnmarshalledSilence(received) // Verify data is preserved require.Len(t, received.MatcherSets, 1) require.Len(t, received.MatcherSets[0].Matchers, 2) require.Equal(t, original.MatcherSets[0].Matchers[0].Name, received.MatcherSets[0].Matchers[0].Name) require.Equal(t, original.MatcherSets[0].Matchers[0].Pattern, received.MatcherSets[0].Matchers[0].Pattern) require.Equal(t, original.MatcherSets[0].Matchers[0].Type, received.MatcherSets[0].Matchers[0].Type) require.Nil(t, received.Matchers, "old Matchers field should be cleared after postprocess") }) t.Run("postprocess handles empty Matchers gracefully", func(t *testing.T) { // Silence with no matchers at all silence := &pb.Silence{ Id: "test-id", StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(time.Hour)), } postprocessUnmarshalledSilence(silence) require.Nil(t, silence.Matchers) require.Nil(t, silence.MatcherSets) }) t.Run("postprocess prefers MatcherSets when both fields set", func(t *testing.T) { // Silence with both old and new fields (can happen during migration) silence := &pb.Silence{ Id: "test-id", Matchers: []*pb.Matcher{ {Name: "job", Pattern: "old-value", Type: pb.Matcher_EQUAL}, }, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "job", Pattern: "new-value", Type: pb.Matcher_EQUAL}, }, }}, StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(time.Hour)), } postprocessUnmarshalledSilence(silence) // MatcherSets field should be preserved when already set require.Len(t, silence.MatcherSets, 1) require.Equal(t, "new-value", silence.MatcherSets[0].Matchers[0].Pattern) require.Nil(t, silence.Matchers) }) t.Run("multi-matcher silence backward compat populates only first set", func(t *testing.T) { // Create a silence with multiple matcher sets multiSilence := &pb.Silence{ Id: "test-id", MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "job", Pattern: "test", Type: pb.Matcher_EQUAL}, }, }, { Matchers: []*pb.Matcher{ {Name: "method", Pattern: "GET", Type: pb.Matcher_EQUAL}, }, }, }, StartsAt: timestamppb.New(time.Now()), EndsAt: timestamppb.New(time.Now().Add(time.Hour)), } // Prepare for marshalling prepareSilenceForMarshalling(multiSilence) // Only first matcher set should be in old Matchers field require.Len(t, multiSilence.Matchers, 1, "should only populate first matcher set") require.Equal(t, "job", multiSilence.Matchers[0].Name) require.Equal(t, "test", multiSilence.Matchers[0].Pattern) // All matcher sets should still be intact require.Len(t, multiSilence.MatcherSets, 2) }) } func TestStateUnmarshalling(t *testing.T) { // test that we can decode silences with the old format (without MatcherSets field) now := time.Now().UTC() testCases := []struct { name string silence *pb.MeshSilence expected *pb.MeshSilence }{ { name: "empty silence", silence: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, expected: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, }, { name: "silence with matcher sets", silence: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }, }, }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, expected: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }, }, }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, }, { name: "silence with multiple matcher sets", silence: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }, { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val2", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val2.+", Type: pb.Matcher_REGEXP}, }, }, }, }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, expected: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }, { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val2", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val2.+", Type: pb.Matcher_REGEXP}, }, }, }, }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, }, { name: "silence with both classic matchers and matcher sets", silence: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }, { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val2", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val2.+", Type: pb.Matcher_REGEXP}, }, }, }, }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, expected: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }, { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val2", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val2.+", Type: pb.Matcher_REGEXP}, }, }, }, }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, }, { name: "silence with classic matchers", silence: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, expected: &pb.MeshSilence{ Silence: &pb.Silence{ Id: "silence1", MatcherSets: []*pb.MatcherSet{ { Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }, }, }, ExpiresAt: timestamppb.New(now.Add(time.Hour)), }, }, } for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { // Marshal the silence to binary format in := state{ tt.silence.Silence.Id: tt.silence, } msg, err := in.MarshalBinary() require.NoError(t, err) decoded, err := decodeState(bytes.NewReader(msg)) require.NoError(t, err, "decoding message failed") require.True(t, proto.Equal(tt.expected, decoded[tt.silence.Silence.Id]), "decoded data doesn't match encoded data") }) } } func TestQSince(t *testing.T) { type testCase struct { index versionIndex since int results []string } cases := map[string]testCase{ "skips current version": { index: versionIndex{ {id: "1", version: 1}, {id: "2", version: 2}, }, since: 1, results: []string{"2"}, }, "skips any number of old versions": { index: versionIndex{ {id: "1", version: 1}, {id: "2", version: 2}, {id: "3", version: 2}, {id: "4", version: 3}, {id: "5", version: 4}, }, since: 3, results: []string{"5"}, }, "since 0 returns everything": { index: versionIndex{ {id: "1", version: 1}, {id: "2", version: 2}, }, since: 0, results: []string{"1", "2"}, }, "returns all elements of a group with the same version": { index: versionIndex{ {id: "1", version: 1}, {id: "2", version: 2}, {id: "3", version: 3}, {id: "4", version: 3}, }, since: 2, results: []string{"3", "4"}, }, "returns everything after the provided version": { index: versionIndex{ {id: "1", version: 1}, {id: "2", version: 2}, {id: "3", version: 3}, {id: "4", version: 3}, {id: "5", version: 4}, {id: "6", version: 5}, }, since: 2, results: []string{"3", "4", "5", "6"}, }, } for name, c := range cases { t.Run(name, func(t *testing.T) { silences, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) // build state from index so test cases are easier to write st := state{} for _, mapping := range c.index { st[mapping.id] = &pb.MeshSilence{Silence: &pb.Silence{Id: mapping.id}} } silences.st = st silences.vi = c.index res, _, err := silences.Query(t.Context(), QSince(c.since)) require.NoError(t, err) resultIds := []string{} for _, sil := range res { resultIds = append(resultIds, sil.Id) } sort.StringSlice(c.results).Sort() sort.StringSlice(resultIds).Sort() require.Equal(t, c.results, resultIds) }) } } func TestSilencesQuery(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) s.st = state{ "1": &pb.MeshSilence{Silence: &pb.Silence{Id: "1"}}, "2": &pb.MeshSilence{Silence: &pb.Silence{Id: "2"}}, "3": &pb.MeshSilence{Silence: &pb.Silence{Id: "3"}}, "4": &pb.MeshSilence{Silence: &pb.Silence{Id: "4"}}, "5": &pb.MeshSilence{Silence: &pb.Silence{Id: "5"}}, } s.vi = versionIndex{ {id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"}, } cases := []struct { q *query exp []*pb.Silence }{ { // Default query of retrieving all silences. q: &query{}, exp: []*pb.Silence{ {Id: "1"}, {Id: "2"}, {Id: "3"}, {Id: "4"}, {Id: "5"}, }, }, { // Retrieve by IDs. q: &query{ ids: []string{"2", "5"}, }, exp: []*pb.Silence{ {Id: "2"}, {Id: "5"}, }, }, { // Retrieve all and filter q: &query{ filters: []silenceFilter{ func(sil *pb.Silence, _ *Silences, _ time.Time) (bool, error) { return sil.Id == "1" || sil.Id == "2", nil }, }, }, exp: []*pb.Silence{ {Id: "1"}, {Id: "2"}, }, }, { // Retrieve by IDs and filter q: &query{ ids: []string{"2", "5"}, filters: []silenceFilter{ func(sil *pb.Silence, _ *Silences, _ time.Time) (bool, error) { return sil.Id == "1" || sil.Id == "2", nil }, }, }, exp: []*pb.Silence{ {Id: "2"}, }, }, } for _, c := range cases { // Run default query of retrieving all silences. res, _, err := s.query(c.q, time.Time{}) require.NoError(t, err, "unexpected error on querying") // Currently there are no sorting guarantees in the querying API. sort.Sort(silencesByID(c.exp)) sort.Sort(silencesByID(res)) for i := range c.exp { require.True(t, proto.Equal(c.exp[i], res[i]), "unexpected silence in result") } } } func TestQIDs(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry()}) require.NoError(t, err) s.st = state{ "1": &pb.MeshSilence{Silence: &pb.Silence{Id: "1"}}, "2": &pb.MeshSilence{Silence: &pb.Silence{Id: "2"}}, "3": &pb.MeshSilence{Silence: &pb.Silence{Id: "3"}}, "4": &pb.MeshSilence{Silence: &pb.Silence{Id: "4"}}, } // Test QIDs with empty arguments returns an error _, _, err = s.Query(t.Context(), QIDs()) require.Error(t, err, "expected error when QIDs is called with no arguments") require.Contains(t, err.Error(), "QIDs filter must have at least one id") // Test QIDs with empty arguments returns an error via QueryOne _, err = s.QueryOne(t.Context(), QIDs()) require.Error(t, err, "expected error when QIDs is called with no arguments") require.Contains(t, err.Error(), "QIDs filter must have at least one id") // Test QIDs with single ID works res, _, err := s.Query(t.Context(), QIDs("1")) require.NoError(t, err) require.Len(t, res, 1) require.Equal(t, "1", res[0].Id) // Test QIDs with multiple IDs works res, _, err = s.Query(t.Context(), QIDs("1", "2")) require.NoError(t, err) require.Len(t, res, 2) // Test QueryOne with single ID works sil, err := s.QueryOne(t.Context(), QIDs("1")) require.NoError(t, err) require.Equal(t, "1", sil.Id) } type silencesByID []*pb.Silence func (s silencesByID) Len() int { return len(s) } func (s silencesByID) Swap(i, j int) { s[i], s[j] = s[j], s[i] } func (s silencesByID) Less(i, j int) bool { return s[i].Id < s[j].Id } func TestSilenceCanUpdate(t *testing.T) { now := time.Now().UTC() cases := []struct { a, b *pb.Silence ok bool }{ // Bad arguments. { a: &pb.Silence{}, b: &pb.Silence{ StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(-time.Minute)), }, ok: false, }, // Expired silence. { a: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(-time.Second)), }, b: &pb.Silence{ StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now), }, ok: false, }, // Pending silences. { a: &pb.Silence{ StartsAt: timestamppb.New(now.Add(time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }, b: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), }, ok: false, }, { a: &pb.Silence{ StartsAt: timestamppb.New(now.Add(time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }, b: &pb.Silence{ StartsAt: timestamppb.New(now.Add(time.Minute)), EndsAt: timestamppb.New(now.Add(time.Minute)), }, ok: true, }, { a: &pb.Silence{ StartsAt: timestamppb.New(now.Add(time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }, b: &pb.Silence{ StartsAt: timestamppb.New(now), // set to exactly start now. EndsAt: timestamppb.New(now.Add(2 * time.Hour)), }, ok: true, }, // Active silences. { a: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }, b: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), }, ok: false, }, { a: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }, b: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(-time.Second)), }, ok: false, }, { a: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }, b: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now), }, ok: true, }, { a: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }, b: &pb.Silence{ StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(3 * time.Hour)), }, ok: true, }, } for _, c := range cases { ok := canUpdate(c.a, c.b, now) if ok && !c.ok { t.Errorf("expected not-updateable but was: %v, %v", c.a, c.b) } if ok && !c.ok { t.Errorf("expected updateable but was not: %v, %v", c.a, c.b) } } } func TestSilenceExpire(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry(), Retention: time.Hour}) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock now := s.nowUTC() m := &pb.Matcher{Type: pb.Matcher_EQUAL, Name: "a", Pattern: "b"} s.st = state{ "pending": &pb.MeshSilence{Silence: &pb.Silence{ Id: "pending", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now.Add(time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }}, "active": &pb.MeshSilence{Silence: &pb.Silence{ Id: "active", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }}, "expired": &pb.MeshSilence{Silence: &pb.Silence{ Id: "expired", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(-time.Minute)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }}, } s.vi = versionIndex{ silenceVersion{id: "pending"}, silenceVersion{id: "active"}, silenceVersion{id: "expired"}, } count, err := s.CountState(t.Context(), SilenceStatePending) require.NoError(t, err) require.Equal(t, 1, count) count, err = s.CountState(t.Context(), SilenceStateExpired) require.NoError(t, err) require.Equal(t, 1, count) require.NoError(t, s.Expire(t.Context(), "pending")) require.NoError(t, s.Expire(t.Context(), "active")) require.NoError(t, s.Expire(t.Context(), "expired")) sil, err := s.QueryOne(t.Context(), QIDs("pending")) require.NoError(t, err) expectedPending := &pb.Silence{ Id: "pending", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now), UpdatedAt: timestamppb.New(now), } require.True(t, proto.Equal(expectedPending, sil), "pending silence mismatch") // Let time pass... clock.Advance(time.Second) count, err = s.CountState(t.Context(), SilenceStatePending) require.NoError(t, err) require.Equal(t, 0, count) count, err = s.CountState(t.Context(), SilenceStateExpired) require.NoError(t, err) require.Equal(t, 3, count) // Expiring a pending Silence should make the API return the // SilenceStateExpired Silence state. silenceState := CurrentState(sil.StartsAt.AsTime(), sil.EndsAt.AsTime()) require.Equal(t, SilenceStateExpired, silenceState) sil, err = s.QueryOne(t.Context(), QIDs("active")) require.NoError(t, err) expectedActive := &pb.Silence{ Id: "active", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now), UpdatedAt: timestamppb.New(now), } require.True(t, proto.Equal(expectedActive, sil), "active silence mismatch") sil, err = s.QueryOne(t.Context(), QIDs("expired")) require.NoError(t, err) expectedExpired := &pb.Silence{ Id: "expired", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(-time.Minute)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } require.True(t, proto.Equal(expectedExpired, sil), "expired silence mismatch") } // TestSilenceExpireWithZeroRetention covers the problem that, with zero // retention time, a silence explicitly set to expired will also immediately // expire from the silence storage. func TestSilenceExpireWithZeroRetention(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry(), Retention: 0}) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock now := s.nowUTC() m := &pb.Matcher{Type: pb.Matcher_EQUAL, Name: "a", Pattern: "b"} s.st = state{ "pending": &pb.MeshSilence{Silence: &pb.Silence{ Id: "pending", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now.Add(time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }}, "active": &pb.MeshSilence{Silence: &pb.Silence{ Id: "active", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }}, "expired": &pb.MeshSilence{Silence: &pb.Silence{ Id: "expired", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{m}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(-time.Minute)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), }}, } s.vi = versionIndex{ silenceVersion{id: "pending"}, silenceVersion{id: "active"}, silenceVersion{id: "expired"}, } count, err := s.CountState(t.Context(), SilenceStatePending) require.NoError(t, err) require.Equal(t, 1, count) count, err = s.CountState(t.Context(), SilenceStateActive) require.NoError(t, err) require.Equal(t, 1, count) count, err = s.CountState(t.Context(), SilenceStateExpired) require.NoError(t, err) require.Equal(t, 1, count) // Advance time. The silence state management code uses update time when // merging, and the logic is "first write wins". So we must advance the clock // one tick for updates to take effect. clock.Advance(1 * time.Millisecond) require.NoError(t, s.Expire(t.Context(), "pending")) require.NoError(t, s.Expire(t.Context(), "active")) require.NoError(t, s.Expire(t.Context(), "expired")) // Advance time again. Despite what the function name says, s.Expire() does // not expire a silence. It sets the silence to EndAt the current time. This // means that the silence is active immediately after calling Expire. clock.Advance(1 * time.Millisecond) // Verify all silences have expired. count, err = s.CountState(t.Context(), SilenceStatePending) require.NoError(t, err) require.Equal(t, 0, count) count, err = s.CountState(t.Context(), SilenceStateActive) require.NoError(t, err) require.Equal(t, 0, count) count, err = s.CountState(t.Context(), SilenceStateExpired) require.NoError(t, err) require.Equal(t, 3, count) } // This test checks that invalid silences can be expired. func TestSilenceExpireInvalid(t *testing.T) { s, err := New(Options{Metrics: prometheus.NewRegistry(), Retention: time.Hour}) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock now := s.nowUTC() // In this test the matcher has an invalid type. silence := pb.Silence{ Id: "active", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Type: -1, Name: "a", Pattern: "b"}}, }}, StartsAt: timestamppb.New(now.Add(-time.Minute)), EndsAt: timestamppb.New(now.Add(time.Hour)), UpdatedAt: timestamppb.New(now.Add(-time.Hour)), } // Assert that this silence is invalid. require.EqualError(t, validateSilence(&silence), "invalid label matcher 0 in set 0: unknown matcher type \"-1\"") s.st = state{"active": &pb.MeshSilence{Silence: &silence}} s.vi = versionIndex{silenceVersion{id: "active"}} // The silence should be active. count, err := s.CountState(t.Context(), SilenceStateActive) require.NoError(t, err) require.Equal(t, 1, count) clock.Advance(time.Millisecond) require.NoError(t, s.Expire(t.Context(), "active")) clock.Advance(time.Millisecond) // The silence should be expired. count, err = s.CountState(t.Context(), SilenceStateActive) require.NoError(t, err) require.Equal(t, 0, count) count, err = s.CountState(t.Context(), SilenceStateExpired) require.NoError(t, err) require.Equal(t, 1, count) } func TestSilencer(t *testing.T) { ss, err := New(Options{Metrics: prometheus.NewRegistry(), Retention: time.Hour}) require.NoError(t, err) clock := quartz.NewMock(t) ss.clock = clock now := ss.nowUTC() m := types.NewMarker(prometheus.NewRegistry()) s := NewSilencer(ss, m, promslog.NewNopLogger()) require.False(t, s.Mutes(t.Context(), model.LabelSet{"foo": "bar"}), "expected alert not silenced without any silences") sil1 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "foo", Pattern: "baz"}}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(5 * time.Minute)), } require.NoError(t, ss.Set(t.Context(), sil1)) require.False(t, s.Mutes(t.Context(), model.LabelSet{"foo": "bar"}), "expected alert not silenced by non-matching silence") sil2 := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "foo", Pattern: "bar"}}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(5 * time.Minute)), } require.NoError(t, ss.Set(t.Context(), sil2)) require.NotEmpty(t, sil2.Id) require.True(t, s.Mutes(t.Context(), model.LabelSet{"foo": "bar"}), "expected alert silenced by matching silence") // One hour passes, silence expires. clock.Advance(time.Hour) now = ss.nowUTC() require.False(t, s.Mutes(t.Context(), model.LabelSet{"foo": "bar"}), "expected alert not silenced by expired silence") // Update silence to start in the future. err = ss.Set(t.Context(), &pb.Silence{ Id: sil2.Id, MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "foo", Pattern: "bar"}}, }}, StartsAt: timestamppb.New(now.Add(time.Hour)), EndsAt: timestamppb.New(now.Add(3 * time.Hour)), }) require.NoError(t, err) require.False(t, s.Mutes(t.Context(), model.LabelSet{"foo": "bar"}), "expected alert not silenced by future silence") // Two hours pass, silence becomes active. clock.Advance(2 * time.Hour) now = ss.nowUTC() // Exposes issue #2426. require.True(t, s.Mutes(t.Context(), model.LabelSet{"foo": "bar"}), "expected alert silenced by activated silence") err = ss.Set(t.Context(), &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "foo", Pattern: "b..", Type: pb.Matcher_REGEXP}}, }}, StartsAt: timestamppb.New(now.Add(time.Hour)), EndsAt: timestamppb.New(now.Add(3 * time.Hour)), }) require.NoError(t, err) // Note that issue #2426 doesn't apply anymore because we added a new silence. require.True(t, s.Mutes(t.Context(), model.LabelSet{"foo": "bar"}), "expected alert still silenced by activated silence") // Two hours pass, first silence expires, overlapping second silence becomes active. clock.Advance(2 * time.Hour) // Another variant of issue #2426 (overlapping silences). require.True(t, s.Mutes(t.Context(), model.LabelSet{"foo": "bar"}), "expected alert silenced by activated second silence") } func TestSilencerPostDeleteEvictsCache(t *testing.T) { ss, err := New(Options{Metrics: prometheus.NewRegistry(), Retention: time.Hour}) require.NoError(t, err) clock := quartz.NewMock(t) ss.clock = clock now := ss.nowUTC() m := types.NewMarker(prometheus.NewRegistry()) s := NewSilencer(ss, m, promslog.NewNopLogger()) lset := model.LabelSet{"foo": "bar"} fp := lset.Fingerprint() // Create a matching silence. sil := &pb.Silence{ MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{{Name: "foo", Pattern: "bar"}}, }}, StartsAt: timestamppb.New(now.Add(-time.Hour)), EndsAt: timestamppb.New(now.Add(5 * time.Minute)), } require.NoError(t, ss.Set(t.Context(), sil)) // Mutes populates the cache. require.True(t, s.Mutes(t.Context(), lset)) entry := s.cache.get(fp) require.Positive(t, entry.count(), "cache should have entries after Mutes()") // PostGC evicts the cache entry for this fingerprint. s.PostGC(model.Fingerprints{fp}) entry = s.cache.get(fp) require.Equal(t, 0, entry.count(), "cache should be empty after PostGC()") require.Equal(t, 0, entry.version, "version should be zero for evicted entry") // Mutes re-evaluates from scratch (cache miss) and still finds the silence. require.True(t, s.Mutes(t.Context(), lset), "expected alert still silenced after cache eviction") entry = s.cache.get(fp) require.Positive(t, entry.count(), "cache should be repopulated after Mutes()") // Expire the silence, advance time so it's truly expired. clock.Advance(time.Hour) // PostGC for a different fingerprint should not affect this entry. otherLset := model.LabelSet{"other": "alert"} s.PostGC(model.Fingerprints{otherLset.Fingerprint()}) entry = s.cache.get(fp) require.Positive(t, entry.count(), "unrelated PostGC should not evict other entries") } func TestValidateClassicMatcher(t *testing.T) { cases := []struct { m *pb.Matcher err string }{ { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: pb.Matcher_EQUAL, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: pb.Matcher_NOT_EQUAL, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: pb.Matcher_REGEXP, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: pb.Matcher_NOT_REGEXP, }, err: "", }, { m: &pb.Matcher{ Name: "00", Pattern: "a", Type: pb.Matcher_EQUAL, }, err: "invalid label name", }, { m: &pb.Matcher{ Name: "\xf0\x9f\x99\x82", // U+1F642 Pattern: "a", Type: pb.Matcher_EQUAL, }, err: "invalid label name", }, { m: &pb.Matcher{ Name: "a", Pattern: "((", Type: pb.Matcher_REGEXP, }, err: "invalid regular expression", }, { m: &pb.Matcher{ Name: "a", Pattern: "))", Type: pb.Matcher_NOT_REGEXP, }, err: "invalid regular expression", }, { m: &pb.Matcher{ Name: "a", Pattern: "\xff", Type: pb.Matcher_EQUAL, }, err: "invalid label value", }, { m: &pb.Matcher{ Name: "a", Pattern: "\xf0\x9f\x99\x82", // U+1F642 Type: pb.Matcher_EQUAL, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: 333, }, err: "unknown matcher type", }, } for _, c := range cases { checkErr(t, c.err, validateMatcher(c.m)) } } func TestValidateUTF8Matcher(t *testing.T) { cases := []struct { m *pb.Matcher err string }{ { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: pb.Matcher_EQUAL, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: pb.Matcher_NOT_EQUAL, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: pb.Matcher_REGEXP, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: pb.Matcher_NOT_REGEXP, }, err: "", }, { m: &pb.Matcher{ Name: "00", Pattern: "a", Type: pb.Matcher_EQUAL, }, err: "", }, { m: &pb.Matcher{ Name: "\xf0\x9f\x99\x82", // U+1F642 Pattern: "a", Type: pb.Matcher_EQUAL, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "((", Type: pb.Matcher_REGEXP, }, err: "invalid regular expression", }, { m: &pb.Matcher{ Name: "a", Pattern: "))", Type: pb.Matcher_NOT_REGEXP, }, err: "invalid regular expression", }, { m: &pb.Matcher{ Name: "a", Pattern: "\xff", Type: pb.Matcher_EQUAL, }, err: "invalid label value", }, { m: &pb.Matcher{ Name: "a", Pattern: "\xf0\x9f\x99\x82", // U+1F642 Type: pb.Matcher_EQUAL, }, err: "", }, { m: &pb.Matcher{ Name: "a", Pattern: "b", Type: 333, }, err: "unknown matcher type", }, } // Change the mode to UTF-8 mode. ff, err := featurecontrol.NewFlags(promslog.NewNopLogger(), featurecontrol.FeatureUTF8StrictMode) require.NoError(t, err) compat.InitFromFlags(promslog.NewNopLogger(), ff) // Restore the mode to classic at the end of the test. ff, err = featurecontrol.NewFlags(promslog.NewNopLogger(), featurecontrol.FeatureClassicMode) require.NoError(t, err) defer compat.InitFromFlags(promslog.NewNopLogger(), ff) for _, c := range cases { checkErr(t, c.err, validateMatcher(c.m)) } } func TestValidateSilence(t *testing.T) { var ( now = time.Now().UTC() zeroTimestamp *timestamppb.Timestamp // nil represents zero timestamp validTimestamp = timestamppb.New(now) ) cases := []struct { s *pb.Silence err string }{ { s: &pb.Silence{ Id: "some_id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "a", Pattern: "b"}, }, }}, StartsAt: validTimestamp, EndsAt: validTimestamp, UpdatedAt: validTimestamp, }, err: "", }, { s: &pb.Silence{ Id: "some_id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{}, }}, StartsAt: validTimestamp, EndsAt: validTimestamp, UpdatedAt: validTimestamp, }, err: "matcher set 0 is empty", }, { s: &pb.Silence{ Id: "some_id", Matchers: []*pb.Matcher{ {Name: "a", Pattern: "b"}, {Name: "00", Pattern: "b"}, }, StartsAt: validTimestamp, EndsAt: validTimestamp, UpdatedAt: validTimestamp, }, err: "invalid label matcher", }, { s: &pb.Silence{ Id: "some_id", Matchers: []*pb.Matcher{ {Name: "a", Pattern: ""}, {Name: "b", Pattern: ".*", Type: pb.Matcher_REGEXP}, }, StartsAt: validTimestamp, EndsAt: validTimestamp, UpdatedAt: validTimestamp, }, err: "at least one matcher must not match the empty string", }, { s: &pb.Silence{ Id: "some_id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "a", Pattern: "b"}, }, }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(-time.Second)), UpdatedAt: validTimestamp, }, err: "end time must not be before start time", }, { s: &pb.Silence{ Id: "some_id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "a", Pattern: "b"}, }, }}, StartsAt: zeroTimestamp, EndsAt: validTimestamp, UpdatedAt: validTimestamp, }, err: "invalid zero start timestamp", }, { s: &pb.Silence{ Id: "some_id", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "a", Pattern: "b"}, }, }}, StartsAt: validTimestamp, EndsAt: zeroTimestamp, UpdatedAt: validTimestamp, }, err: "invalid zero end timestamp", }, } for _, c := range cases { checkErr(t, c.err, validateSilence(c.s)) } } func TestStateMerge(t *testing.T) { now := time.Now().UTC() // We only care about key names and timestamps for the // merging logic. newSilence := func(id string, ts, exp time.Time) *pb.MeshSilence { return &pb.MeshSilence{ Silence: &pb.Silence{Id: id, UpdatedAt: timestamppb.New(ts)}, ExpiresAt: timestamppb.New(exp), } } exp := now.Add(time.Minute) cases := []struct { a, b state final state }{ { a: state{ "a1": newSilence("a1", now, exp), "a2": newSilence("a2", now, exp), "a3": newSilence("a3", now, exp), }, b: state{ "b1": newSilence("b1", now, exp), // new key, should be added "a2": newSilence("a2", now.Add(-time.Minute), exp), // older timestamp, should be dropped "a3": newSilence("a3", now.Add(time.Minute), exp), // newer timestamp, should overwrite "a4": newSilence("a4", now.Add(-time.Minute), now.Add(-time.Millisecond)), // new key, expired, should not be added }, final: state{ "a1": newSilence("a1", now, exp), "a2": newSilence("a2", now, exp), "a3": newSilence("a3", now.Add(time.Minute), exp), "b1": newSilence("b1", now, exp), }, }, } for _, c := range cases { for _, e := range c.b { c.a.merge(e, now) } require.Equal(t, c.final, c.a, "Merge result should match expectation") } } func TestStateCoding(t *testing.T) { // Check whether encoding and decoding the data is symmetric. now := time.Now().UTC() cases := []struct { entries []*pb.MeshSilence }{ { entries: []*pb.MeshSilence{ { Silence: &pb.Silence{ Id: "3be80475-e219-4ee7-b6fc-4b65114e362f", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_REGEXP}, }, }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now), UpdatedAt: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, { Silence: &pb.Silence{ Id: "4b1e760d-182c-4980-b873-c1a6827c9817", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_EQUAL}, }, }}, StartsAt: timestamppb.New(now.Add(time.Hour)), EndsAt: timestamppb.New(now.Add(2 * time.Hour)), UpdatedAt: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now.Add(24 * time.Hour)), }, { Silence: &pb.Silence{ Id: "3dfb2528-59ce-41eb-b465-f875a4e744a4", MatcherSets: []*pb.MatcherSet{{ Matchers: []*pb.Matcher{ {Name: "label1", Pattern: "val1", Type: pb.Matcher_NOT_EQUAL}, {Name: "label2", Pattern: "val.+", Type: pb.Matcher_NOT_REGEXP}, }, }}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now), UpdatedAt: timestamppb.New(now), }, ExpiresAt: timestamppb.New(now), }, }, }, } for _, c := range cases { // Create gossip data from input. in := state{} for _, e := range c.entries { in[e.Silence.Id] = e } msg, err := in.MarshalBinary() require.NoError(t, err) out, err := decodeState(bytes.NewReader(msg)) require.NoError(t, err, "decoding message failed") require.Len(t, out, len(in), "decoded state length mismatch") for id, expected := range in { actual, ok := out[id] require.True(t, ok, "silence %s missing from decoded state", id) require.True(t, proto.Equal(expected, actual), "silence %s mismatch after decoding", id) } } } func TestStateDecodingError(t *testing.T) { // Check whether decoding copes with erroneous data. s := state{"": &pb.MeshSilence{}} msg, err := s.MarshalBinary() require.NoError(t, err) _, err = decodeState(bytes.NewReader(msg)) require.Equal(t, ErrInvalidState, err) } // runtime.Gosched() does not "suspend" the current goroutine so there's no guarantee that the main goroutine won't // be able to continue. For more see https://pkg.go.dev/runtime#Gosched. func gosched() { time.Sleep(1 * time.Millisecond) } func TestSilenceAnnotations(t *testing.T) { s, err := New(Options{ Metrics: prometheus.NewRegistry(), Retention: time.Hour, }) require.NoError(t, err) clock := quartz.NewMock(t) s.clock = clock now := s.nowUTC() // Create a silence with annotations sil1 := &pb.Silence{ Matchers: []*pb.Matcher{{Name: "job", Pattern: "test"}}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Hour)), Annotations: map[string]string{ "ticket": "JIRA-123", "type": "planned", "test": "integration", }, } // Set the silence via the API require.NoError(t, s.Set(t.Context(), sil1)) require.NotEmpty(t, sil1.Id) // Query the silence back by ID queriedSil, err := s.QueryOne(t.Context(), QIDs(sil1.Id)) require.NoError(t, err) // Verify all annotations are returned correctly require.NotNil(t, queriedSil.Annotations) require.Equal(t, "JIRA-123", queriedSil.Annotations["ticket"]) require.Equal(t, "planned", queriedSil.Annotations["type"]) require.Equal(t, "integration", queriedSil.Annotations["test"]) // Test querying all silences allSils, _, err := s.Query(t.Context()) require.NoError(t, err) require.Len(t, allSils, 1) require.Equal(t, queriedSil.Annotations, allSils[0].Annotations) // Create a second silence with different annotations sil2 := &pb.Silence{ Matchers: []*pb.Matcher{{Name: "job", Pattern: "frontend"}}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Hour)), Annotations: map[string]string{ "ticket": "JIRA-456", }, } require.NoError(t, s.Set(t.Context(), sil2)) // Query by state and verify both silences have their annotations activeSils, _, err := s.Query(t.Context(), QState(SilenceStateActive)) require.NoError(t, err) require.Len(t, activeSils, 2) for _, sil := range activeSils { require.NotNil(t, sil.Annotations) switch sil.Id { case sil1.Id: require.Len(t, sil.Annotations, 3) require.Equal(t, "JIRA-123", sil.Annotations["ticket"]) case sil2.Id: require.Len(t, sil.Annotations, 1) require.Equal(t, "JIRA-456", sil.Annotations["ticket"]) default: t.Fatalf("unexpected silence ID: %s", sil.Id) } } // Test updating a silence with new annotations clock.Advance(time.Minute) sil1Updated := &pb.Silence{ Id: sil1.Id, Matchers: []*pb.Matcher{{Name: "job", Pattern: "test"}}, StartsAt: sil1.StartsAt, EndsAt: sil1.EndsAt, Annotations: map[string]string{ "ticket": "JIRA-123", "type": "emergency", // changed "test": "load", // changed }, } require.NoError(t, s.Set(t.Context(), sil1Updated)) // Query back and verify annotations were updated queriedUpdated, err := s.QueryOne(t.Context(), QIDs(sil1.Id)) require.NoError(t, err) require.Len(t, queriedUpdated.Annotations, 3) require.Equal(t, "emergency", queriedUpdated.Annotations["type"]) require.Equal(t, "load", queriedUpdated.Annotations["test"]) // Test silence with nil annotations sil3 := &pb.Silence{ Matchers: []*pb.Matcher{{Name: "job", Pattern: "backend"}}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Hour)), Annotations: nil, } require.NoError(t, s.Set(t.Context(), sil3)) queriedSil3, err := s.QueryOne(t.Context(), QIDs(sil3.Id)) require.NoError(t, err) // nil annotations should be preserved or converted to empty map if queriedSil3.Annotations != nil { require.Empty(t, queriedSil3.Annotations) } // Test silence with empty annotations map sil4 := &pb.Silence{ Matchers: []*pb.Matcher{{Name: "job", Pattern: "database"}}, StartsAt: timestamppb.New(now), EndsAt: timestamppb.New(now.Add(time.Hour)), Annotations: map[string]string{}, } require.NoError(t, s.Set(t.Context(), sil4)) queriedSil4, err := s.QueryOne(t.Context(), QIDs(sil4.Id)) require.NoError(t, err) if queriedSil4.Annotations != nil { require.Empty(t, queriedSil4.Annotations) } } ================================================ FILE: silence/silencepb/silence.pb.go ================================================ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 // protoc (unknown) // source: silence.proto package silencepb import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) // Type specifies how the given name and pattern are matched // against a label set. type Matcher_Type int32 const ( Matcher_EQUAL Matcher_Type = 0 Matcher_REGEXP Matcher_Type = 1 Matcher_NOT_EQUAL Matcher_Type = 2 Matcher_NOT_REGEXP Matcher_Type = 3 ) // Enum value maps for Matcher_Type. var ( Matcher_Type_name = map[int32]string{ 0: "EQUAL", 1: "REGEXP", 2: "NOT_EQUAL", 3: "NOT_REGEXP", } Matcher_Type_value = map[string]int32{ "EQUAL": 0, "REGEXP": 1, "NOT_EQUAL": 2, "NOT_REGEXP": 3, } ) func (x Matcher_Type) Enum() *Matcher_Type { p := new(Matcher_Type) *p = x return p } func (x Matcher_Type) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (Matcher_Type) Descriptor() protoreflect.EnumDescriptor { return file_silence_proto_enumTypes[0].Descriptor() } func (Matcher_Type) Type() protoreflect.EnumType { return &file_silence_proto_enumTypes[0] } func (x Matcher_Type) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use Matcher_Type.Descriptor instead. func (Matcher_Type) EnumDescriptor() ([]byte, []int) { return file_silence_proto_rawDescGZIP(), []int{0, 0} } // Matcher specifies a rule, which can match or set of labels or not. type Matcher struct { state protoimpl.MessageState `protogen:"open.v1"` Type Matcher_Type `protobuf:"varint,1,opt,name=type,proto3,enum=silencepb.Matcher_Type" json:"type,omitempty"` // The label name in a label set to against which the matcher // checks the pattern. Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // The pattern being checked according to the matcher's type. Pattern string `protobuf:"bytes,3,opt,name=pattern,proto3" json:"pattern,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Matcher) Reset() { *x = Matcher{} mi := &file_silence_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Matcher) String() string { return protoimpl.X.MessageStringOf(x) } func (*Matcher) ProtoMessage() {} func (x *Matcher) ProtoReflect() protoreflect.Message { mi := &file_silence_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Matcher.ProtoReflect.Descriptor instead. func (*Matcher) Descriptor() ([]byte, []int) { return file_silence_proto_rawDescGZIP(), []int{0} } func (x *Matcher) GetType() Matcher_Type { if x != nil { return x.Type } return Matcher_EQUAL } func (x *Matcher) GetName() string { if x != nil { return x.Name } return "" } func (x *Matcher) GetPattern() string { if x != nil { return x.Pattern } return "" } // DEPRECATED: A comment can be attached to a silence. type Comment struct { state protoimpl.MessageState `protogen:"open.v1"` Author string `protobuf:"bytes,1,opt,name=author,proto3" json:"author,omitempty"` Comment string `protobuf:"bytes,2,opt,name=comment,proto3" json:"comment,omitempty"` Timestamp *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Comment) Reset() { *x = Comment{} mi := &file_silence_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Comment) String() string { return protoimpl.X.MessageStringOf(x) } func (*Comment) ProtoMessage() {} func (x *Comment) ProtoReflect() protoreflect.Message { mi := &file_silence_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Comment.ProtoReflect.Descriptor instead. func (*Comment) Descriptor() ([]byte, []int) { return file_silence_proto_rawDescGZIP(), []int{1} } func (x *Comment) GetAuthor() string { if x != nil { return x.Author } return "" } func (x *Comment) GetComment() string { if x != nil { return x.Comment } return "" } func (x *Comment) GetTimestamp() *timestamppb.Timestamp { if x != nil { return x.Timestamp } return nil } // MatcherSet is a set of matchers all of which have to be true // for a silence to affect a given label set. type MatcherSet struct { state protoimpl.MessageState `protogen:"open.v1"` Matchers []*Matcher `protobuf:"bytes,1,rep,name=matchers,proto3" json:"matchers,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MatcherSet) Reset() { *x = MatcherSet{} mi := &file_silence_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MatcherSet) String() string { return protoimpl.X.MessageStringOf(x) } func (*MatcherSet) ProtoMessage() {} func (x *MatcherSet) ProtoReflect() protoreflect.Message { mi := &file_silence_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MatcherSet.ProtoReflect.Descriptor instead. func (*MatcherSet) Descriptor() ([]byte, []int) { return file_silence_proto_rawDescGZIP(), []int{2} } func (x *MatcherSet) GetMatchers() []*Matcher { if x != nil { return x.Matchers } return nil } // Silence specifies an object that ignores alerts based // on a set of matchers during a given time frame. type Silence struct { state protoimpl.MessageState `protogen:"open.v1"` // A globally unique identifier. Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // A set of matchers all of which have to be true for a silence // to affect a given label set. For silences with matcher_sets, // this is expected to be equal to the first entry in matcher_sets Matchers []*Matcher `protobuf:"bytes,2,rep,name=matchers,proto3" json:"matchers,omitempty"` // The time range during which the silence is active. StartsAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=starts_at,json=startsAt,proto3" json:"starts_at,omitempty"` EndsAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=ends_at,json=endsAt,proto3" json:"ends_at,omitempty"` // The last notification made to the silence. UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // DEPRECATED: A set of comments made on the silence. Comments []*Comment `protobuf:"bytes,7,rep,name=comments,proto3" json:"comments,omitempty"` // Comment for the silence. CreatedBy string `protobuf:"bytes,8,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` Comment string `protobuf:"bytes,9,opt,name=comment,proto3" json:"comment,omitempty"` // Additional structured information about the silence Annotations map[string]string `protobuf:"bytes,10,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // Multiple matcher sets with OR logic between them. // At least one matcher set must match for the silence to apply. MatcherSets []*MatcherSet `protobuf:"bytes,11,rep,name=matcher_sets,json=matcherSets,proto3" json:"matcher_sets,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Silence) Reset() { *x = Silence{} mi := &file_silence_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *Silence) String() string { return protoimpl.X.MessageStringOf(x) } func (*Silence) ProtoMessage() {} func (x *Silence) ProtoReflect() protoreflect.Message { mi := &file_silence_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Silence.ProtoReflect.Descriptor instead. func (*Silence) Descriptor() ([]byte, []int) { return file_silence_proto_rawDescGZIP(), []int{3} } func (x *Silence) GetId() string { if x != nil { return x.Id } return "" } func (x *Silence) GetMatchers() []*Matcher { if x != nil { return x.Matchers } return nil } func (x *Silence) GetStartsAt() *timestamppb.Timestamp { if x != nil { return x.StartsAt } return nil } func (x *Silence) GetEndsAt() *timestamppb.Timestamp { if x != nil { return x.EndsAt } return nil } func (x *Silence) GetUpdatedAt() *timestamppb.Timestamp { if x != nil { return x.UpdatedAt } return nil } func (x *Silence) GetComments() []*Comment { if x != nil { return x.Comments } return nil } func (x *Silence) GetCreatedBy() string { if x != nil { return x.CreatedBy } return "" } func (x *Silence) GetComment() string { if x != nil { return x.Comment } return "" } func (x *Silence) GetAnnotations() map[string]string { if x != nil { return x.Annotations } return nil } func (x *Silence) GetMatcherSets() []*MatcherSet { if x != nil { return x.MatcherSets } return nil } // MeshSilence wraps a regular silence with an expiration timestamp // after which the silence may be garbage collected. type MeshSilence struct { state protoimpl.MessageState `protogen:"open.v1"` Silence *Silence `protobuf:"bytes,1,opt,name=silence,proto3" json:"silence,omitempty"` ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *MeshSilence) Reset() { *x = MeshSilence{} mi := &file_silence_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } func (x *MeshSilence) String() string { return protoimpl.X.MessageStringOf(x) } func (*MeshSilence) ProtoMessage() {} func (x *MeshSilence) ProtoReflect() protoreflect.Message { mi := &file_silence_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use MeshSilence.ProtoReflect.Descriptor instead. func (*MeshSilence) Descriptor() ([]byte, []int) { return file_silence_proto_rawDescGZIP(), []int{4} } func (x *MeshSilence) GetSilence() *Silence { if x != nil { return x.Silence } return nil } func (x *MeshSilence) GetExpiresAt() *timestamppb.Timestamp { if x != nil { return x.ExpiresAt } return nil } var File_silence_proto protoreflect.FileDescriptor const file_silence_proto_rawDesc = "" + "\n" + "\rsilence.proto\x12\tsilencepb\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa2\x01\n" + "\aMatcher\x12+\n" + "\x04type\x18\x01 \x01(\x0e2\x17.silencepb.Matcher.TypeR\x04type\x12\x12\n" + "\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" + "\apattern\x18\x03 \x01(\tR\apattern\"<\n" + "\x04Type\x12\t\n" + "\x05EQUAL\x10\x00\x12\n" + "\n" + "\x06REGEXP\x10\x01\x12\r\n" + "\tNOT_EQUAL\x10\x02\x12\x0e\n" + "\n" + "NOT_REGEXP\x10\x03\"u\n" + "\aComment\x12\x16\n" + "\x06author\x18\x01 \x01(\tR\x06author\x12\x18\n" + "\acomment\x18\x02 \x01(\tR\acomment\x128\n" + "\ttimestamp\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\"<\n" + "\n" + "MatcherSet\x12.\n" + "\bmatchers\x18\x01 \x03(\v2\x12.silencepb.MatcherR\bmatchers\"\x9c\x04\n" + "\aSilence\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12.\n" + "\bmatchers\x18\x02 \x03(\v2\x12.silencepb.MatcherR\bmatchers\x127\n" + "\tstarts_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\bstartsAt\x123\n" + "\aends_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x06endsAt\x129\n" + "\n" + "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x12.\n" + "\bcomments\x18\a \x03(\v2\x12.silencepb.CommentR\bcomments\x12\x1d\n" + "\n" + "created_by\x18\b \x01(\tR\tcreatedBy\x12\x18\n" + "\acomment\x18\t \x01(\tR\acomment\x12E\n" + "\vannotations\x18\n" + " \x03(\v2#.silencepb.Silence.AnnotationsEntryR\vannotations\x128\n" + "\fmatcher_sets\x18\v \x03(\v2\x15.silencepb.MatcherSetR\vmatcherSets\x1a>\n" + "\x10AnnotationsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"v\n" + "\vMeshSilence\x12,\n" + "\asilence\x18\x01 \x01(\v2\x12.silencepb.SilenceR\asilence\x129\n" + "\n" + "expires_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAtB6Z4github.com/prometheus/alertmanager/silence/silencepbb\x06proto3" var ( file_silence_proto_rawDescOnce sync.Once file_silence_proto_rawDescData []byte ) func file_silence_proto_rawDescGZIP() []byte { file_silence_proto_rawDescOnce.Do(func() { file_silence_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_silence_proto_rawDesc), len(file_silence_proto_rawDesc))) }) return file_silence_proto_rawDescData } var file_silence_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_silence_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_silence_proto_goTypes = []any{ (Matcher_Type)(0), // 0: silencepb.Matcher.Type (*Matcher)(nil), // 1: silencepb.Matcher (*Comment)(nil), // 2: silencepb.Comment (*MatcherSet)(nil), // 3: silencepb.MatcherSet (*Silence)(nil), // 4: silencepb.Silence (*MeshSilence)(nil), // 5: silencepb.MeshSilence nil, // 6: silencepb.Silence.AnnotationsEntry (*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp } var file_silence_proto_depIdxs = []int32{ 0, // 0: silencepb.Matcher.type:type_name -> silencepb.Matcher.Type 7, // 1: silencepb.Comment.timestamp:type_name -> google.protobuf.Timestamp 1, // 2: silencepb.MatcherSet.matchers:type_name -> silencepb.Matcher 1, // 3: silencepb.Silence.matchers:type_name -> silencepb.Matcher 7, // 4: silencepb.Silence.starts_at:type_name -> google.protobuf.Timestamp 7, // 5: silencepb.Silence.ends_at:type_name -> google.protobuf.Timestamp 7, // 6: silencepb.Silence.updated_at:type_name -> google.protobuf.Timestamp 2, // 7: silencepb.Silence.comments:type_name -> silencepb.Comment 6, // 8: silencepb.Silence.annotations:type_name -> silencepb.Silence.AnnotationsEntry 3, // 9: silencepb.Silence.matcher_sets:type_name -> silencepb.MatcherSet 4, // 10: silencepb.MeshSilence.silence:type_name -> silencepb.Silence 7, // 11: silencepb.MeshSilence.expires_at:type_name -> google.protobuf.Timestamp 12, // [12:12] is the sub-list for method output_type 12, // [12:12] is the sub-list for method input_type 12, // [12:12] is the sub-list for extension type_name 12, // [12:12] is the sub-list for extension extendee 0, // [0:12] is the sub-list for field type_name } func init() { file_silence_proto_init() } func file_silence_proto_init() { if File_silence_proto != nil { return } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_silence_proto_rawDesc), len(file_silence_proto_rawDesc)), NumEnums: 1, NumMessages: 6, NumExtensions: 0, NumServices: 0, }, GoTypes: file_silence_proto_goTypes, DependencyIndexes: file_silence_proto_depIdxs, EnumInfos: file_silence_proto_enumTypes, MessageInfos: file_silence_proto_msgTypes, }.Build() File_silence_proto = out.File file_silence_proto_goTypes = nil file_silence_proto_depIdxs = nil } ================================================ FILE: silence/silencepb/silence.proto ================================================ syntax = "proto3"; package silencepb; option go_package = "github.com/prometheus/alertmanager/silence/silencepb"; import "google/protobuf/timestamp.proto"; // Matcher specifies a rule, which can match or set of labels or not. message Matcher { // Type specifies how the given name and pattern are matched // against a label set. enum Type { EQUAL = 0; REGEXP = 1; NOT_EQUAL = 2; NOT_REGEXP = 3; }; Type type = 1; // The label name in a label set to against which the matcher // checks the pattern. string name = 2; // The pattern being checked according to the matcher's type. string pattern = 3; } // DEPRECATED: A comment can be attached to a silence. message Comment { string author = 1; string comment = 2; google.protobuf.Timestamp timestamp = 3; } // MatcherSet is a set of matchers all of which have to be true // for a silence to affect a given label set. message MatcherSet { repeated Matcher matchers = 1; } // Silence specifies an object that ignores alerts based // on a set of matchers during a given time frame. message Silence { // A globally unique identifier. string id = 1; // A set of matchers all of which have to be true for a silence // to affect a given label set. For silences with matcher_sets, // this is expected to be equal to the first entry in matcher_sets repeated Matcher matchers = 2; // The time range during which the silence is active. google.protobuf.Timestamp starts_at = 3; google.protobuf.Timestamp ends_at = 4; // The last notification made to the silence. google.protobuf.Timestamp updated_at = 5; // DEPRECATED: A set of comments made on the silence. repeated Comment comments = 7; // Comment for the silence. string created_by = 8; string comment = 9; // Additional structured information about the silence map annotations = 10; // Multiple matcher sets with OR logic between them. // At least one matcher set must match for the silence to apply. repeated MatcherSet matcher_sets = 11; } // MeshSilence wraps a regular silence with an expiration timestamp // after which the silence may be garbage collected. message MeshSilence { Silence silence = 1; google.protobuf.Timestamp expires_at = 2; } ================================================ FILE: silence/state.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package silence import "time" type SilenceState string const ( SilenceStateExpired SilenceState = "expired" SilenceStateActive SilenceState = "active" SilenceStatePending SilenceState = "pending" ) // CurrentState returns the SilenceState that a silence with the given start // and end time would have right now. func CurrentState(start, end time.Time) SilenceState { current := time.Now() if current.Before(start) { return SilenceStatePending } if current.Before(end) { return SilenceStateActive } return SilenceStateExpired } ================================================ FILE: silence/state_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package silence import ( "testing" "time" "github.com/stretchr/testify/require" ) func TestCurrentState(t *testing.T) { var ( pastStartTime = time.Now() pastEndTime = time.Now() futureStartTime = time.Now().Add(time.Hour) futureEndTime = time.Now().Add(time.Hour) ) expected := CurrentState(futureStartTime, futureEndTime) require.Equal(t, SilenceStatePending, expected) expected = CurrentState(pastStartTime, futureEndTime) require.Equal(t, SilenceStateActive, expected) expected = CurrentState(pastStartTime, pastEndTime) require.Equal(t, SilenceStateExpired, expected) } ================================================ FILE: store/store.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package store import ( "context" "errors" "sync" "time" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/limit" "github.com/prometheus/alertmanager/types" ) // ErrLimited is returned if a Store has reached the per-alert limit. var ErrLimited = errors.New("alert limited") // ErrNotFound is returned if a Store cannot find the Alert. var ErrNotFound = errors.New("alert not found") // ErrDestroyed is returned if a Store has been destroyed. var ErrDestroyed = errors.New("alert store destroyed") // Alerts provides lock-coordinated to an in-memory map of alerts, keyed by // their fingerprint. Resolved alerts are removed from the map based on // gcInterval. An optional callback can be set which receives a slice of all // resolved alerts that have been removed. type Alerts struct { sync.Mutex alerts map[model.Fingerprint]*types.Alert gcCallback func([]*types.Alert) limits map[string]*limit.Bucket[model.Fingerprint] perAlertLimit int destroyed bool } // NewAlerts returns a new Alerts struct. func NewAlerts() *Alerts { a := &Alerts{ alerts: make(map[model.Fingerprint]*types.Alert), gcCallback: func(_ []*types.Alert) {}, perAlertLimit: 0, } return a } // WithPerAlertLimit sets the per-alert limit for the Alerts struct. func (a *Alerts) WithPerAlertLimit(lim int) *Alerts { a.Lock() defer a.Unlock() a.limits = make(map[string]*limit.Bucket[model.Fingerprint]) a.perAlertLimit = lim return a } // SetGCCallback sets a GC callback to be executed after each GC. func (a *Alerts) SetGCCallback(cb func([]*types.Alert)) { a.Lock() defer a.Unlock() a.gcCallback = cb } // Run starts the GC loop. The interval must be greater than zero; if not, the function will panic. // Note: This is only used by inhibitor currently and potentially can be removed later. func (a *Alerts) Run(ctx context.Context, interval time.Duration) { t := time.NewTicker(interval) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: a.GC() } } } // GC deletes resolved alerts and returns them. func (a *Alerts) GC() (deleted []*types.Alert) { // Remove stale alert limit buckets. a.gcLimitBuckets() // Delete resolved alerts. deleted = a.gcAlerts() // Execute GC callback if needed. if len(deleted) > 0 { a.gcCallback(deleted) } return deleted } // gcAlerts deletes resolved alerts and returns a copy of them. func (a *Alerts) gcAlerts() (deleted []*types.Alert) { a.Lock() defer a.Unlock() for fp, alert := range a.alerts { if alert.Resolved() { deleted = append(deleted, alert) delete(a.alerts, fp) } } return deleted } // gcLimitBuckets removes stale alert limit buckets. func (a *Alerts) gcLimitBuckets() { a.Lock() defer a.Unlock() for alertName, bucket := range a.limits { if bucket.IsStale() { delete(a.limits, alertName) } } } // Get returns the Alert with the matching fingerprint, or an error if it is // not found. func (a *Alerts) Get(fp model.Fingerprint) (*types.Alert, error) { a.Lock() defer a.Unlock() alert, prs := a.alerts[fp] if !prs { return nil, ErrNotFound } return alert, nil } // Set unconditionally sets the alert in memory. func (a *Alerts) Set(alert *types.Alert) error { a.Lock() defer a.Unlock() if a.destroyed { return ErrDestroyed } fp := alert.Fingerprint() name := alert.Name() // Apply per alert limits if necessary if a.perAlertLimit > 0 { bucket, ok := a.limits[name] if !ok { bucket = limit.NewBucket[model.Fingerprint](a.perAlertLimit) a.limits[name] = bucket } if !bucket.Upsert(fp, alert.EndsAt) { return ErrLimited } } a.alerts[fp] = alert return nil } // DeleteIfNotModified deletes the slice of Alerts from the store if not // modified. func (a *Alerts) DeleteIfNotModified(alerts types.AlertSlice, destroyIfEmpty bool) error { a.Lock() defer a.Unlock() for _, alert := range alerts { fp := alert.Fingerprint() if other, ok := a.alerts[fp]; ok && alert.UpdatedAt.Equal(other.UpdatedAt) { delete(a.alerts, fp) } } // If the store is now empty, mark it as destroyed if len(a.alerts) == 0 && destroyIfEmpty { a.destroyed = true } return nil } // List returns a slice of Alerts currently held in memory. func (a *Alerts) List() []*types.Alert { a.Lock() defer a.Unlock() alerts := make([]*types.Alert, 0, len(a.alerts)) for _, alert := range a.alerts { alerts = append(alerts, alert) } return alerts } // Empty returns true if the store is empty. func (a *Alerts) Empty() bool { a.Lock() defer a.Unlock() return len(a.alerts) == 0 } // Empty returns true if the store is empty. func (a *Alerts) Destroyed() bool { a.Lock() defer a.Unlock() return a.destroyed } // Len returns the number of alerts in the store. func (a *Alerts) Len() int { a.Lock() defer a.Unlock() return len(a.alerts) } ================================================ FILE: store/store_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package store import ( "context" "testing" "time" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/types" ) func TestSetGet(t *testing.T) { a := NewAlerts() alert := &types.Alert{ UpdatedAt: time.Now(), } require.NoError(t, a.Set(alert)) want := alert.Fingerprint() got, err := a.Get(want) require.NoError(t, err) require.Equal(t, want, got.Fingerprint()) } func TestDeleteIfNotModified(t *testing.T) { t.Run("unmodified alert should be deleted", func(t *testing.T) { a := NewAlerts() a1 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "foo": "bar", }, }, UpdatedAt: time.Now().Add(-time.Second), } require.NoError(t, a.Set(a1)) // a1 should be deleted as it has not been modified. a.DeleteIfNotModified(types.AlertSlice{a1}, false) got, err := a.Get(a1.Fingerprint()) require.Equal(t, ErrNotFound, err) require.Nil(t, got) }) t.Run("modified alert should not be deleted", func(t *testing.T) { a := NewAlerts() a1 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "foo": "bar", }, }, UpdatedAt: time.Now(), } require.NoError(t, a.Set(a1)) // Make a copy of a1 that is older, but do not put it. // We want to make sure a1 is not deleted. a2 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "foo": "bar", }, }, UpdatedAt: time.Now().Add(-time.Second), } require.True(t, a2.UpdatedAt.Before(a1.UpdatedAt)) a.DeleteIfNotModified(types.AlertSlice{a2}, false) // a1 should not be deleted. got, err := a.Get(a1.Fingerprint()) require.NoError(t, err) require.Equal(t, a1, got) // Make another copy of a1 that is older, but do not put it. // We want to make sure a2 is not deleted here either. a3 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "foo": "bar", }, }, UpdatedAt: time.Now().Add(time.Second), } require.True(t, a3.UpdatedAt.After(a1.UpdatedAt)) a.DeleteIfNotModified(types.AlertSlice{a3}, false) // a1 should not be deleted. got, err = a.Get(a1.Fingerprint()) require.NoError(t, err) require.Equal(t, a1, got) }) t.Run("should not delete other alerts", func(t *testing.T) { a := NewAlerts() a1 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "foo": "bar", }, }, UpdatedAt: time.Now(), } a2 := &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{ "bar": "baz", }, }, UpdatedAt: time.Now(), } require.NoError(t, a.Set(a1)) require.NoError(t, a.Set(a2)) // Deleting a1 should not delete a2. require.NoError(t, a.DeleteIfNotModified(types.AlertSlice{a1}, true)) // a1 should be deleted. got, err := a.Get(a1.Fingerprint()) require.Equal(t, ErrNotFound, err) require.False(t, a.Destroyed()) require.Nil(t, got) // a2 should not be deleted. got, err = a.Get(a2.Fingerprint()) require.NoError(t, err) require.Equal(t, a2, got) }) } func TestGC(t *testing.T) { now := time.Now() newAlert := func(key string, start, end time.Duration) *types.Alert { return &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{model.LabelName(key): "b"}, StartsAt: now.Add(start * time.Minute), EndsAt: now.Add(end * time.Minute), }, } } active := []*types.Alert{ newAlert("b", 10, 20), newAlert("c", -10, 10), } resolved := []*types.Alert{ newAlert("a", -10, -5), newAlert("d", -10, -1), } s := NewAlerts() var ( n int done = make(chan struct{}) ctx, cancel = context.WithCancel(context.Background()) ) s.SetGCCallback(func(a []*types.Alert) { n += len(a) if n >= len(resolved) { cancel() } }) for _, alert := range append(active, resolved...) { require.NoError(t, s.Set(alert)) } go func() { s.Run(ctx, 10*time.Millisecond) close(done) }() select { case <-done: break case <-time.After(1 * time.Second): t.Fatal("garbage collection didn't complete in time") } for _, alert := range active { if _, err := s.Get(alert.Fingerprint()); err != nil { t.Errorf("alert %v should not have been gc'd", alert) } } for _, alert := range resolved { if _, err := s.Get(alert.Fingerprint()); err == nil { t.Errorf("alert %v should have been gc'd", alert) } } require.Len(t, resolved, n) } ================================================ FILE: template/Dockerfile ================================================ FROM node:20-alpine ENV NODE_PATH="/usr/local/lib/node_modules" RUN npm install juice@10.0.1 -g ENTRYPOINT [""] ================================================ FILE: template/Makefile ================================================ DOCKER_IMG := alertmanager-template DOCKER_RUN_CURRENT_USER := docker run --user=$(shell id -u $(USER)):$(shell id -g $(USER)) DOCKER_CMD := $(DOCKER_RUN_CURRENT_USER) --rm -t -v $(PWD):/app -w /app $(DOCKER_IMG) ifeq ($(NO_DOCKER), true) DOCKER_CMD= endif template-image: @(if [ "$(NO_DOCKER)" != "true" ] ; then \ echo ">> build template docker image"; \ docker build -t $(DOCKER_IMG) . > /dev/null; \ fi; ) email.tmpl: template-image email.html @echo ">> inline css for html email template" $(DOCKER_CMD) ./inline-css.js ================================================ FILE: template/default.tmpl ================================================ {{ define "__alertmanager" }}Alertmanager{{ end }} {{ define "__alertmanagerURL" }}{{ .ExternalURL }}/#/alerts?receiver={{ .Receiver | urlquery }}{{ end }} {{ define "__subject" }}[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .GroupLabels.SortedPairs.Values | join " " }} {{ if gt (len .CommonLabels) (len .GroupLabels) }}({{ with .CommonLabels.Remove .GroupLabels.Names }}{{ .Values | join " " }}{{ end }}){{ end }}{{ end }} {{ define "__description" }}{{ end }} {{ define "__text_alert_list" }}{{ range . }}Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }}Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }}Source: {{ .GeneratorURL }} {{ end }}{{ end }} {{ define "__text_alert_list_markdown" }}{{ range . }} Labels: {{ range .Labels.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Annotations: {{ range .Annotations.SortedPairs }} - {{ .Name }} = {{ .Value }} {{ end }} Source: {{ .GeneratorURL }} {{ end }} {{ end }} {{ define "slack.default.title" }}{{ template "__subject" . }}{{ end }} {{ define "slack.default.username" }}{{ template "__alertmanager" . }}{{ end }} {{ define "slack.default.fallback" }}{{ template "slack.default.title" . }} | {{ template "slack.default.titlelink" . }}{{ end }} {{ define "slack.default.callbackid" }}{{ end }} {{ define "slack.default.pretext" }}{{ end }} {{ define "slack.default.titlelink" }}{{ template "__alertmanagerURL" . }}{{ end }} {{ define "slack.default.iconemoji" }}{{ end }} {{ define "slack.default.iconurl" }}{{ end }} {{ define "slack.default.text" }}{{ end }} {{ define "slack.default.footer" }}{{ end }} {{ define "slack.default.color" }}{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}{{ end }} {{ define "pagerduty.default.description" }}{{ template "__subject" . }}{{ end }} {{ define "pagerduty.default.client" }}{{ template "__alertmanager" . }}{{ end }} {{ define "pagerduty.default.clientURL" }}{{ template "__alertmanagerURL" . }}{{ end }} {{ define "pagerduty.default.instances" }}{{ template "__text_alert_list" . }}{{ end }} {{ define "opsgenie.default.message" }}{{ template "__subject" . }}{{ end }} {{ define "opsgenie.default.description" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }} {{ if gt (len .Alerts.Firing) 0 -}} Alerts Firing: {{ template "__text_alert_list" .Alerts.Firing }} {{- end }} {{ if gt (len .Alerts.Resolved) 0 -}} Alerts Resolved: {{ template "__text_alert_list" .Alerts.Resolved }} {{- end }} {{- end }} {{ define "opsgenie.default.source" }}{{ template "__alertmanagerURL" . }}{{ end }} {{ define "wechat.default.message" }}{{ template "__subject" . }} {{ .CommonAnnotations.SortedPairs.Values | join " " }} {{ if gt (len .Alerts.Firing) 0 -}} Alerts Firing: {{ template "__text_alert_list" .Alerts.Firing }} {{- end }} {{ if gt (len .Alerts.Resolved) 0 -}} Alerts Resolved: {{ template "__text_alert_list" .Alerts.Resolved }} {{- end }} AlertmanagerUrl: {{ template "__alertmanagerURL" . }} {{- end }} {{ define "wechat.default.to_user" }}{{ end }} {{ define "wechat.default.to_party" }}{{ end }} {{ define "wechat.default.to_tag" }}{{ end }} {{ define "wechat.default.agent_id" }}{{ end }} {{ define "victorops.default.state_message" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }} {{ if gt (len .Alerts.Firing) 0 -}} Alerts Firing: {{ template "__text_alert_list" .Alerts.Firing }} {{- end }} {{ if gt (len .Alerts.Resolved) 0 -}} Alerts Resolved: {{ template "__text_alert_list" .Alerts.Resolved }} {{- end }} {{- end }} {{ define "victorops.default.entity_display_name" }}{{ template "__subject" . }}{{ end }} {{ define "victorops.default.monitoring_tool" }}{{ template "__alertmanager" . }}{{ end }} {{ define "pushover.default.title" }}{{ template "__subject" . }}{{ end }} {{ define "pushover.default.message" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }} {{ if gt (len .Alerts.Firing) 0 }} Alerts Firing: {{ template "__text_alert_list" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} Alerts Resolved: {{ template "__text_alert_list" .Alerts.Resolved }} {{ end }} {{ end }} {{ define "pushover.default.url" }}{{ template "__alertmanagerURL" . }}{{ end }} {{ define "sns.default.subject" }}{{ template "__subject" . }}{{ end }} {{ define "sns.default.message" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }} {{ if gt (len .Alerts.Firing) 0 }} Alerts Firing: {{ template "__text_alert_list" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} Alerts Resolved: {{ template "__text_alert_list" .Alerts.Resolved }} {{ end }} {{ end }} {{ define "telegram.default.message" }} {{ if gt (len .Alerts.Firing) 0 }} Alerts Firing: {{ template "__text_alert_list" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} Alerts Resolved: {{ template "__text_alert_list" .Alerts.Resolved }} {{ end }} {{ end }} {{ define "discord.default.content" }}{{ end }} {{ define "discord.default.title" }}{{ template "__subject" . }}{{ end }} {{ define "discord.default.message" }} {{ if gt (len .Alerts.Firing) 0 }} Alerts Firing: {{ template "__text_alert_list" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} Alerts Resolved: {{ template "__text_alert_list" .Alerts.Resolved }} {{ end }} {{ end }} {{ define "webex.default.message" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }} {{ if gt (len .Alerts.Firing) 0 }} Alerts Firing: {{ template "__text_alert_list" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} Alerts Resolved: {{ template "__text_alert_list" .Alerts.Resolved }} {{ end }} {{ end }} {{ define "msteams.default.summary" }}{{ template "__subject" . }}{{ end }} {{ define "msteams.default.title" }}{{ template "__subject" . }}{{ end }} {{ define "msteams.default.text" }} {{ if gt (len .Alerts.Firing) 0 }} # Alerts Firing: {{ template "__text_alert_list_markdown" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} # Alerts Resolved: {{ template "__text_alert_list_markdown" .Alerts.Resolved }} {{ end }} {{ end }} {{ define "msteamsv2.default.title" }}{{ template "__subject" . }}{{ end }} {{ define "msteamsv2.default.text" }} {{ if gt (len .Alerts.Firing) 0 }} # Alerts Firing: {{ template "__text_alert_list_markdown" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} # Alerts Resolved: {{ template "__text_alert_list_markdown" .Alerts.Resolved }} {{ end }} {{ end }} {{ define "jira.default.summary" }}{{ template "__subject" . }}{{ end }} {{ define "jira.default.description" }} {{ if gt (len .Alerts.Firing) 0 }} # Alerts Firing: {{ template "__text_alert_list_markdown" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} # Alerts Resolved: {{ template "__text_alert_list_markdown" .Alerts.Resolved }} {{ end }} {{ end }} {{- define "jira.default.priority" -}} {{- $priority := "" }} {{- range .Alerts.Firing -}} {{- $severity := index .Labels "severity" -}} {{- if (eq $severity "critical") -}} {{- $priority = "High" -}} {{- else if (and (eq $severity "warning") (ne $priority "High")) -}} {{- $priority = "Medium" -}} {{- else if (and (eq $severity "info") (eq $priority "")) -}} {{- $priority = "Low" -}} {{- end -}} {{- end -}} {{- if eq $priority "" -}} {{- range .Alerts.Resolved -}} {{- $severity := index .Labels "severity" -}} {{- if (eq $severity "critical") -}} {{- $priority = "High" -}} {{- else if (and (eq $severity "warning") (ne $priority "High")) -}} {{- $priority = "Medium" -}} {{- else if (and (eq $severity "info") (eq $priority "")) -}} {{- $priority = "Low" -}} {{- end -}} {{- end -}} {{- end -}} {{- $priority -}} {{- end -}} {{ define "rocketchat.default.title" }}{{ template "__subject" . }}{{ end }} {{ define "rocketchat.default.alias" }}{{ template "__alertmanager" . }}{{ end }} {{ define "rocketchat.default.titlelink" }}{{ template "__alertmanagerURL" . }}{{ end }} {{ define "rocketchat.default.emoji" }}{{ end }} {{ define "rocketchat.default.iconurl" }}{{ end }} {{ define "rocketchat.default.text" }}{{ end }} {{ define "mattermost.default.color" }}{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}{{ end }} {{ define "mattermost.default.username" }}{{ template "__alertmanager" . }}{{ end }} {{ define "mattermost.default.title" }}{{ template "__subject" . }}{{ end }} {{ define "mattermost.default.titlelink" }}{{ template "__alertmanagerURL" . }}{{ end }} {{ define "mattermost.default.fallback" }}{{ template "mattermost.default.title" . }} | {{ template "mattermost.default.titlelink" . }}{{ end }} {{ define "mattermost.default.text" }} {{ if gt (len .Alerts.Firing) 0 }} # Alerts Firing: {{ template "__text_alert_list_markdown" .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} # Alerts Resolved: {{ template "__text_alert_list_markdown" .Alerts.Resolved }} {{ end }} {{ end }} ================================================ FILE: template/email.html ================================================ {{ template "__subject" . }}
{{ if gt (len .Alerts.Firing) 0 }} {{ else }} {{ end }}
{{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for {{ range .GroupLabels.SortedPairs }} {{ .Name }}={{ .Value }} {{ end }} {{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for {{ range .GroupLabels.SortedPairs }} {{ .Name }}={{ .Value }} {{ end }}
{{ if gt (len .Alerts.Firing) 0 }} {{ end }} {{ range .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} {{ if gt (len .Alerts.Firing) 0 }} {{ end }} {{ end }} {{ range .Alerts.Resolved }} {{ end }}
View in {{ template "__alertmanager" . }}
[{{ .Alerts.Firing | len }}] Firing
Labels
{{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} {{ if gt (len .Annotations) 0 }}Annotations
{{ end }} {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} Source



[{{ .Alerts.Resolved | len }}] Resolved
Labels
{{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} {{ if gt (len .Annotations) 0 }}Annotations
{{ end }} {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} Source
================================================ FILE: template/email.tmpl ================================================ {{ define "email.default.subject" }}{{ template "__subject" . }}{{ end }} {{ define "email.default.html" }} {{ template "__subject" . }}
{{ if gt (len .Alerts.Firing) 0 }} {{ else }} {{ end }}
{{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for {{ range .GroupLabels.SortedPairs }} {{ .Name }}={{ .Value }} {{ end }} {{ .Alerts | len }} alert{{ if gt (len .Alerts) 1 }}s{{ end }} for {{ range .GroupLabels.SortedPairs }} {{ .Name }}={{ .Value }} {{ end }}
{{ if gt (len .Alerts.Firing) 0 }} {{ end }} {{ range .Alerts.Firing }} {{ end }} {{ if gt (len .Alerts.Resolved) 0 }} {{ if gt (len .Alerts.Firing) 0 }} {{ end }} {{ end }} {{ range .Alerts.Resolved }} {{ end }}
View in {{ template "__alertmanager" . }}
[{{ .Alerts.Firing | len }}] Firing
Labels
{{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} {{ if gt (len .Annotations) 0 }}Annotations
{{ end }} {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} Source



[{{ .Alerts.Resolved | len }}] Resolved
Labels
{{ range .Labels.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} {{ if gt (len .Annotations) 0 }}Annotations
{{ end }} {{ range .Annotations.SortedPairs }}{{ .Name }} = {{ .Value }}
{{ end }} Source
{{ end }} ================================================ FILE: template/inline-css.js ================================================ #!/usr/bin/env node // Copyright 2021 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. const juice = require('juice') const fs = require('fs') const inputFile = 'email.html' const outputFile = 'email.tmpl' var inputData = '' try { inputData = fs.readFileSync(inputFile, 'utf8') } catch (err) { console.error(err) process.exit(1) } var templateData = juice(inputData) const outputData = ` {{ define "email.default.subject" }}{{ template "__subject" . }}{{ end }} {{ define "email.default.html" }} ${templateData} {{ end }} ` fs.writeFileSync(outputFile, outputData) ================================================ FILE: template/template.go ================================================ // Copyright 2015 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package template import ( "bytes" "embed" "encoding/json" tmplhtml "html/template" "io" "net/url" "path/filepath" "reflect" "regexp" "sort" "strings" tmpltext "text/template" "time" commonTemplates "github.com/prometheus/common/helpers/templates" "github.com/prometheus/common/model" "golang.org/x/text/cases" "golang.org/x/text/language" "gopkg.in/yaml.v2" "github.com/prometheus/alertmanager/types" ) //go:embed default.tmpl email.tmpl var asset embed.FS // Template bundles a text and a html template instance. type Template struct { text *tmpltext.Template html *tmplhtml.Template ExternalURL *url.URL } // Option is generic modifier of the text and html templates used by a Template. type Option func(text *tmpltext.Template, html *tmplhtml.Template) // New returns a new Template with the DefaultFuncs added. The DefaultFuncs // have precedence over any added custom functions. Options allow customization // of the text and html templates in given order. func New(options ...Option) (*Template, error) { t := &Template{ text: tmpltext.New("").Option("missingkey=zero"), html: tmplhtml.New("").Option("missingkey=zero"), } for _, o := range options { o(t.text, t.html) } t.text.Funcs(tmpltext.FuncMap(DefaultFuncs)) t.html.Funcs(tmplhtml.FuncMap(DefaultFuncs)) return t, nil } // FromGlobs calls ParseGlob on all path globs provided and returns the // resulting Template. func FromGlobs(paths []string, options ...Option) (*Template, error) { t, err := New(options...) if err != nil { return nil, err } defaultTemplates := []string{"default.tmpl", "email.tmpl"} for _, file := range defaultTemplates { f, err := asset.Open(file) if err != nil { return nil, err } if err := t.Parse(f); err != nil { f.Close() return nil, err } f.Close() } for _, tp := range paths { if err := t.FromGlob(tp); err != nil { return nil, err } } return t, nil } // Parse parses the given text into the template. func (t *Template) Parse(r io.Reader) error { b, err := io.ReadAll(r) if err != nil { return err } if t.text, err = t.text.Parse(string(b)); err != nil { return err } if t.html, err = t.html.Parse(string(b)); err != nil { return err } return nil } // FromGlob calls ParseGlob on given path glob provided and parses into the // template. func (t *Template) FromGlob(path string) error { // ParseGlob in the template packages errors if not at least one file is // matched. We want to allow empty matches that may be populated later on. p, err := filepath.Glob(path) if err != nil { return err } if len(p) > 0 { if t.text, err = t.text.ParseGlob(path); err != nil { return err } if t.html, err = t.html.ParseGlob(path); err != nil { return err } } return nil } // ExecuteTextString needs a meaningful doc comment (TODO(fabxc)). func (t *Template) ExecuteTextString(text string, data any) (string, error) { if text == "" { return "", nil } tmpl, err := t.text.Clone() if err != nil { return "", err } tmpl, err = tmpl.New("").Option("missingkey=zero").Parse(text) if err != nil { return "", err } var buf bytes.Buffer err = tmpl.Execute(&buf, data) return buf.String(), err } // ExecuteHTMLString needs a meaningful doc comment (TODO(fabxc)). func (t *Template) ExecuteHTMLString(html string, data any) (string, error) { if html == "" { return "", nil } tmpl, err := t.html.Clone() if err != nil { return "", err } tmpl, err = tmpl.New("").Option("missingkey=zero").Parse(html) if err != nil { return "", err } var buf bytes.Buffer err = tmpl.Execute(&buf, data) return buf.String(), err } type FuncMap map[string]any var DefaultFuncs = FuncMap{ "toUpper": strings.ToUpper, "toLower": strings.ToLower, "title": func(text string) string { // Casers should not be shared between goroutines, instead // create a new caser each time this function is called. return cases.Title(language.AmericanEnglish).String(text) }, "trimSpace": strings.TrimSpace, // join is equal to strings.Join but inverts the argument order // for easier pipelining in templates. "join": func(sep string, s []string) string { return strings.Join(s, sep) }, "match": regexp.MatchString, "safeHtml": func(text string) tmplhtml.HTML { return tmplhtml.HTML(text) }, "safeUrl": func(text string) tmplhtml.URL { return tmplhtml.URL(text) }, "urlUnescape": url.QueryUnescape, "reReplaceAll": func(pattern, repl, text string) string { re := regexp.MustCompile(pattern) return re.ReplaceAllString(text, repl) }, "stringSlice": func(s ...string) []string { return s }, // date returns the text representation of the time in the specified format. "date": func(fmt string, t time.Time) string { return t.Format(fmt) }, // tz returns the time in the timezone. "tz": func(name string, t time.Time) (time.Time, error) { loc, err := time.LoadLocation(name) if err != nil { return time.Time{}, err } return t.In(loc), nil }, "since": time.Since, "humanizeDuration": commonTemplates.HumanizeDuration, "toJson": func(v any) (string, error) { bytes, err := json.Marshal(v) if err != nil { return "", err } return string(bytes), nil }, } // Pair is a key/value string pair. type Pair struct { Name, Value string } // Pairs is a list of key/value string pairs. type Pairs []Pair // Names returns a list of names of the pairs. func (ps Pairs) Names() []string { ns := make([]string, 0, len(ps)) for _, p := range ps { ns = append(ns, p.Name) } return ns } // Values returns a list of values of the pairs. func (ps Pairs) Values() []string { vs := make([]string, 0, len(ps)) for _, p := range ps { vs = append(vs, p.Value) } return vs } func (ps Pairs) String() string { b := strings.Builder{} for i, p := range ps { b.WriteString(p.Name) b.WriteRune('=') b.WriteString(p.Value) if i < len(ps)-1 { b.WriteString(", ") } } return b.String() } // KV is a set of key/value string pairs. type KV map[string]string // SortedPairs returns a sorted list of key/value pairs. func (kv KV) SortedPairs() Pairs { var ( pairs = make([]Pair, 0, len(kv)) keys = make([]string, 0, len(kv)) sortStart = 0 ) for k := range kv { if k == string(model.AlertNameLabel) { keys = append([]string{k}, keys...) sortStart = 1 } else { keys = append(keys, k) } } sort.Strings(keys[sortStart:]) for _, k := range keys { pairs = append(pairs, Pair{k, kv[k]}) } return pairs } // Remove returns a copy of the key/value set without the given keys. func (kv KV) Remove(keys []string) KV { keySet := make(map[string]struct{}, len(keys)) for _, k := range keys { keySet[k] = struct{}{} } res := KV{} for k, v := range kv { if _, ok := keySet[k]; !ok { res[k] = v } } return res } // Names returns the names of the label names in the LabelSet. func (kv KV) Names() []string { return kv.SortedPairs().Names() } // Values returns a list of the values in the LabelSet. func (kv KV) Values() []string { return kv.SortedPairs().Values() } func (kv KV) String() string { return kv.SortedPairs().String() } // Data is the data passed to notification templates and webhook pushes. // // End-users should not be exposed to Go's type system, as this will confuse them and prevent // simple things like simple equality checks to fail. Map everything to float64/string. type Data struct { Receiver string `json:"receiver"` Status string `json:"status"` Alerts Alerts `json:"alerts"` NotificationReason string `json:"notification_reason"` GroupLabels KV `json:"groupLabels"` CommonLabels KV `json:"commonLabels"` CommonAnnotations KV `json:"commonAnnotations"` ExternalURL string `json:"externalURL"` } // Alert holds one alert for notification templates. type Alert struct { Status string `json:"status"` Labels KV `json:"labels"` Annotations KV `json:"annotations"` StartsAt time.Time `json:"startsAt"` EndsAt time.Time `json:"endsAt"` GeneratorURL string `json:"generatorURL"` Fingerprint string `json:"fingerprint"` } // Alerts is a list of Alert objects. type Alerts []Alert // Firing returns the subset of alerts that are firing. func (as Alerts) Firing() []Alert { res := []Alert{} for _, a := range as { if a.Status == string(model.AlertFiring) { res = append(res, a) } } return res } // Resolved returns the subset of alerts that are resolved. func (as Alerts) Resolved() []Alert { res := []Alert{} for _, a := range as { if a.Status == string(model.AlertResolved) { res = append(res, a) } } return res } // Data assembles data for template expansion. func (t *Template) Data(recv string, groupLabels model.LabelSet, notificationReason string, alerts ...*types.Alert) *Data { typedAlerts := types.Alerts(alerts...) data := &Data{ Receiver: regexp.QuoteMeta(recv), Status: string(typedAlerts.Status()), Alerts: make(Alerts, 0, len(alerts)), NotificationReason: notificationReason, GroupLabels: KV{}, CommonLabels: KV{}, CommonAnnotations: KV{}, ExternalURL: t.ExternalURL.String(), } // The call to types.Alert is necessary to correctly resolve the internal // representation to the user representation. for _, a := range typedAlerts { alert := Alert{ Status: string(a.Status()), Labels: make(KV, len(a.Labels)), Annotations: make(KV, len(a.Annotations)), StartsAt: a.StartsAt, EndsAt: a.EndsAt, GeneratorURL: a.GeneratorURL, Fingerprint: a.Fingerprint().String(), } for k, v := range a.Labels { alert.Labels[string(k)] = string(v) } for k, v := range a.Annotations { alert.Annotations[string(k)] = string(v) } data.Alerts = append(data.Alerts, alert) } for k, v := range groupLabels { data.GroupLabels[string(k)] = string(v) } if len(alerts) >= 1 { var ( commonLabels = alerts[0].Labels.Clone() commonAnnotations = alerts[0].Annotations.Clone() ) for _, a := range alerts[1:] { if len(commonLabels) == 0 && len(commonAnnotations) == 0 { break } for ln, lv := range commonLabels { if a.Labels[ln] != lv { delete(commonLabels, ln) } } for an, av := range commonAnnotations { if a.Annotations[an] != av { delete(commonAnnotations, an) } } } for k, v := range commonLabels { data.CommonLabels[string(k)] = string(v) } for k, v := range commonAnnotations { data.CommonAnnotations[string(k)] = string(v) } } return data } type TemplateFunc func(string) (string, error) // DeepCopyWithTemplate returns a deep copy of a map/slice/array/string/int/bool or combination thereof, executing the // provided template (with the provided data) on all string keys or values. All maps are connverted to // map[string]any, with all non-string keys discarded. func DeepCopyWithTemplate(value any, tmplTextFunc TemplateFunc) (any, error) { if value == nil { return value, nil } valueMeta := reflect.ValueOf(value) switch valueMeta.Kind() { case reflect.String: parsed, ok := tmplTextFunc(value.(string)) if ok == nil { var inlineType any err := yaml.Unmarshal([]byte(parsed), &inlineType) if err != nil || (inlineType != nil && reflect.TypeOf(inlineType).Kind() == reflect.String) { // ignore error, thus the string is not an interface return parsed, ok } return DeepCopyWithTemplate(inlineType, tmplTextFunc) } return parsed, ok case reflect.Array, reflect.Slice: arrayLen := valueMeta.Len() converted := make([]any, arrayLen) for i := range arrayLen { var err error converted[i], err = DeepCopyWithTemplate(valueMeta.Index(i).Interface(), tmplTextFunc) if err != nil { return nil, err } } return converted, nil case reflect.Map: keys := valueMeta.MapKeys() converted := make(map[string]any, len(keys)) for _, keyMeta := range keys { var err error strKey, isString := keyMeta.Interface().(string) if !isString { continue } strKey, err = tmplTextFunc(strKey) if err != nil { return nil, err } converted[strKey], err = DeepCopyWithTemplate(valueMeta.MapIndex(keyMeta).Interface(), tmplTextFunc) if err != nil { return nil, err } } return converted, nil default: return value, nil } } ================================================ FILE: template/template_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package template import ( tmplhtml "html/template" "net/url" "sync" "testing" tmpltext "text/template" "time" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/types" ) func TestPairNames(t *testing.T) { pairs := Pairs{ {"name1", "value1"}, {"name2", "value2"}, {"name3", "value3"}, } expected := []string{"name1", "name2", "name3"} require.Equal(t, expected, pairs.Names()) } func TestPairValues(t *testing.T) { pairs := Pairs{ {"name1", "value1"}, {"name2", "value2"}, {"name3", "value3"}, } expected := []string{"value1", "value2", "value3"} require.Equal(t, expected, pairs.Values()) } func TestPairsString(t *testing.T) { pairs := Pairs{{"name1", "value1"}} require.Equal(t, "name1=value1", pairs.String()) pairs = append(pairs, Pair{"name2", "value2"}) require.Equal(t, "name1=value1, name2=value2", pairs.String()) } func TestKVSortedPairs(t *testing.T) { kv := KV{"d": "dVal", "b": "bVal", "c": "cVal"} expectedPairs := Pairs{ {"b", "bVal"}, {"c", "cVal"}, {"d", "dVal"}, } for i, p := range kv.SortedPairs() { require.Equal(t, p.Name, expectedPairs[i].Name) require.Equal(t, p.Value, expectedPairs[i].Value) } // validates alertname always comes first kv = KV{"d": "dVal", "b": "bVal", "c": "cVal", "alertname": "alert", "a": "aVal"} expectedPairs = Pairs{ {"alertname", "alert"}, {"a", "aVal"}, {"b", "bVal"}, {"c", "cVal"}, {"d", "dVal"}, } for i, p := range kv.SortedPairs() { require.Equal(t, p.Name, expectedPairs[i].Name) require.Equal(t, p.Value, expectedPairs[i].Value) } } func TestKVRemove(t *testing.T) { kv := KV{ "key1": "val1", "key2": "val2", "key3": "val3", "key4": "val4", } kv = kv.Remove([]string{"key2", "key4"}) expected := []string{"key1", "key3"} require.Equal(t, expected, kv.Names()) } func TestAlertsFiring(t *testing.T) { alerts := Alerts{ {Status: string(model.AlertFiring)}, {Status: string(model.AlertResolved)}, {Status: string(model.AlertFiring)}, {Status: string(model.AlertResolved)}, {Status: string(model.AlertResolved)}, } for _, alert := range alerts.Firing() { if alert.Status != string(model.AlertFiring) { t.Errorf("unexpected status %q", alert.Status) } } } func TestAlertsResolved(t *testing.T) { alerts := Alerts{ {Status: string(model.AlertFiring)}, {Status: string(model.AlertResolved)}, {Status: string(model.AlertFiring)}, {Status: string(model.AlertResolved)}, {Status: string(model.AlertResolved)}, } for _, alert := range alerts.Resolved() { if alert.Status != string(model.AlertResolved) { t.Errorf("unexpected status %q", alert.Status) } } } func TestData(t *testing.T) { u, err := url.Parse("http://example.com/") require.NoError(t, err) tmpl := &Template{ExternalURL: u} startTime := time.Time{}.Add(1 * time.Second) endTime := time.Time{}.Add(2 * time.Second) for _, tc := range []struct { receiver string groupLabels model.LabelSet alerts []*types.Alert exp *Data }{ { receiver: "webhook", exp: &Data{ Receiver: "webhook", Status: "resolved", Alerts: Alerts{}, NotificationReason: "first notification", GroupLabels: KV{}, CommonLabels: KV{}, CommonAnnotations: KV{}, ExternalURL: u.String(), }, }, { receiver: "webhook", groupLabels: model.LabelSet{ model.LabelName("job"): model.LabelValue("foo"), }, alerts: []*types.Alert{ { Alert: model.Alert{ StartsAt: startTime, Labels: model.LabelSet{ model.LabelName("severity"): model.LabelValue("warning"), model.LabelName("job"): model.LabelValue("foo"), }, Annotations: model.LabelSet{ model.LabelName("description"): model.LabelValue("something happened"), model.LabelName("runbook"): model.LabelValue("foo"), }, }, }, { Alert: model.Alert{ StartsAt: startTime, EndsAt: endTime, Labels: model.LabelSet{ model.LabelName("severity"): model.LabelValue("critical"), model.LabelName("job"): model.LabelValue("foo"), }, Annotations: model.LabelSet{ model.LabelName("description"): model.LabelValue("something else happened"), model.LabelName("runbook"): model.LabelValue("foo"), }, }, }, }, exp: &Data{ Receiver: "webhook", Status: "firing", Alerts: Alerts{ { Status: "firing", Labels: KV{"severity": "warning", "job": "foo"}, Annotations: KV{"description": "something happened", "runbook": "foo"}, StartsAt: startTime, Fingerprint: "9266ef3da838ad95", }, { Status: "resolved", Labels: KV{"severity": "critical", "job": "foo"}, Annotations: KV{"description": "something else happened", "runbook": "foo"}, StartsAt: startTime, EndsAt: endTime, Fingerprint: "3b15fd163d36582e", }, }, NotificationReason: "first notification", GroupLabels: KV{"job": "foo"}, CommonLabels: KV{"job": "foo"}, CommonAnnotations: KV{"runbook": "foo"}, ExternalURL: u.String(), }, }, { receiver: "webhook", groupLabels: model.LabelSet{}, alerts: []*types.Alert{ { Alert: model.Alert{ StartsAt: startTime, Labels: model.LabelSet{ model.LabelName("severity"): model.LabelValue("warning"), model.LabelName("job"): model.LabelValue("foo"), }, Annotations: model.LabelSet{ model.LabelName("description"): model.LabelValue("something happened"), model.LabelName("runbook"): model.LabelValue("foo"), }, }, }, { Alert: model.Alert{ StartsAt: startTime, EndsAt: endTime, Labels: model.LabelSet{ model.LabelName("severity"): model.LabelValue("critical"), model.LabelName("job"): model.LabelValue("bar"), }, Annotations: model.LabelSet{ model.LabelName("description"): model.LabelValue("something else happened"), model.LabelName("runbook"): model.LabelValue("bar"), }, }, }, }, exp: &Data{ Receiver: "webhook", Status: "firing", Alerts: Alerts{ { Status: "firing", Labels: KV{"severity": "warning", "job": "foo"}, Annotations: KV{"description": "something happened", "runbook": "foo"}, StartsAt: startTime, Fingerprint: "9266ef3da838ad95", }, { Status: "resolved", Labels: KV{"severity": "critical", "job": "bar"}, Annotations: KV{"description": "something else happened", "runbook": "bar"}, StartsAt: startTime, EndsAt: endTime, Fingerprint: "c7e68cb08e3e67f9", }, }, NotificationReason: "first notification", GroupLabels: KV{}, CommonLabels: KV{}, CommonAnnotations: KV{}, ExternalURL: u.String(), }, }, } { t.Run("", func(t *testing.T) { got := tmpl.Data(tc.receiver, tc.groupLabels, "first notification", tc.alerts...) require.Equal(t, tc.exp, got) }) } } func TestTemplateExpansion(t *testing.T) { tmpl, err := FromGlobs([]string{}) require.NoError(t, err) for _, tc := range []struct { title string in string data any html bool exp string fail bool }{ { title: "Template without action", in: `abc`, exp: "abc", }, { title: "Template with simple action", in: `{{ "abc" }}`, exp: "abc", }, { title: "Template with invalid syntax", in: `{{ `, fail: true, }, { title: "Template using toUpper", in: `{{ "abc" | toUpper }}`, exp: "ABC", }, { title: "Template using toLower", in: `{{ "ABC" | toLower }}`, exp: "abc", }, { title: "Template using title", in: `{{ "abc" | title }}`, exp: "Abc", }, { title: "Template using TrimSpace", in: `{{ " a b c " | trimSpace }}`, exp: "a b c", }, { title: "Template using positive match", in: `{{ if match "^a" "abc"}}abc{{ end }}`, exp: "abc", }, { title: "Template using negative match", in: `{{ if match "abcd" "abc" }}abc{{ end }}`, exp: "", }, { title: "Template using join", in: `{{ . | join "," }}`, data: []string{"a", "b", "c"}, exp: "a,b,c", }, { title: "Text template without HTML escaping", in: `{{ "" }}`, exp: "", }, { title: "HTML template with escaping", in: `{{ "" }}`, html: true, exp: "<b>", }, { title: "HTML template using safeHTML", in: `{{ "" | safeHtml }}`, html: true, exp: "", }, { title: "URL template with escaping", in: ``, html: true, exp: ``, }, { title: "URL template using safeUrl", in: ``, html: true, exp: ``, }, { title: "Template using reReplaceAll", in: `{{ reReplaceAll "ab" "AB" "abcdabcda"}}`, exp: "ABcdABcda", }, { title: "Template using urlUnescape", in: `{{ "search?q=test%20foo" | urlUnescape }}`, exp: "search?q=test foo", }, { title: "Template using stringSlice", in: `{{ with .GroupLabels }}{{ with .Remove (stringSlice "key1" "key3") }}{{ .SortedPairs.Values }}{{ end }}{{ end }}`, data: Data{ GroupLabels: KV{ "key1": "key1", "key2": "key2", "key3": "key3", "key4": "key4", }, }, exp: "[key2 key4]", }, { title: "Template using toJson with string", in: `{{ "test" | toJson }}`, exp: `"test"`, }, { title: "Template using toJson with number", in: `{{ 42 | toJson }}`, exp: `42`, }, { title: "Template using toJson with boolean", in: `{{ true | toJson }}`, exp: `true`, }, { title: "Template using toJson with map", in: `{{ . | toJson }}`, data: map[string]any{"key": "value", "number": 123}, exp: `{"key":"value","number":123}`, }, { title: "Template using toJson with slice", in: `{{ . | toJson }}`, data: []string{"a", "b", "c"}, exp: `["a","b","c"]`, }, { title: "Template using toJson with KV", in: `{{ .CommonLabels | toJson }}`, data: Data{ CommonLabels: KV{"severity": "critical", "job": "foo"}, }, exp: `{"job":"foo","severity":"critical"}`, }, { title: "Template using toJson with Alerts", in: `{{ .Alerts | toJson }}`, data: Data{ Alerts: Alerts{ { Status: "firing", Labels: KV{"alertname": "test"}, }, }, }, exp: `[{"status":"firing","labels":{"alertname":"test"},"annotations":null,"startsAt":"0001-01-01T00:00:00Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"","fingerprint":""}]`, }, { title: "Template using toJson with Alerts.Firing()", in: `{{ .Alerts.Firing | toJson }}`, data: Data{ Alerts: Alerts{ {Status: "firing"}, {Status: "resolved"}, }, }, exp: `[{"status":"firing","labels":null,"annotations":null,"startsAt":"0001-01-01T00:00:00Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"","fingerprint":""}]`, }, } { t.Run(tc.title, func(t *testing.T) { f := tmpl.ExecuteTextString if tc.html { f = tmpl.ExecuteHTMLString } got, err := f(tc.in, tc.data) if tc.fail { require.Error(t, err) return } require.NoError(t, err) require.Equal(t, tc.exp, got) }) } } func TestTemplateExpansionWithOptions(t *testing.T) { testOptionWithAdditionalFuncs := func(funcs FuncMap) Option { return func(text *tmpltext.Template, html *tmplhtml.Template) { text.Funcs(tmpltext.FuncMap(funcs)) html.Funcs(tmplhtml.FuncMap(funcs)) } } for _, tc := range []struct { options []Option title string in string data any html bool exp string fail bool }{ { title: "Test custom function", options: []Option{testOptionWithAdditionalFuncs(FuncMap{"printFoo": func() string { return "foo" }})}, in: `{{ printFoo }}`, exp: "foo", }, { title: "Test Default function with additional function added", options: []Option{testOptionWithAdditionalFuncs(FuncMap{"printFoo": func() string { return "foo" }})}, in: `{{ toUpper "test" }}`, exp: "TEST", }, { title: "Test custom function is overridden by the DefaultFuncs", options: []Option{testOptionWithAdditionalFuncs(FuncMap{"toUpper": func(s string) string { return "foo" }})}, in: `{{ toUpper "test" }}`, exp: "TEST", }, { title: "Test later Option overrides the previous", options: []Option{ testOptionWithAdditionalFuncs(FuncMap{"printFoo": func() string { return "foo" }}), testOptionWithAdditionalFuncs(FuncMap{"printFoo": func() string { return "bar" }}), }, in: `{{ printFoo }}`, exp: "bar", }, } { t.Run(tc.title, func(t *testing.T) { tmpl, err := FromGlobs([]string{}, tc.options...) require.NoError(t, err) f := tmpl.ExecuteTextString if tc.html { f = tmpl.ExecuteHTMLString } got, err := f(tc.in, tc.data) if tc.fail { require.Error(t, err) return } require.NoError(t, err) require.Equal(t, tc.exp, got) }) } } // This test asserts that template functions are thread-safe. func TestTemplateFuncs(t *testing.T) { tmpl, err := FromGlobs([]string{}) require.NoError(t, err) for _, tc := range []struct { title string in string data any exp string expErr string }{{ title: "Template using toUpper", in: `{{ "abc" | toUpper }}`, exp: "ABC", }, { title: "Template using toLower", in: `{{ "ABC" | toLower }}`, exp: "abc", }, { title: "Template using title", in: `{{ "abc" | title }}`, exp: "Abc", }, { title: "Template using trimSpace", in: `{{ " abc " | trimSpace }}`, exp: "abc", }, { title: "Template using join", in: `{{ . | join "," }}`, data: []string{"abc", "def"}, exp: "abc,def", }, { title: "Template using match", in: `{{ match "[a-z]+" "abc" }}`, exp: "true", }, { title: "Template using reReplaceAll", in: `{{ reReplaceAll "ab" "AB" "abc" }}`, exp: "ABc", }, { title: "Template using date", in: `{{ . | date "2006-01-02" }}`, data: time.Date(2024, 1, 1, 8, 15, 30, 0, time.UTC), exp: "2024-01-01", }, { title: "Template using tz", in: `{{ . | tz "Europe/Paris" }}`, data: time.Date(2024, 1, 1, 8, 15, 30, 0, time.UTC), exp: "2024-01-01 09:15:30 +0100 CET", }, { title: "Template using invalid tz", in: `{{ . | tz "Invalid/Timezone" }}`, data: time.Date(2024, 1, 1, 8, 15, 30, 0, time.UTC), expErr: "template: :1:7: executing \"\" at : error calling tz: unknown time zone Invalid/Timezone", }, { title: "Template using HumanizeDuration - seconds - float64", in: "{{ range . }}{{ humanizeDuration . }}:{{ end }}", data: []float64{0, 1, 60, 3600, 86400, 86400 + 3600, -(86400*2 + 3600*3 + 60*4 + 5), 899.99}, exp: "0s:1s:1m 0s:1h 0m 0s:1d 0h 0m 0s:1d 1h 0m 0s:-2d 3h 4m 5s:14m 59s:", }, { title: "Template using HumanizeDuration - seconds - string.", in: "{{ range . }}{{ humanizeDuration . }}:{{ end }}", data: []string{"0", "1", "60", "3600", "86400"}, exp: "0s:1s:1m 0s:1h 0m 0s:1d 0h 0m 0s:", }, { title: "Template using HumanizeDuration - subsecond and fractional seconds - float64.", in: "{{ range . }}{{ humanizeDuration . }}:{{ end }}", data: []float64{.1, .0001, .12345, 60.1, 60.5, 1.2345, 12.345}, exp: "100ms:100us:123.5ms:1m 0s:1m 0s:1.234s:12.35s:", }, { title: "Template using HumanizeDuration - subsecond and fractional seconds - string.", in: "{{ range . }}{{ humanizeDuration . }}:{{ end }}", data: []string{".1", ".0001", ".12345", "60.1", "60.5", "1.2345", "12.345"}, exp: "100ms:100us:123.5ms:1m 0s:1m 0s:1.234s:12.35s:", }, { title: "Template using HumanizeDuration - string with error.", in: `{{ humanizeDuration "one" }}`, expErr: "template: :1:3: executing \"\" at : error calling humanizeDuration: strconv.ParseFloat: parsing \"one\": invalid syntax", }, { title: "Template using HumanizeDuration - int.", in: "{{ range . }}{{ humanizeDuration . }}:{{ end }}", data: []int{0, -1, 1, 1234567}, exp: "0s:-1s:1s:14d 6h 56m 7s:", }, { title: "Template using HumanizeDuration - uint.", in: "{{ range . }}{{ humanizeDuration . }}:{{ end }}", data: []uint{0, 1, 1234567}, exp: "0s:1s:14d 6h 56m 7s:", }, { title: "Template using since", in: "{{ . | since | humanizeDuration }}", data: time.Now().Add(-1 * time.Hour), exp: "1h 0m 0s", }, { title: "Template using toJson with string", in: `{{ "hello" | toJson }}`, exp: `"hello"`, }, { title: "Template using toJson with map", in: `{{ . | toJson }}`, data: map[string]string{"key": "value"}, exp: `{"key":"value"}`, }, { title: "Template using toJson with Alerts.Firing()", in: `{{ .Alerts.Firing | toJson }}`, data: Data{ Alerts: Alerts{ {Status: "firing", Labels: KV{"alertname": "test"}}, {Status: "resolved"}, }, }, exp: `[{"status":"firing","labels":{"alertname":"test"},"annotations":null,"startsAt":"0001-01-01T00:00:00Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":"","fingerprint":""}]`, }} { t.Run(tc.title, func(t *testing.T) { wg := sync.WaitGroup{} for range 10 { wg.Go(func() { got, err := tmpl.ExecuteTextString(tc.in, tc.data) if tc.expErr == "" { require.NoError(t, err) require.Equal(t, tc.exp, got) } else { require.EqualError(t, err, tc.expErr) require.Empty(t, got) } }) } wg.Wait() }) } } func TestDeepCopyWithTemplate(t *testing.T) { identity := TemplateFunc(func(s string) (string, error) { return s, nil }) withSuffix := TemplateFunc(func(s string) (string, error) { return s + "-templated", nil }) for _, tc := range []struct { title string input any fn TemplateFunc want any wantErr string }{ { title: "string keeps templated value", input: "hello", fn: withSuffix, want: "hello-templated", }, { title: "string parsed as YAML map", input: "foo: bar", fn: identity, want: map[string]any{"foo": "bar"}, }, { title: "slice templating applied recursively", input: []any{"foo", 42}, fn: withSuffix, want: []any{"foo-templated", 42}, }, { title: "map converts keys and drops non-string", input: map[any]any{ "foo": "bar", 42: "ignore", "nested": []any{"baz"}, }, fn: withSuffix, want: map[string]any{ "foo-templated": "bar-templated", "nested-templated": []any{"baz-templated"}, }, }, { title: "non string value returned as-is", input: 123, fn: identity, want: 123, }, { title: "nil input", input: nil, fn: identity, want: nil, }, } { t.Run(tc.title, func(t *testing.T) { got, err := DeepCopyWithTemplate(tc.input, tc.fn) require.NoError(t, err) require.Equal(t, tc.want, got) }) } } func BenchmarkTemplateData(b *testing.B) { u, _ := url.Parse("http://example.com/") tmpl := &Template{ExternalURL: u} now := time.Now() alerts := make([]*types.Alert, 50) for i := range alerts { alerts[i] = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"alertname": "test", "job": "bench"}, Annotations: model.LabelSet{"summary": "test alert"}, StartsAt: now, EndsAt: now.Add(time.Hour), }, } } groupLabels := model.LabelSet{"alertname": "test"} b.ResetTimer() for b.Loop() { tmpl.Data("receiver", groupLabels, "firing", alerts...) } } func BenchmarkTypesAlerts(b *testing.B) { now := time.Now() alerts := make([]*types.Alert, 50) for i := range alerts { alerts[i] = &types.Alert{ Alert: model.Alert{ Labels: model.LabelSet{"alertname": "test", "job": "bench"}, Annotations: model.LabelSet{"summary": "test alert"}, StartsAt: now, EndsAt: now.Add(time.Hour), }, } } b.Run("SingleCall", func(b *testing.B) { for i := 0; i < b.N; i++ { typed := types.Alerts(alerts...) _ = typed.Status() for range typed { } } }) b.Run("DuplicateCall", func(b *testing.B) { for i := 0; i < b.N; i++ { _ = types.Alerts(alerts...).Status() for range types.Alerts(alerts...) { } } }) } ================================================ FILE: test/cli/acceptance/cli_test.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "encoding/json" "fmt" "os" "testing" "time" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/api/v2/models" . "github.com/prometheus/alertmanager/test/cli" ) func TestMain(m *testing.M) { if ok, err := AmtoolOk(); !ok { panic("unable to access amtool binary: " + err.Error()) } os.Exit(m.Run()) } // TestAmtoolVersion checks that amtool is executable and // is reporting valid version info. func TestAmtoolVersion(t *testing.T) { t.Parallel() version, err := Version() if err != nil { t.Fatal("Unable to get amtool version", err) } t.Logf("testing amtool version: %v", version) } func TestAddAlert(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) am := amc.Members()[0] alert1 := Alert("alertname", "test1").Active(1, 2) am.AddAlertsAt(false, 0, alert1) co.Want(Between(1, 2), Alert("alertname", "test1").Active(1)) at.Run() t.Log(co.Check()) } func TestQueryAlert(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] alert1 := Alert("alertname", "test1", "severity", "warning").Active(1) alert2 := Alert("alertname", "alertname=test2", "severity", "info").Active(1) alert3 := Alert("alertname", "{alertname=test3}", "severity", "info").Active(1) am.AddAlerts(true, alert1, alert2, alert3) alerts, err := am.QueryAlerts() require.NoError(t, err) require.Len(t, alerts, 3) // Get the first alert using the alertname heuristic alerts, err = am.QueryAlerts("test1") require.NoError(t, err) require.Len(t, alerts, 1) // QueryAlerts uses the simple output option, which means just the alertname // label is printed. We can assert that querying works as expected as we know // there are two alerts called "test1" and "test2". expectedLabels := models.LabelSet{"alertname": "test1"} require.True(t, alerts[0].HasLabels(expectedLabels)) // Get the second alert alerts, err = am.QueryAlerts("alertname=test2") require.NoError(t, err) require.Len(t, alerts, 1) expectedLabels = models.LabelSet{"alertname": "test2"} require.True(t, alerts[0].HasLabels(expectedLabels)) // Get the third alert alerts, err = am.QueryAlerts("{alertname=test3}") require.NoError(t, err) require.Len(t, alerts, 1) expectedLabels = models.LabelSet{"alertname": "{alertname=test3}"} require.True(t, alerts[0].HasLabels(expectedLabels)) } func TestQuerySilence(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] silence1 := Silence(0, 4).Match("test1", "severity=warn").Comment("test1") silence2 := Silence(0, 4).Match("alertname=test2", "severity=warn").Comment("test2") silence3 := Silence(0, 4).Match("{alertname=test3}", "severity=warn").Comment("test3") am.SetSilence(0, silence1) am.SetSilence(0, silence2) am.SetSilence(0, silence3) // Get all silences sils, err := am.QuerySilence() require.NoError(t, err) require.Len(t, sils, 3) expected1 := []string{"alertname=\"test1\"", "severity=\"warn\""} require.Equal(t, expected1, sils[0].GetMatches()) expected2 := []string{"alertname=\"test2\"", "severity=\"warn\""} require.Equal(t, expected2, sils[1].GetMatches()) expected3 := []string{"alertname=\"{alertname=test3}\"", "severity=\"warn\""} require.Equal(t, expected3, sils[2].GetMatches()) // Get the first silence using the alertname heuristic sils, err = am.QuerySilence("test1") require.NoError(t, err) require.Len(t, sils, 1) require.Equal(t, expected1, sils[0].GetMatches()) } func TestRoutesShow(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] _, err := am.ShowRoute() require.NoError(t, err) } func TestRoutesTest(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] _, err := am.TestRoute() require.NoError(t, err) // Bad labels should return error out, err := am.TestRoute("{foo=bar}") require.EqualError(t, err, "exit status 1") require.Equal(t, "amtool: error: Failed to parse labels: unexpected open or close brace: {foo=bar}\n\n", string(out)) } func TestSilenceImport(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] // Add some test silences silence1 := Silence(0, 4).Match("alertname=test1", "severity=warning").Comment("test silence 1") silence2 := Silence(0, 4).Match("alertname=test2", "severity=critical").Comment("test silence 2") am.SetSilence(0, silence1) am.SetSilence(0, silence2) // Export silences to JSON file tmpDir := t.TempDir() exportFile := tmpDir + "/silences.json" exportOut, err := am.ExportSilences() require.NoError(t, err) // Write to file err = os.WriteFile(exportFile, exportOut, 0o644) require.NoError(t, err) // Query current silences to get their IDs, then expire them sils, err := am.QuerySilence() require.NoError(t, err) require.Len(t, sils, 2) silIDs := make([]string, 0, len(sils)) // Expire all silences by ID for _, sil := range sils { id := sil.ID() _, err := am.ExpireSilenceByID(id) require.NoError(t, err) silIDs = append(silIDs, id) } // Verify silences show as expired sils, err = am.QueryExpiredSilence() require.NoError(t, err) // Silences should still be queryable but in expired state require.Len(t, sils, 2, "expired silences should still be queryable") // Check that the silences are actually expired (endsAt is in the past or equal to now) now := float64(time.Now().Unix()) for _, sil := range sils { require.Contains(t, silIDs, sil.ID(), "silence ID should be in the expired list") require.LessOrEqual(t, sil.EndsAt(), now, "silence %s should be expired", sil.ID()) } // Import silences back importOut, err := am.ImportSilences(exportFile) require.NoError(t, err, "import failed: %s", string(importOut)) // Verify silences were imported sils, err = am.QuerySilence() require.NoError(t, err) require.GreaterOrEqual(t, len(sils), 2, "expected at least 2 silences after import") } func TestSilenceImportInvalidJSON(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] // Create file with invalid JSON tmpDir := t.TempDir() invalidFile := tmpDir + "/invalid.json" err := os.WriteFile(invalidFile, []byte(`[{"broken": "json"`), 0o644) require.NoError(t, err) // Try to import - should fail out, err := am.ImportSilences(invalidFile) require.Error(t, err, "import should fail with invalid JSON") require.Contains(t, string(out), "couldn't unmarshal", "error message should mention JSON parsing") } func TestSilenceImportInvalidSilence(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] // Create file with valid JSON but invalid silence (zero timestamps) tmpDir := t.TempDir() invalidFile := tmpDir + "/invalid_silence.json" invalidSilence := `[ { "matchers": [ {"name": "alertname", "value": "test", "isRegex": false} ], "startsAt": "0001-01-01T00:00:00.000Z", "endsAt": "0001-01-01T00:00:00.000Z", "createdBy": "test", "comment": "invalid silence with zero timestamps" } ]` err := os.WriteFile(invalidFile, []byte(invalidSilence), 0o644) require.NoError(t, err) // Try to import - should fail with error from addSilenceWorker out, err := am.ImportSilences(invalidFile) require.Error(t, err, "import should fail with invalid silence") require.Contains(t, string(out), "couldn't import 1 out of 1 silences", "error message should report exact count") } func TestSilenceImportPartialFailure(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] // Create array of PostableSilence directly now := time.Now() future := now.Add(4 * time.Hour) silences := []models.PostableSilence{ // Valid silence 1 { Silence: models.Silence{ Matchers: models.Matchers{ &models.Matcher{Name: ptrString("alertname"), Value: ptrString("test1"), IsRegex: ptrBool(false)}, }, StartsAt: ptrTime(now), EndsAt: ptrTime(future), CreatedBy: ptrString("test"), Comment: ptrString("valid silence 1"), }, }, // Invalid silence 2 (endsAt before startsAt) { Silence: models.Silence{ Matchers: models.Matchers{ &models.Matcher{Name: ptrString("alertname"), Value: ptrString("test2"), IsRegex: ptrBool(false)}, }, StartsAt: ptrTime(future), // Swapped! EndsAt: ptrTime(now), // Swapped! CreatedBy: ptrString("test"), Comment: ptrString("invalid silence 2"), }, }, // Valid silence 3 { Silence: models.Silence{ Matchers: models.Matchers{ &models.Matcher{Name: ptrString("alertname"), Value: ptrString("test3"), IsRegex: ptrBool(false)}, }, StartsAt: ptrTime(now), EndsAt: ptrTime(future), CreatedBy: ptrString("test"), Comment: ptrString("valid silence 3"), }, }, // Invalid silence 4 (endsAt before startsAt) { Silence: models.Silence{ Matchers: models.Matchers{ &models.Matcher{Name: ptrString("alertname"), Value: ptrString("test4"), IsRegex: ptrBool(false)}, }, StartsAt: ptrTime(future), // Swapped! EndsAt: ptrTime(now), // Swapped! CreatedBy: ptrString("test"), Comment: ptrString("invalid silence 4"), }, }, // Valid silence 5 { Silence: models.Silence{ Matchers: models.Matchers{ &models.Matcher{Name: ptrString("alertname"), Value: ptrString("test5"), IsRegex: ptrBool(false)}, }, StartsAt: ptrTime(now), EndsAt: ptrTime(future), CreatedBy: ptrString("test"), Comment: ptrString("valid silence 5"), }, }, } // Serialize to JSON jsonData, err := json.Marshal(silences) require.NoError(t, err) // Write to file tmpDir := t.TempDir() mixedFile := tmpDir + "/mixed_silences.json" err = os.WriteFile(mixedFile, jsonData, 0o644) require.NoError(t, err) // Try to import - should partially succeed out, err := am.ImportSilences(mixedFile) require.Error(t, err, "import should fail with partial import") require.Contains(t, string(out), "couldn't import 2 out of 5 silences", "error message should report 2 failures out of 5") } func ptrString(s string) *string { return &s } func ptrBool(b bool) *bool { return &b } func ptrTime(t time.Time) *strfmt.DateTime { st := strfmt.DateTime(t) return &st } ================================================ FILE: test/cli/acceptance.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "errors" "fmt" "maps" "os" "os/exec" "regexp" "strings" "testing" "time" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/cli/format" "github.com/prometheus/alertmanager/test/testutils" ) const ( // nolint:godot // amtool is the relative path to local amtool binary. amtool = "../../../amtool" ) // Re-export common types from testutils. type ( Collector = testutils.Collector AcceptanceOpts = testutils.AcceptanceOpts ) var CompareCollectors = testutils.CompareCollectors // AcceptanceTest wraps testutils.AcceptanceTest for CLI-based testing. type AcceptanceTest struct { *testutils.AcceptanceTest } // NewAcceptanceTest returns a new acceptance test. func NewAcceptanceTest(t *testing.T, opts *AcceptanceOpts) *AcceptanceTest { return &AcceptanceTest{ AcceptanceTest: testutils.NewAcceptanceTest(t, opts), } } // AmtoolOk verifies that the "amtool" file exists in the correct location for testing, // and is a regular file. func AmtoolOk() (bool, error) { stat, err := os.Stat(amtool) if err != nil { return false, fmt.Errorf("error accessing amtool command, try 'make build' to generate the file. %w", err) } else if stat.IsDir() { return false, fmt.Errorf("file %s is a directory, expecting a binary executable file", amtool) } return true, nil } // Alertmanager wraps testutils.Alertmanager and adds CLI-specific methods. type Alertmanager struct { *testutils.Alertmanager } // AlertmanagerCluster wraps testutils.AlertmanagerCluster and adds CLI-specific methods. type AlertmanagerCluster struct { *testutils.AlertmanagerCluster } // AlertmanagerCluster returns a new AlertmanagerCluster. func (t *AcceptanceTest) AlertmanagerCluster(conf string, size int) *AlertmanagerCluster { return &AlertmanagerCluster{ AlertmanagerCluster: t.AcceptanceTest.AlertmanagerCluster(conf, size), } } // Members returns the underlying Alertmanager instances wrapped for CLI testing. func (amc *AlertmanagerCluster) Members() []*Alertmanager { baseMembers := amc.AlertmanagerCluster.Members() wrapped := make([]*Alertmanager, len(baseMembers)) for i, am := range baseMembers { wrapped[i] = &Alertmanager{Alertmanager: am} } return wrapped } // AddAlertsAt declares alerts that are to be added to the Alertmanager server // at a relative point in time. func (am *Alertmanager) AddAlertsAt(omitEquals bool, at float64, alerts ...*TestAlert) { am.T.Do(at, func() { am.AddAlerts(omitEquals, alerts...) }) } // AddAlerts declares alerts that are to be added to the Alertmanager server. // The omitEquals option omits alertname= from the command line args passed to // amtool and instead uses the alertname value as the first argument to the command. // For example `amtool alert add foo` instead of `amtool alert add alertname=foo`. // This has been added to allow certain tests to test adding alerts both with and // without alertname=. All other tests that use AddAlerts as a fixture can set this // to false. func (am *Alertmanager) AddAlerts(omitEquals bool, alerts ...*TestAlert) { for _, alert := range alerts { out, err := am.addAlertCommand(omitEquals, alert) if err != nil { am.T.Errorf("Error adding alert: %v\nOutput: %s", err, string(out)) } } } func (am *Alertmanager) addAlertCommand(omitEquals bool, alert *TestAlert) ([]byte, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := []string{amURLFlag, "alert", "add"} // Make a copy of the labels labels := make(models.LabelSet, len(alert.Labels)) maps.Copy(labels, alert.Labels) if omitEquals { // If alertname is present and omitEquals is true then the command should // be `amtool alert add foo ...` and not `amtool alert add alertname=foo ...`. if alertname, ok := labels["alertname"]; ok { args = append(args, alertname) delete(labels, "alertname") } } for k, v := range labels { args = append(args, k+"="+v) } startsAt := strfmt.DateTime(am.Opts.ExpandTime(alert.StartsAt)) args = append(args, "--start="+startsAt.String()) if alert.EndsAt > alert.StartsAt { endsAt := strfmt.DateTime(am.Opts.ExpandTime(alert.EndsAt)) args = append(args, "--end="+endsAt.String()) } cmd := exec.Command(amtool, args...) return cmd.CombinedOutput() } // QueryAlerts uses the amtool cli to query alerts. func (am *Alertmanager) QueryAlerts(match ...string) ([]TestAlert, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := append([]string{amURLFlag, "alert", "query"}, match...) cmd := exec.Command(amtool, args...) output, err := cmd.CombinedOutput() if err != nil { return nil, err } return parseAlertQueryResponse(output) } func parseAlertQueryResponse(data []byte) ([]TestAlert, error) { alerts := []TestAlert{} lines := strings.Split(string(data), "\n") header, lines := lines[0], lines[1:len(lines)-1] startTimePos := strings.Index(header, "Starts At") if startTimePos == -1 { return alerts, errors.New("Invalid header: " + header) } summPos := strings.Index(header, "Summary") if summPos == -1 { return alerts, errors.New("Invalid header: " + header) } for _, line := range lines { alertName := strings.TrimSpace(line[0:startTimePos]) startTime := strings.TrimSpace(line[startTimePos:summPos]) startsAt, err := time.Parse(format.DefaultDateFormat, startTime) if err != nil { return alerts, err } summary := strings.TrimSpace(line[summPos:]) alert := TestAlert{ Labels: models.LabelSet{"alertname": alertName}, StartsAt: float64(startsAt.Unix()), Summary: summary, } alerts = append(alerts, alert) } return alerts, nil } // SetSilence updates or creates the given Silence. func (amc *AlertmanagerCluster) SetSilence(at float64, sil *TestSilence) { for _, am := range amc.Members() { am.SetSilence(at, sil) } } // SetSilence updates or creates the given Silence. func (am *Alertmanager) SetSilence(at float64, sil *TestSilence) { out, err := am.addSilenceCommand(sil) if err != nil { am.T.Errorf("Unable to set silence %v %v", err, string(out)) } } // addSilenceCommand adds a silence using the 'amtool silence add' command. func (am *Alertmanager) addSilenceCommand(sil *TestSilence) ([]byte, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := []string{amURLFlag, "silence", "add"} if sil.comment != "" { args = append(args, "--comment="+sil.comment) } args = append(args, sil.match...) cmd := exec.Command(amtool, args...) return cmd.CombinedOutput() } // QuerySilence queries the current silences using the 'amtool silence query' command. func (am *Alertmanager) QuerySilence(match ...string) ([]TestSilence, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := append([]string{amURLFlag, "silence", "query"}, match...) cmd := exec.Command(amtool, args...) out, err := cmd.CombinedOutput() if err != nil { am.T.Error("Silence query command failed: ", err) } return parseSilenceQueryResponse(out) } // QueryExpiredSilence queries expired silences using the 'amtool silence query --expired --within' command. func (am *Alertmanager) QueryExpiredSilence(match ...string) ([]TestSilence, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := append([]string{amURLFlag, "silence", "query", "--expired", "--within=1h"}, match...) cmd := exec.Command(amtool, args...) out, err := cmd.CombinedOutput() if err != nil { am.T.Error("Silence query command failed: ", err) } return parseSilenceQueryResponse(out) } var silenceHeaderFields = []string{"ID", "Matchers", "Ends At", "Created By", "Comment"} func parseSilenceQueryResponse(data []byte) ([]TestSilence, error) { sils := []TestSilence{} lines := strings.Split(string(data), "\n") header, lines := lines[0], lines[1:len(lines)-1] matchersPos := strings.Index(header, silenceHeaderFields[1]) if matchersPos == -1 { return sils, errors.New("Invalid header: " + header) } endsAtPos := strings.Index(header, silenceHeaderFields[2]) if endsAtPos == -1 { return sils, errors.New("Invalid header: " + header) } createdByPos := strings.Index(header, silenceHeaderFields[3]) if createdByPos == -1 { return sils, errors.New("Invalid header: " + header) } commentPos := strings.Index(header, silenceHeaderFields[4]) if commentPos == -1 { return sils, errors.New("Invalid header: " + header) } for _, line := range lines { id := strings.TrimSpace(line[0:matchersPos]) matchers := strings.TrimSpace(line[matchersPos:endsAtPos]) endsAtString := strings.TrimSpace(line[endsAtPos:createdByPos]) endsAt, err := time.Parse(format.DefaultDateFormat, endsAtString) if err != nil { return sils, err } createdBy := strings.TrimSpace(line[createdByPos:commentPos]) comment := strings.TrimSpace(line[commentPos:]) silence := TestSilence{ id: id, endsAt: float64(endsAt.Unix()), match: strings.Split(matchers, " "), createdBy: createdBy, comment: comment, } sils = append(sils, silence) } return sils, nil } // DelSilence deletes the silence with the sid at the given time. func (amc *AlertmanagerCluster) DelSilence(at float64, sil *TestSilence) { for _, am := range amc.Members() { am.DelSilence(at, sil) } } // DelSilence deletes the silence with the sid at the given time. func (am *Alertmanager) DelSilence(at float64, sil *TestSilence) { output, err := am.expireSilenceCommand(sil) if err != nil { am.T.Errorf("Error expiring silence %v: %s", string(output), err) return } } // expireSilenceCommand expires a silence using the 'amtool silence expire' command. func (am *Alertmanager) expireSilenceCommand(sil *TestSilence) ([]byte, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := []string{amURLFlag, "silence", "expire", sil.ID()} cmd := exec.Command(amtool, args...) return cmd.CombinedOutput() } // ExportSilences exports all silences to JSON format using 'amtool silence query -o json'. func (am *Alertmanager) ExportSilences() ([]byte, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := []string{amURLFlag, "silence", "query", "-o", "json"} cmd := exec.Command(amtool, args...) return cmd.Output() } // ImportSilences imports silences from a JSON file using 'amtool silence import'. func (am *Alertmanager) ImportSilences(filename string) ([]byte, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := []string{amURLFlag, "silence", "import", filename} cmd := exec.Command(amtool, args...) return cmd.CombinedOutput() } // ExpireSilenceByID expires a silence by its ID using 'amtool silence expire'. func (am *Alertmanager) ExpireSilenceByID(id string) ([]byte, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := []string{amURLFlag, "silence", "expire", id} cmd := exec.Command(amtool, args...) return cmd.CombinedOutput() } // ShowRoute shows the routing tree using 'amtool config routes show'. func (am *Alertmanager) ShowRoute() ([]byte, error) { return am.showRouteCommand() } func (am *Alertmanager) showRouteCommand() ([]byte, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := []string{amURLFlag, "config", "routes", "show"} cmd := exec.Command(amtool, args...) return cmd.CombinedOutput() } // TestRoute tests label matching against the routing tree using 'amtool config routes test'. func (am *Alertmanager) TestRoute(labels ...string) ([]byte, error) { return am.testRouteCommand(labels...) } func (am *Alertmanager) testRouteCommand(labels ...string) ([]byte, error) { amURLFlag := "--alertmanager.url=" + am.getURL("/") args := append([]string{amURLFlag, "config", "routes", "test"}, labels...) cmd := exec.Command(amtool, args...) return cmd.CombinedOutput() } func (am *Alertmanager) getURL(path string) string { return fmt.Sprintf("http://%s%s%s", am.APIAddr(), am.Opts.RoutePrefix, path) } // Version runs the 'amtool' command with the --version option and checks // for appropriate output. func Version() (string, error) { cmd := exec.Command(amtool, "--version") out, err := cmd.CombinedOutput() if err != nil { return "", err } versionRE := regexp.MustCompile(`^amtool, version (\d+\.\d+\.\d+) *`) matched := versionRE.FindStringSubmatch(string(out)) if len(matched) != 2 { return "", errors.New("Unable to match version info regex: " + string(out)) } return matched[1], nil } ================================================ FILE: test/cli/mock.go ================================================ // Copyright 2019 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "github.com/prometheus/alertmanager/test/testutils" ) // Re-export common types and functions from testutils. type ( Interval = testutils.Interval TestAlert = testutils.TestAlert MockWebhook = testutils.MockWebhook ) var ( At = testutils.At Between = testutils.Between Alert = testutils.Alert NewWebhook = testutils.NewWebhook ) // TestSilence models a model.Silence with relative times. // This is the CLI-specific version with additional fields. type TestSilence struct { id string createdBy string match []string matchRE []string startsAt, endsAt float64 comment string } // Silence creates a new TestSilence active for the relative interval given // by start and end. func Silence(start, end float64) *TestSilence { return &TestSilence{ startsAt: start, endsAt: end, } } // Match adds a new plain matcher to the silence. func (s *TestSilence) Match(v ...string) *TestSilence { s.match = append(s.match, v...) return s } // GetMatches returns the plain matchers for the silence. func (s TestSilence) GetMatches() []string { return s.match } // MatchRE adds a new regex matcher to the silence. func (s *TestSilence) MatchRE(v ...string) *TestSilence { if len(v)%2 == 1 { panic("bad key/values") } s.matchRE = append(s.matchRE, v...) return s } // GetMatchREs returns the regex matchers for the silence. func (s *TestSilence) GetMatchREs() []string { return s.matchRE } // Comment sets the comment to the silence. func (s *TestSilence) Comment(c string) *TestSilence { s.comment = c return s } // SetID sets the silence ID. func (s *TestSilence) SetID(ID string) { s.id = ID } // ID gets the silence ID. func (s *TestSilence) ID() string { return s.id } // EndsAt gets the silence end time. func (s *TestSilence) EndsAt() float64 { return s.endsAt } ================================================ FILE: test/testutils/acceptance.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package testutils import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "syscall" "testing" "time" apiclient "github.com/prometheus/alertmanager/api/v2/client" "github.com/prometheus/alertmanager/api/v2/client/general" "github.com/prometheus/alertmanager/api/v2/models" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" ) // AcceptanceOpts defines configuration parameters for an acceptance test. type AcceptanceOpts struct { FeatureFlags []string RoutePrefix string Tolerance time.Duration baseTime time.Time } // AlertString formats an alert for display with relative times. func (opts *AcceptanceOpts) AlertString(a *models.GettableAlert) string { if a.EndsAt == nil || time.Time(*a.EndsAt).IsZero() { return fmt.Sprintf("%v[%v:]", a, opts.RelativeTime(time.Time(*a.StartsAt))) } return fmt.Sprintf("%v[%v:%v]", a, opts.RelativeTime(time.Time(*a.StartsAt)), opts.RelativeTime(time.Time(*a.EndsAt))) } // ExpandTime returns the absolute time for the relative time // calculated from the test's base time. func (opts *AcceptanceOpts) ExpandTime(rel float64) time.Time { return opts.baseTime.Add(time.Duration(rel * float64(time.Second))) } // RelativeTime returns the relative time for the given time // calculated from the test's base time. func (opts *AcceptanceOpts) RelativeTime(act time.Time) float64 { return float64(act.Sub(opts.baseTime)) / float64(time.Second) } // SetBaseTime sets the base time for relative time calculations. func (opts *AcceptanceOpts) SetBaseTime(t time.Time) { opts.baseTime = t } // AcceptanceTest provides declarative definition of given inputs and expected // output of an Alertmanager setup. type AcceptanceTest struct { *testing.T opts *AcceptanceOpts amc *AlertmanagerCluster collectors []*Collector actions map[float64][]func() } // NewAcceptanceTest returns a new acceptance test with the base time // set to the current time. func NewAcceptanceTest(t *testing.T, opts *AcceptanceOpts) *AcceptanceTest { test := &AcceptanceTest{ T: t, opts: opts, actions: map[float64][]func(){}, } return test } // Do sets the given function to be executed at the given time. func (t *AcceptanceTest) Do(at float64, f func()) { t.actions[at] = append(t.actions[at], f) } // AlertmanagerCluster returns a new AlertmanagerCluster that allows starting a // cluster of Alertmanager instances on random ports. func (t *AcceptanceTest) AlertmanagerCluster(conf string, size int) *AlertmanagerCluster { amc := AlertmanagerCluster{} for range size { am := &Alertmanager{ T: t, Opts: t.opts, } dir, err := os.MkdirTemp("", "am_test") if err != nil { t.Fatal(err) } am.dir = dir cf, err := os.Create(filepath.Join(dir, "config.yml")) if err != nil { t.Fatal(err) } am.confFile = cf am.UpdateConfig(conf) // apiAddr and clusterAddr will be discovered during Start() // clientV2 will be created during Start() with the discovered address amc.ams = append(amc.ams, am) } t.amc = &amc return &amc } // Collector returns a new collector bound to the test instance. func (t *AcceptanceTest) Collector(name string) *Collector { co := NewCollector(t.T, name, t.opts) t.collectors = append(t.collectors, co) return co } // Run starts all Alertmanagers and runs queries against them. It then checks // whether all expected notifications have arrived at the expected receiver. func (t *AcceptanceTest) Run(additionalArgs ...string) { errc := make(chan error) for _, am := range t.amc.ams { am.errc = errc t.Cleanup(am.Terminate) t.Cleanup(am.cleanup) } err := t.amc.Start(additionalArgs...) if err != nil { t.Log(err) t.Fail() return } // Set the reference time right before running the test actions to avoid // test failures due to slow setup of the test environment. t.opts.SetBaseTime(time.Now()) go t.runActions() var latest float64 for _, coll := range t.collectors { if l := coll.Latest(); l > latest { latest = l } } deadline := t.opts.ExpandTime(latest) select { case <-time.After(time.Until(deadline)): // continue case err := <-errc: t.Error(err) } } // runActions performs the stored actions at the defined times. func (t *AcceptanceTest) runActions() { var wg sync.WaitGroup for at, fs := range t.actions { ts := t.opts.ExpandTime(at) wg.Add(len(fs)) for _, f := range fs { go func(f func()) { time.Sleep(time.Until(ts)) f() wg.Done() }(f) } } wg.Wait() } type buffer struct { b bytes.Buffer mtx sync.Mutex } func (b *buffer) Write(p []byte) (int, error) { b.mtx.Lock() defer b.mtx.Unlock() return b.b.Write(p) } func (b *buffer) String() string { b.mtx.Lock() defer b.mtx.Unlock() return b.b.String() } // Alertmanager encapsulates an Alertmanager process and allows // declaring alerts being pushed to it at fixed points in time. type Alertmanager struct { T *AcceptanceTest Opts *AcceptanceOpts apiAddr string // the API address of this instance, discovered after start clusterAddr string // the cluster address can be the address of any peer clientV2 *apiclient.AlertmanagerAPI confFile *os.File dir string cmd *exec.Cmd errc chan<- error } // ClusterAddr returns an address for the cluster. func (am *Alertmanager) ClusterAddr() string { return am.clusterAddr } // APIAddr returns the API address for the instance. func (am *Alertmanager) APIAddr() string { return am.apiAddr } // AlertmanagerCluster represents a group of Alertmanager instances // acting as a cluster. type AlertmanagerCluster struct { ams []*Alertmanager } // Start the Alertmanager cluster and wait until it is ready to receive. func (amc *AlertmanagerCluster) Start(additionalArgs ...string) error { args := make([]string, 0, len(additionalArgs)+1) args = append(args, additionalArgs...) clusterAdded := false for i, am := range amc.ams { am.T.Logf("Starting cluster member %d/%d", i+1, len(amc.ams)) // Start this instance (it will discover its own ports) if err := am.Start(args); err != nil { return fmt.Errorf("starting cluster member %d: %w", i, err) } // From the second instance onwards, append the cluster.peer argument // so the subsequent ones join up. if !clusterAdded { args = append(args, "--cluster.peer="+am.ClusterAddr()) clusterAdded = true } } // Wait for cluster to converge for _, am := range amc.ams { if err := am.WaitForCluster(len(amc.ams)); err != nil { return fmt.Errorf("failed to wait for Alertmanager instance %q to join cluster: %w", am.APIAddr(), err) } } return nil } // Members returns the underlying slice of cluster members. func (amc *AlertmanagerCluster) Members() []*Alertmanager { return amc.ams } // discoverWebAddress parses stderr for "Listening on" log message and updates am.apiAddr. func (am *Alertmanager) discoverWebAddress(timeout time.Duration) error { am.T.Helper() deadline := time.Now().Add(timeout) stderrBuf, ok := am.cmd.Stderr.(*buffer) if !ok { return fmt.Errorf("stderr is not a buffer") } // Compile regex once outside the loop re := regexp.MustCompile(`address=([^\s]+)`) lastPos := 0 for time.Now().Before(deadline) { time.Sleep(10 * time.Millisecond) stderr := stderrBuf.String() // Only process new content since last check if len(stderr) <= lastPos { continue } newContent := stderr[lastPos:] lastPos = len(stderr) // Look for: msg="Listening on" address=127.0.0.1:PORT for line := range strings.SplitSeq(newContent, "\n") { if !strings.Contains(line, "Listening on") { continue } // Extract address using regex: address=IP:PORT matches := re.FindStringSubmatch(line) if len(matches) == 2 { am.apiAddr = matches[1] am.T.Logf("Discovered web address: %s", am.apiAddr) return nil } } } return fmt.Errorf("timeout waiting for web address in logs") } // discoverClusterAddress queries /api/v2/status for cluster address and updates am.clusterAddr. func (am *Alertmanager) discoverClusterAddress(timeout time.Duration) error { am.T.Helper() deadline := time.Now().Add(timeout) params := general.NewGetStatusParams() params.WithContext(context.Background()) for time.Now().Before(deadline) { time.Sleep(100 * time.Millisecond) status, err := am.clientV2.General.GetStatus(params) if err != nil || status.Payload == nil || status.Payload.Cluster == nil { continue } if len(status.Payload.Cluster.Peers) == 0 { continue } peer := status.Payload.Cluster.Peers[0] if peer != nil && peer.Address != nil { am.clusterAddr = *peer.Address am.T.Logf("Discovered cluster address: %s", am.clusterAddr) return nil } } return fmt.Errorf("timeout waiting for cluster address from API") } // Start the alertmanager and wait until it is ready to receive. func (am *Alertmanager) Start(additionalArg []string) error { am.T.Helper() args := []string{ "--config.file", am.confFile.Name(), "--log.level", "debug", "--web.listen-address", "127.0.0.1:0", "--storage.path", am.dir, "--cluster.listen-address", "127.0.0.1:0", "--cluster.settle-timeout", "0s", } if len(am.Opts.FeatureFlags) > 0 { args = append(args, "--enable-feature", strings.Join(am.Opts.FeatureFlags, ",")) } if am.Opts.RoutePrefix != "" { args = append(args, "--web.route-prefix", am.Opts.RoutePrefix) } args = append(args, additionalArg...) cmd := exec.Command("../../../alertmanager", args...) if am.cmd == nil { var outb, errb buffer cmd.Stdout = &outb cmd.Stderr = &errb } else { cmd.Stdout = am.cmd.Stdout cmd.Stderr = am.cmd.Stderr } am.cmd = cmd if err := am.cmd.Start(); err != nil { return err } go func() { if err := am.cmd.Wait(); err != nil { am.errc <- err } }() // Discover web address from logs if err := am.discoverWebAddress(5 * time.Second); err != nil { return fmt.Errorf("failed to discover web address: %w", err) } // Update API client with discovered address transport := httptransport.New(am.apiAddr, am.Opts.RoutePrefix+"/api/v2/", nil) am.clientV2 = apiclient.New(transport, strfmt.Default) // Discover cluster address from API (also serves as readiness check) if err := am.discoverClusterAddress(5 * time.Second); err != nil { return fmt.Errorf("failed to discover cluster address: %w", err) } am.T.Logf("Alertmanager started - web: %s, cluster: %s", am.apiAddr, am.clusterAddr) return nil } // WaitForCluster waits for the Alertmanager instance to join a cluster with the // given size. func (am *Alertmanager) WaitForCluster(size int) error { params := general.NewGetStatusParams() params.WithContext(context.Background()) var status *general.GetStatusOK // Poll for 2s for range 20 { var err error status, err = am.clientV2.General.GetStatus(params) if err != nil { return err } if len(status.Payload.Cluster.Peers) == size { return nil } time.Sleep(100 * time.Millisecond) } return fmt.Errorf( "expected %v peers, but got %v", size, len(status.Payload.Cluster.Peers), ) } // Terminate kills the underlying Alertmanager cluster processes and removes intermediate // data. func (amc *AlertmanagerCluster) Terminate() { for _, am := range amc.ams { am.Terminate() } } // Terminate kills the underlying Alertmanager process and remove intermediate // data. func (am *Alertmanager) Terminate() { am.T.Helper() if am.cmd != nil && am.cmd.Process != nil { if err := syscall.Kill(am.cmd.Process.Pid, syscall.SIGTERM); err != nil { am.T.Logf("Error sending SIGTERM to Alertmanager process: %v", err) } am.T.Logf("stdout:\n%v", am.cmd.Stdout) am.T.Logf("stderr:\n%v", am.cmd.Stderr) } } // Reload sends the reloading signal to the Alertmanager instances. func (amc *AlertmanagerCluster) Reload() { for _, am := range amc.ams { am.Reload() } } // Reload sends the reloading signal to the Alertmanager process. func (am *Alertmanager) Reload() { am.T.Helper() if am.cmd.Process != nil { if err := syscall.Kill(am.cmd.Process.Pid, syscall.SIGHUP); err != nil { am.T.Fatalf("Error sending SIGHUP to Alertmanager process: %v", err) } } } func (am *Alertmanager) cleanup() { am.T.Helper() if err := os.RemoveAll(am.confFile.Name()); err != nil { am.T.Errorf("Error removing test config file %q: %v", am.confFile.Name(), err) } } // UpdateConfig rewrites the configuration file for the Alertmanager cluster. It // does not initiate config reloading. func (amc *AlertmanagerCluster) UpdateConfig(conf string) { for _, am := range amc.ams { am.UpdateConfig(conf) } } // UpdateConfig rewrites the configuration file for the Alertmanager. It does not // initiate config reloading. func (am *Alertmanager) UpdateConfig(conf string) { if _, err := am.confFile.WriteString(conf); err != nil { am.T.Fatal(err) } if err := am.confFile.Sync(); err != nil { am.T.Fatal(err) } } // Client returns a client to interact with the API v2 endpoint. func (am *Alertmanager) Client() *apiclient.AlertmanagerAPI { if am.clientV2 == nil { panic("Client not available. Start() was not called or failed.") } return am.clientV2 } ================================================ FILE: test/testutils/collector.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package testutils import ( "encoding/json" "fmt" "strings" "sync" "testing" "time" "github.com/prometheus/alertmanager/api/v2/models" ) // Collector gathers alerts received by a notification receiver // and verifies whether all arrived and within the correct time boundaries. type Collector struct { t *testing.T name string opts *AcceptanceOpts collected map[float64][]models.GettableAlerts expected map[Interval][]models.GettableAlerts mtx sync.RWMutex } // NewCollector creates a new Collector with the given parameters. func NewCollector(t *testing.T, name string, opts *AcceptanceOpts) *Collector { return &Collector{ t: t, name: name, opts: opts, collected: map[float64][]models.GettableAlerts{}, expected: map[Interval][]models.GettableAlerts{}, } } func (c *Collector) String() string { return c.name } // Opts returns the acceptance options for this collector. func (c *Collector) Opts() *AcceptanceOpts { return c.opts } // Collected returns a map of alerts collected by the collector indexed with the // receive timestamp. func (c *Collector) Collected() map[float64][]models.GettableAlerts { c.mtx.RLock() defer c.mtx.RUnlock() return c.collected } func batchesEqual(as, bs models.GettableAlerts, opts *AcceptanceOpts) bool { if len(as) != len(bs) { return false } for _, a := range as { found := false for _, b := range bs { if EqualAlerts(a, b, opts) { found = true break } } if !found { return false } } return true } // Latest returns the latest relative point in time where a notification is // expected. func (c *Collector) Latest() float64 { c.mtx.RLock() defer c.mtx.RUnlock() var latest float64 for iv := range c.expected { if iv.end > latest { latest = iv.end } } return latest } // Want declares that the Collector expects to receive the given alerts // within the given time boundaries. func (c *Collector) Want(iv Interval, alerts ...*TestAlert) { c.mtx.Lock() defer c.mtx.Unlock() var nas models.GettableAlerts for _, a := range alerts { nas = append(nas, a.NativeAlert(c.opts)) } c.expected[iv] = append(c.expected[iv], nas) } // Add the given alerts to the collected alerts. // This is exported so it can be used by MockWebhook implementations. func (c *Collector) Add(alerts ...*models.GettableAlert) { c.mtx.Lock() defer c.mtx.Unlock() arrival := c.opts.RelativeTime(time.Now()) c.collected[arrival] = append(c.collected[arrival], models.GettableAlerts(alerts)) } func (c *Collector) Check() string { var report strings.Builder fmt.Fprintf(&report, "\ncollector %q:\n\n", c) c.mtx.RLock() defer c.mtx.RUnlock() for iv, expected := range c.expected { fmt.Fprintf(&report, "interval %v\n", iv) var alerts []models.GettableAlerts for at, got := range c.collected { if iv.contains(at) { alerts = append(alerts, got...) } } for _, exp := range expected { found := len(exp) == 0 && len(alerts) == 0 report.WriteString("---\n") for _, e := range exp { fmt.Fprintf(&report, "- %v\n", c.opts.AlertString(e)) } for _, a := range alerts { if batchesEqual(exp, a, c.opts) { found = true break } } if found { report.WriteString(" [ ✓ ]\n") } else { c.t.Fail() report.WriteString(" [ ✗ ]\n") } } } // Detect unexpected notifications. var totalExp, totalAct int for _, exp := range c.expected { for _, e := range exp { totalExp += len(e) } } for _, act := range c.collected { for _, a := range act { if len(a) == 0 { c.t.Error("received empty notifications") } totalAct += len(a) } } if totalExp != totalAct { c.t.Fail() fmt.Fprintf(&report, "\nExpected total of %d alerts, got %d", totalExp, totalAct) } if c.t.Failed() { report.WriteString("\nreceived:\n") for at, col := range c.collected { for _, alerts := range col { fmt.Fprintf(&report, "@ %v\n", at) for _, a := range alerts { fmt.Fprintf(&report, "- %v\n", c.opts.AlertString(a)) } } } } return report.String() } // alertsToString returns a string representation of the given Alerts. Use for // debugging. func alertsToString(as []*models.GettableAlert) (string, error) { b, err := json.Marshal(as) if err != nil { return "", err } return string(b), nil } // CompareCollectors compares two collectors based on their collected alerts. func CompareCollectors(a, b *Collector, opts *AcceptanceOpts) (bool, error) { f := func(collected map[float64][]models.GettableAlerts) []*models.GettableAlert { result := []*models.GettableAlert{} for _, batches := range collected { for _, batch := range batches { for _, alert := range batch { result = append(result, alert) } } } return result } aAlerts := f(a.Collected()) bAlerts := f(b.Collected()) if len(aAlerts) != len(bAlerts) { aAsString, err := alertsToString(aAlerts) if err != nil { return false, err } bAsString, err := alertsToString(bAlerts) if err != nil { return false, err } err = fmt.Errorf( "first collector has %v alerts, second collector has %v alerts\n%v\n%v", len(aAlerts), len(bAlerts), aAsString, bAsString, ) return false, err } for _, aAlert := range aAlerts { found := false for _, bAlert := range bAlerts { if EqualAlerts(aAlert, bAlert, opts) { found = true break } } if !found { aAsString, err := alertsToString([]*models.GettableAlert{aAlert}) if err != nil { return false, err } bAsString, err := alertsToString(bAlerts) if err != nil { return false, err } err = fmt.Errorf( "could not find matching alert for alert from first collector\n%v\nin alerts of second collector\n%v", aAsString, bAsString, ) return false, err } } return true, nil } ================================================ FILE: test/testutils/mock.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package testutils import ( "encoding/json" "fmt" "io" "maps" "net/http" "net/http/httptest" "reflect" "sync/atomic" "testing" "time" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/notify/webhook" ) // At is a convenience method to allow for declarative syntax of Acceptance // test definitions. func At(ts float64) float64 { return ts } type Interval struct { start, end float64 } func (iv Interval) String() string { return fmt.Sprintf("[%v,%v]", iv.start, iv.end) } func (iv Interval) contains(f float64) bool { return f >= iv.start && f <= iv.end } // Between is a convenience constructor for an interval for declarative syntax // of Acceptance test definitions. func Between(start, end float64) Interval { return Interval{start: start, end: end} } // TestAlert models a model.Alert with relative times. type TestAlert struct { Labels models.LabelSet Annotations models.LabelSet StartsAt, EndsAt float64 Summary string // CLI-specific field, unused in with_api_v2 } // Alert creates a new alert declaration with the given key/value pairs // as identifying labels. func Alert(keyval ...any) *TestAlert { if len(keyval)%2 == 1 { panic("bad key/values") } a := &TestAlert{ Labels: models.LabelSet{}, Annotations: models.LabelSet{}, } for i := 0; i < len(keyval); i += 2 { ln := keyval[i].(string) lv := keyval[i+1].(string) a.Labels[ln] = lv } return a } // NativeAlert converts the declared test alert into a full alert based // on the given parameters. func (a *TestAlert) NativeAlert(opts *AcceptanceOpts) *models.GettableAlert { na := &models.GettableAlert{ Alert: models.Alert{ Labels: a.Labels, }, Annotations: a.Annotations, StartsAt: &strfmt.DateTime{}, EndsAt: &strfmt.DateTime{}, } if a.StartsAt > 0 { start := strfmt.DateTime(opts.ExpandTime(a.StartsAt)) na.StartsAt = &start } if a.EndsAt > 0 { end := strfmt.DateTime(opts.ExpandTime(a.EndsAt)) na.EndsAt = &end } return na } // Annotate the alert with the given key/value pairs. func (a *TestAlert) Annotate(keyval ...any) *TestAlert { if len(keyval)%2 == 1 { panic("bad key/values") } for i := 0; i < len(keyval); i += 2 { ln := keyval[i].(string) lv := keyval[i+1].(string) a.Annotations[ln] = lv } return a } // Active declares the relative activity time for this alert. It // must be a single starting value or two values where the second value // declares the resolved time. func (a *TestAlert) Active(tss ...float64) *TestAlert { if len(tss) > 2 || len(tss) == 0 { panic("only one or two timestamps allowed") } if len(tss) == 2 { a.EndsAt = tss[1] } a.StartsAt = tss[0] return a } // HasLabels returns true if the two label sets are equivalent, otherwise false. // CLI-specific method, unused in with_api_v2. func (a *TestAlert) HasLabels(labels models.LabelSet) bool { return reflect.DeepEqual(a.Labels, labels) } // EqualAlerts compares two alerts for equality, considering the tolerance. func EqualAlerts(a, b *models.GettableAlert, opts *AcceptanceOpts) bool { if !reflect.DeepEqual(a.Labels, b.Labels) { return false } if !reflect.DeepEqual(a.Annotations, b.Annotations) { return false } if !EqualTime(time.Time(*a.StartsAt), time.Time(*b.StartsAt), opts) { return false } if (a.EndsAt == nil) != (b.EndsAt == nil) { return false } if (a.EndsAt != nil) && (b.EndsAt != nil) && !EqualTime(time.Time(*a.EndsAt), time.Time(*b.EndsAt), opts) { return false } return true } // EqualTime compares two times for equality within the tolerance. func EqualTime(a, b time.Time, opts *AcceptanceOpts) bool { if a.IsZero() != b.IsZero() { return false } diff := a.Sub(b) if diff < 0 { diff = -diff } return diff <= opts.Tolerance } // MockWebhook provides a mock HTTP webhook receiver for testing. type MockWebhook struct { opts *AcceptanceOpts collector *Collector addr string closing atomic.Bool // Func is called early on when retrieving a notification by an // Alertmanager. If Func returns true, the given notification is dropped. // See sample usage in `send_test.go/TestRetry()`. Func func(timestamp float64) bool } // NewWebhook creates a new MockWebhook that collects alerts via HTTP. func NewWebhook(t *testing.T, c *Collector) *MockWebhook { t.Helper() wh := &MockWebhook{ collector: c, opts: c.Opts(), } server := httptest.NewServer(wh) wh.addr = server.Listener.Addr().String() t.Cleanup(func() { wh.closing.Store(true) server.Close() }) return wh } // ServeHTTP handles incoming webhook requests. func (ws *MockWebhook) ServeHTTP(w http.ResponseWriter, req *http.Request) { // Inject drop function if it exists. if ws.Func != nil { if ws.Func(ws.opts.RelativeTime(time.Now())) { return } } dec := json.NewDecoder(req.Body) defer req.Body.Close() var v webhook.Message if err := dec.Decode(&v); err != nil { // During shutdown, ignore EOF errors from interrupted connections if ws.closing.Load() && (err == io.EOF || err.Error() == "EOF") { return } panic(err) } // Transform the webhook message alerts back into model.Alerts. var alerts models.GettableAlerts for _, a := range v.Alerts { var ( labels = models.LabelSet{} annotations = models.LabelSet{} ) maps.Copy(labels, a.Labels) maps.Copy(annotations, a.Annotations) start := strfmt.DateTime(a.StartsAt) end := strfmt.DateTime(a.EndsAt) alerts = append(alerts, &models.GettableAlert{ Alert: models.Alert{ Labels: labels, GeneratorURL: strfmt.URI(a.GeneratorURL), }, Annotations: annotations, StartsAt: &start, EndsAt: &end, }) } ws.collector.Add(alerts...) } // Address returns the address of the mock webhook server. func (ws *MockWebhook) Address() string { return ws.addr } ================================================ FILE: test/with_api_v2/acceptance/api_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "fmt" "testing" "time" "github.com/go-openapi/strfmt" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/api/v2/client/alert" "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/featurecontrol" a "github.com/prometheus/alertmanager/test/with_api_v2" ) func TestAddAlerts(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 10m repeat_interval: 1h receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := a.NewAcceptanceTest(t, &a.AcceptanceOpts{ FeatureFlags: []string{featurecontrol.FeatureClassicMode}, Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := a.NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] now := time.Now() pa := &models.PostableAlert{ StartsAt: strfmt.DateTime(now), EndsAt: strfmt.DateTime(now.Add(5 * time.Minute)), Alert: models.Alert{ Labels: models.LabelSet{ "a": "b", "b": "Σ", "c": "\xf0\x9f\x99\x82", "d": "eΘ", }, }, } alertParams := alert.NewPostAlertsParams() alertParams.Alerts = models.PostableAlerts{pa} _, err := am.Client().Alert.PostAlerts(alertParams) require.NoError(t, err) } // TestAlertGetReturnsCurrentStatus checks that querying the API returns the // current status of each alert, i.e. if it is silenced or inhibited. func TestAlertGetReturnsCurrentAlertStatus(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 10m repeat_interval: 1h inhibit_rules: - source_match: severity: 'critical' target_match: severity: 'warning' equal: ['alertname'] receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := a.NewAcceptanceTest(t, &a.AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := a.NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] labelName := "alertname" labelValue := "test1" now := time.Now() startsAt := strfmt.DateTime(now) endsAt := strfmt.DateTime(now.Add(5 * time.Minute)) labels := models.LabelSet(map[string]string{labelName: labelValue, "severity": "warning"}) fp := model.LabelSet{model.LabelName(labelName): model.LabelValue(labelValue), "severity": "warning"}.Fingerprint() pa := &models.PostableAlert{ StartsAt: startsAt, EndsAt: endsAt, Alert: models.Alert{Labels: labels}, } alertParams := alert.NewPostAlertsParams() alertParams.Alerts = models.PostableAlerts{pa} _, err := am.Client().Alert.PostAlerts(alertParams) require.NoError(t, err) resp, err := am.Client().Alert.GetAlerts(nil) require.NoError(t, err) // No silence has been created or inhibiting alert sent, alert should // be active. for _, al := range resp.Payload { require.Equal(t, models.AlertStatusStateActive, *al.Status.State) } // Wait for group_wait, so that we are in the group_interval period, // when the pipeline won't update the alert's status. time.Sleep(2 * time.Second) // Create silence and verify that the alert is immediately marked // silenced via the API. silenceParams := silence.NewPostSilencesParams() cm := "a" isRegex := false ps := &models.PostableSilence{ Silence: models.Silence{ StartsAt: &startsAt, EndsAt: &endsAt, Comment: &cm, CreatedBy: &cm, Matchers: models.Matchers{ &models.Matcher{Name: &labelName, Value: &labelValue, IsRegex: &isRegex}, }, }, } silenceParams.Silence = ps silenceResp, err := am.Client().Silence.PostSilences(silenceParams) require.NoError(t, err) silenceID := silenceResp.Payload.SilenceID resp, err = am.Client().Alert.GetAlerts(nil) require.NoError(t, err) for _, al := range resp.Payload { require.Equal(t, models.AlertStatusStateSuppressed, *al.Status.State) require.Equal(t, fp.String(), *al.Fingerprint) require.Len(t, al.Status.SilencedBy, 1) require.Equal(t, silenceID, al.Status.SilencedBy[0]) } // Create inhibiting alert and verify that original alert is // immediately marked as inhibited. labels["severity"] = "critical" _, err = am.Client().Alert.PostAlerts(alertParams) require.NoError(t, err) inhibitingFP := model.LabelSet{model.LabelName(labelName): model.LabelValue(labelValue), "severity": "critical"}.Fingerprint() resp, err = am.Client().Alert.GetAlerts(nil) require.NoError(t, err) for _, al := range resp.Payload { require.Len(t, al.Status.SilencedBy, 1) require.Equal(t, silenceID, al.Status.SilencedBy[0]) if fp.String() == *al.Fingerprint { require.Equal(t, models.AlertStatusStateSuppressed, *al.Status.State) require.Equal(t, fp.String(), *al.Fingerprint) require.Len(t, al.Status.InhibitedBy, 1) require.Equal(t, inhibitingFP.String(), al.Status.InhibitedBy[0]) } } deleteParams := silence.NewDeleteSilenceParams().WithSilenceID(strfmt.UUID(silenceID)) _, err = am.Client().Silence.DeleteSilence(deleteParams) require.NoError(t, err) resp, err = am.Client().Alert.GetAlerts(nil) require.NoError(t, err) // Silence has been deleted, inhibiting alert should be active. // Original alert should still be inhibited. for _, al := range resp.Payload { require.Empty(t, al.Status.SilencedBy) if inhibitingFP.String() == *al.Fingerprint { require.Equal(t, models.AlertStatusStateActive, *al.Status.State) } else { require.Equal(t, models.AlertStatusStateSuppressed, *al.Status.State) } } } func TestFilterAlertRequest(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 10m repeat_interval: 1h inhibit_rules: - source_match: severity: 'critical' target_match: severity: 'warning' equal: ['alertname'] receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := a.NewAcceptanceTest(t, &a.AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := a.NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] now := time.Now() startsAt := strfmt.DateTime(now) endsAt := strfmt.DateTime(now.Add(5 * time.Minute)) labels := models.LabelSet(map[string]string{"alertname": "test1", "severity": "warning"}) pa1 := &models.PostableAlert{ StartsAt: startsAt, EndsAt: endsAt, Alert: models.Alert{Labels: labels}, } labels = models.LabelSet(map[string]string{"system": "foo", "severity": "critical"}) pa2 := &models.PostableAlert{ StartsAt: startsAt, EndsAt: endsAt, Alert: models.Alert{Labels: labels}, } alertParams := alert.NewPostAlertsParams() alertParams.Alerts = models.PostableAlerts{pa1, pa2} _, err := am.Client().Alert.PostAlerts(alertParams) require.NoError(t, err) filter := []string{"alertname=test1", "severity=warning"} resp, err := am.Client().Alert.GetAlerts(alert.NewGetAlertsParams().WithFilter(filter)) require.NoError(t, err) require.Len(t, resp.Payload, 1) for _, al := range resp.Payload { require.Equal(t, models.AlertStatusStateActive, *al.Status.State) } } ================================================ FILE: test/with_api_v2/acceptance/cluster_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "fmt" "sync" "testing" "time" a "github.com/prometheus/alertmanager/test/with_api_v2" ) // TestClusterDeduplication tests, that in an Alertmanager cluster of 3 // instances, only one should send a notification for a given alert. func TestClusterDeduplication(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1h receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := a.NewAcceptanceTest(t, &a.AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := a.NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 3) amc.Push(a.At(1), a.Alert("alertname", "test1")) co.Want(a.Between(2, 3), a.Alert("alertname", "test1").Active(1)) at.Run() t.Log(co.Check()) } // TestClusterVSInstance compares notifications sent by Alertmanager cluster to // notifications sent by single instance. func TestClusterVSInstance(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [ "alertname" ] group_wait: 1s group_interval: 1s repeat_interval: 1h receivers: - name: "default" webhook_configs: - url: 'http://%s' ` acceptanceOpts := func() *a.AcceptanceOpts { return &a.AcceptanceOpts{ Tolerance: 2 * time.Second, } } clusterSizes := []int{1, 3} tests := []*a.AcceptanceTest{ a.NewAcceptanceTest(t, acceptanceOpts()), a.NewAcceptanceTest(t, acceptanceOpts()), } collectors := []*a.Collector{} amClusters := []*a.AlertmanagerCluster{} wg := sync.WaitGroup{} for i, tc := range tests { collectors = append(collectors, tc.Collector("webhook")) webhook := a.NewWebhook(t, collectors[i]) amClusters = append(amClusters, tc.AlertmanagerCluster(fmt.Sprintf(conf, webhook.Address()), clusterSizes[i])) wg.Add(1) } for _, alertTime := range []float64{0, 2, 4, 6, 8} { for i, amc := range amClusters { alert := a.Alert("alertname", fmt.Sprintf("test1-%v", alertTime)) amc.Push(a.At(alertTime), alert) collectors[i].Want(a.Between(alertTime, alertTime+5), alert.Active(alertTime)) } } for _, t := range tests { go func(t *a.AcceptanceTest) { t.Run() wg.Done() }(t) } wg.Wait() _, err := a.CompareCollectors(collectors[0], collectors[1], acceptanceOpts()) if err != nil { t.Fatal(err) } } ================================================ FILE: test/with_api_v2/acceptance/inhibit_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "fmt" "testing" "time" . "github.com/prometheus/alertmanager/test/with_api_v2" ) func TestInhibiting(t *testing.T) { t.Parallel() // This integration test checks that alerts can be inhibited and that an // inhibited alert will be notified again as soon as the inhibiting alert // gets resolved. conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1s receivers: - name: "default" webhook_configs: - url: 'http://%s' inhibit_rules: - source_match: alertname: JobDown target_match: alertname: InstanceDown equal: - job - zone ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) amc.Push(At(1), Alert("alertname", "test1", "job", "testjob", "zone", "aa")) amc.Push(At(1), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "aa")) amc.Push(At(1), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "ab")) // This JobDown in zone aa should inhibit InstanceDown in zone aa in the // second batch of notifications. amc.Push(At(2.2), Alert("alertname", "JobDown", "job", "testjob", "zone", "aa")) // InstanceDown in zone aa should fire again in the third batch of // notifications once JobDown in zone aa gets resolved. amc.Push(At(3.6), Alert("alertname", "JobDown", "job", "testjob", "zone", "aa").Active(2.2, 3.6)) co.Want(Between(2, 2.5), Alert("alertname", "test1", "job", "testjob", "zone", "aa").Active(1), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "aa").Active(1), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "ab").Active(1), ) co.Want(Between(3, 3.5), Alert("alertname", "test1", "job", "testjob", "zone", "aa").Active(1), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "ab").Active(1), Alert("alertname", "JobDown", "job", "testjob", "zone", "aa").Active(2.2), ) co.Want(Between(4, 4.5), Alert("alertname", "test1", "job", "testjob", "zone", "aa").Active(1), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "aa").Active(1), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "ab").Active(1), Alert("alertname", "JobDown", "job", "testjob", "zone", "aa").Active(2.2, 3.6), ) at.Run() t.Log(co.Check()) } func TestAlwaysInhibiting(t *testing.T) { t.Parallel() // This integration test checks that when inhibited and inhibiting alerts // gets resolved at the same time, the final notification contains both // alerts. conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1s receivers: - name: "default" webhook_configs: - url: 'http://%s' inhibit_rules: - source_match: alertname: JobDown target_match: alertname: InstanceDown equal: - job - zone ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) amc.Push(At(1), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "aa")) amc.Push(At(1), Alert("alertname", "JobDown", "job", "testjob", "zone", "aa")) amc.Push(At(2.6), Alert("alertname", "JobDown", "job", "testjob", "zone", "aa").Active(1, 2.6)) amc.Push(At(2.6), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "aa").Active(1, 2.6)) co.Want(Between(2, 2.5), Alert("alertname", "JobDown", "job", "testjob", "zone", "aa").Active(1), ) co.Want(Between(3, 3.5), Alert("alertname", "InstanceDown", "job", "testjob", "zone", "aa").Active(1, 2.6), Alert("alertname", "JobDown", "job", "testjob", "zone", "aa").Active(1, 2.6), ) at.Run() t.Log(co.Check()) } func TestEmptyInhibitionRule(t *testing.T) { t.Parallel() // This integration test checks that when we have empty inhibition rules, // there is no panic caused by null-pointer references. conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1s receivers: - name: "default" webhook_configs: - url: 'http://%s' inhibit_rules: - ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) at.Run() t.Log(co.Check()) } ================================================ FILE: test/with_api_v2/acceptance/send_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "fmt" "testing" "time" . "github.com/prometheus/alertmanager/test/with_api_v2" ) // This file contains acceptance tests around the basic sending logic // for notifications, which includes batching and ensuring that each // notification is eventually sent at least once and ideally exactly // once. func testMergeAlerts(t *testing.T, endsAt bool) { t.Parallel() timerange := func(ts float64) []float64 { if !endsAt { return []float64{ts} } return []float64{ts, ts + 3.0} } conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) am := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) // Refresh an alert several times. The starting time must remain at the earliest // point in time. am.Push(At(1), Alert("alertname", "test").Active(timerange(1.1)...)) // Another Prometheus server might be sending later but with an earlier start time. am.Push(At(1.2), Alert("alertname", "test").Active(1)) co.Want(Between(2, 2.5), Alert("alertname", "test").Active(1)) am.Push(At(2.1), Alert("alertname", "test").Annotate("ann", "v1").Active(timerange(2)...)) co.Want(Between(3, 3.5), Alert("alertname", "test").Annotate("ann", "v1").Active(1)) // Annotations are always overwritten by the alert that arrived most recently. am.Push(At(3.6), Alert("alertname", "test").Annotate("ann", "v2").Active(timerange(1.5)...)) co.Want(Between(4, 4.5), Alert("alertname", "test").Annotate("ann", "v2").Active(1)) // If an alert is marked resolved twice, the latest point in time must be // set as the eventual resolve time. am.Push(At(4.6), Alert("alertname", "test").Annotate("ann", "v2").Active(3, 4.5)) am.Push(At(4.8), Alert("alertname", "test").Annotate("ann", "v3").Active(2.9, 4.8)) am.Push(At(4.8), Alert("alertname", "test").Annotate("ann", "v3").Active(2.9, 4.1)) co.Want(Between(5, 5.5), Alert("alertname", "test").Annotate("ann", "v3").Active(1, 4.8)) // Reactivate an alert after a previous occurrence has been resolved. // No overlap, no merge must occur. am.Push(At(5.3), Alert("alertname", "test").Active(timerange(5)...)) co.Want(Between(6, 6.5), Alert("alertname", "test").Active(5)) at.Run() t.Log(co.Check()) } func TestMergeAlerts(t *testing.T) { testMergeAlerts(t, false) } // This test is similar to TestMergeAlerts except that the firing alerts have // the EndsAt field set to StartsAt + 3s. This is what Prometheus starting from // version 2.4.0 sends to AlertManager. func TestMergeAlertsWithEndsAt(t *testing.T) { testMergeAlerts(t, true) } func TestRepeat(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' ` // Create a new acceptance test that instantiates new Alertmanagers // with the given configuration and verifies times with the given // tolerance. at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) // Create a collector to which alerts can be written and verified // against a set of expected alert notifications. co := at.Collector("webhook") // Run something that satisfies the webhook interface to which the // Alertmanager pushes as defined by its configuration. wh := NewWebhook(t, co) // Create a new Alertmanager process listening to a random port am := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) // Declare pushes to be made to the Alertmanager at the given time. // Times are provided in fractions of seconds. am.Push(At(1), Alert("alertname", "test").Active(1)) // XXX(fabxc): disabled as long as alerts are not persisted. // at.Do(At(1.2), func() { // am.Terminate() // am.Start() // }) am.Push(At(3.5), Alert("alertname", "test").Active(1, 3)) // Declare which alerts are expected to arrive at the collector within // the defined time intervals. co.Want(Between(2, 2.5), Alert("alertname", "test").Active(1)) co.Want(Between(3, 3.5), Alert("alertname", "test").Active(1)) co.Want(Between(4, 4.5), Alert("alertname", "test").Active(1, 3)) // Start the flow as defined above and run the checks afterwards. at.Run() t.Log(co.Check()) } func TestRetry(t *testing.T) { t.Parallel() // We create a notification config that fans out into two different // webhooks. // The succeeding one must still only receive the first successful // notifications. Sending to the succeeding one must eventually succeed. conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 2s repeat_interval: 3s receivers: - name: "default" webhook_configs: - url: 'http://%s' - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co1 := at.Collector("webhook") wh1 := NewWebhook(t, co1) co2 := at.Collector("webhook_failing") wh2 := NewWebhook(t, co2) wh2.Func = func(ts float64) bool { // Fail the first interval period but eventually succeed in the third // interval after a few failed attempts. return ts < 4.5 } am := at.AlertmanagerCluster(fmt.Sprintf(conf, wh1.Address(), wh2.Address()), 1) am.Push(At(1), Alert("alertname", "test1")) co1.Want(Between(2, 2.5), Alert("alertname", "test1").Active(1)) co1.Want(Between(6, 6.5), Alert("alertname", "test1").Active(1)) co2.Want(Between(6, 6.5), Alert("alertname", "test1").Active(1)) at.Run() for _, c := range []*Collector{co1, co2} { t.Log(c.Check()) } } func TestBatching(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s # use a value slightly below the 5s interval to avoid timing issues repeat_interval: 4900ms receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) am := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) am.Push(At(1.1), Alert("alertname", "test1").Active(1)) am.Push(At(1.7), Alert("alertname", "test5").Active(1)) co.Want(Between(2.0, 2.5), Alert("alertname", "test1").Active(1), Alert("alertname", "test5").Active(1), ) am.Push(At(3.3), Alert("alertname", "test2").Active(1.5), Alert("alertname", "test3").Active(1.5), Alert("alertname", "test4").Active(1.6), ) co.Want(Between(4.1, 4.5), Alert("alertname", "test1").Active(1), Alert("alertname", "test5").Active(1), Alert("alertname", "test2").Active(1.5), Alert("alertname", "test3").Active(1.5), Alert("alertname", "test4").Active(1.6), ) // While no changes happen expect no additional notifications // until the 5s repeat interval has ended. co.Want(Between(9.1, 9.5), Alert("alertname", "test1").Active(1), Alert("alertname", "test5").Active(1), Alert("alertname", "test2").Active(1.5), Alert("alertname", "test3").Active(1.5), Alert("alertname", "test4").Active(1.6), ) at.Run() t.Log(co.Check()) } func TestResolved(t *testing.T) { t.Parallel() for range 2 { conf := ` global: resolve_timeout: 10s route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 5s receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) am := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) am.Push(At(1), Alert("alertname", "test", "lbl", "v1"), Alert("alertname", "test", "lbl", "v2"), Alert("alertname", "test", "lbl", "v3"), ) co.Want(Between(2, 2.5), Alert("alertname", "test", "lbl", "v1").Active(1), Alert("alertname", "test", "lbl", "v2").Active(1), Alert("alertname", "test", "lbl", "v3").Active(1), ) co.Want(Between(12, 13), Alert("alertname", "test", "lbl", "v1").Active(1, 11), Alert("alertname", "test", "lbl", "v2").Active(1, 11), Alert("alertname", "test", "lbl", "v3").Active(1, 11), ) at.Run() t.Log(co.Check()) } } func TestResolvedFilter(t *testing.T) { t.Parallel() // This integration test ensures that even though resolved alerts may not be // notified about, they must be set as notified. Resolved alerts, even when // filtered, have to end up in the SetNotifiesStage, otherwise when an alert // fires again it is ambiguous whether it was resolved in between or not. conf := ` global: resolve_timeout: 10s route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 5s receivers: - name: "default" webhook_configs: - url: 'http://%s' send_resolved: true - url: 'http://%s' send_resolved: false ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co1 := at.Collector("webhook1") wh1 := NewWebhook(t, co1) co2 := at.Collector("webhook2") wh2 := NewWebhook(t, co2) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh1.Address(), wh2.Address()), 1) amc.Push(At(1), Alert("alertname", "test", "lbl", "v1"), Alert("alertname", "test", "lbl", "v2"), ) amc.Push(At(3), Alert("alertname", "test", "lbl", "v1").Active(1, 4), Alert("alertname", "test", "lbl", "v3"), ) amc.Push(At(8), Alert("alertname", "test", "lbl", "v3").Active(3), ) co1.Want(Between(2, 2.5), Alert("alertname", "test", "lbl", "v1").Active(1), Alert("alertname", "test", "lbl", "v2").Active(1), ) co1.Want(Between(7, 7.5), Alert("alertname", "test", "lbl", "v1").Active(1, 4), Alert("alertname", "test", "lbl", "v2").Active(1), Alert("alertname", "test", "lbl", "v3").Active(3), ) // Notification should be sent because the v2 alert is resolved due to the time-out. co1.Want(Between(12, 12.5), Alert("alertname", "test", "lbl", "v2").Active(1, 11), Alert("alertname", "test", "lbl", "v3").Active(3), ) co2.Want(Between(2, 2.5), Alert("alertname", "test", "lbl", "v1").Active(1), Alert("alertname", "test", "lbl", "v2").Active(1), ) co2.Want(Between(7, 7.5), Alert("alertname", "test", "lbl", "v2").Active(1), Alert("alertname", "test", "lbl", "v3").Active(3), ) // No notification should be sent after group_interval because no new alert has been fired. co2.Want(Between(12, 12.5)) at.Run() for _, c := range []*Collector{co1, co2} { t.Log(c.Check()) } } func TestColdStart(t *testing.T) { t.Parallel() // This integration test ensures that the first alert isn't notified before // the AlertManager process has started considering the resend delay. conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 6s repeat_interval: 10m receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) amc.Push(At(1), Alert("alertname", "test1").Active(-100)) amc.Push(At(2), Alert("alertname", "test2")) // Alerts are dispatched 5 seconds after the AlertManager process has started. // start delay: 5s // first alert received at: 1s // first alert dispatched at: 5s - 1s = 4s co.Want(Between(4, 5), Alert("alertname", "test1").Active(1), Alert("alertname", "test2").Active(4), ) // Reload AlertManager process. at.Do(At(5), amc.Reload) amc.Push(At(6), Alert("alertname", "test3").Active(-100)) amc.Push(At(7), Alert("alertname", "test4")) // Group interval is applied on top of start delay. // start delay: 5s // group interval: 6s // alerts dispatched at: 5s + 6s = 11s co.Want(Between(11, 11.5), Alert("alertname", "test1").Active(1), Alert("alertname", "test2").Active(4), Alert("alertname", "test3").Active(6), Alert("alertname", "test4").Active(7), ) at.Run("--dispatch.start-delay", "5s") t.Log(co.Check()) } func TestReload(t *testing.T) { t.Parallel() // This integration test ensures that the first alert isn't notified twice // and repeat_interval applies after the AlertManager process has been // reloaded. conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 6s repeat_interval: 10m receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) amc.Push(At(1), Alert("alertname", "test1")) at.Do(At(3), amc.Reload) amc.Push(At(4), Alert("alertname", "test2")) co.Want(Between(2, 2.5), Alert("alertname", "test1").Active(1)) // Timers are reset on reload regardless, so we count the 6 second group // interval from 3 onwards. co.Want(Between(9, 9.5), Alert("alertname", "test1").Active(1), Alert("alertname", "test2").Active(4), ) at.Run() t.Log(co.Check()) } func TestWebhookTimeout(t *testing.T) { t.Parallel() // This integration test uses an extended group_interval to check that // the webhook level timeout has the desired effect, and that notification // sending is retried in this case. conf := ` route: receiver: "default" group_by: [alertname] group_wait: 1s group_interval: 1m repeat_interval: 1m receivers: - name: "default" webhook_configs: - url: 'http://%s' timeout: 500ms ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) wh.Func = func(ts float64) bool { // Make some webhook requests slow enough to hit the webhook // timeout, but not so slow as to hit the dispatcher timeout. if ts < 3 { time.Sleep(time.Second) return true } return false } am := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) am.Push(At(1), Alert("alertname", "test1")) co.Want(Between(3, 4), Alert("alertname", "test1").Active(1)) at.Run() t.Log(co.Check()) } ================================================ FILE: test/with_api_v2/acceptance/silence_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "fmt" "testing" "time" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/featurecontrol" . "github.com/prometheus/alertmanager/test/with_api_v2" ) func TestAddSilence(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ FeatureFlags: []string{featurecontrol.FeatureClassicMode}, Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] now := time.Now() ps := models.PostableSilence{ Silence: models.Silence{ Comment: stringPtr("test"), CreatedBy: stringPtr("test"), Matchers: models.Matchers{{ Name: stringPtr("foo"), IsEqual: boolPtr(true), IsRegex: boolPtr(false), Value: stringPtr("bar"), }}, StartsAt: dateTimePtr(strfmt.DateTime(now)), EndsAt: dateTimePtr(strfmt.DateTime(now.Add(24 * time.Hour))), }, } silenceParams := silence.NewPostSilencesParams() silenceParams.Silence = &ps _, err := am.Client().Silence.PostSilences(silenceParams) require.NoError(t, err) } func TestSilencing(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) // No repeat interval is configured. Thus, we receive an alert // notification every second. amc.Push(At(1), Alert("alertname", "test1").Active(1)) amc.Push(At(1), Alert("alertname", "test2").Active(1)) co.Want(Between(2, 2.5), Alert("alertname", "test1").Active(1), Alert("alertname", "test2").Active(1), ) // Add a silence that affects the first alert. amc.SetSilence(At(2.3), Silence(2.5, 4.5).Match("alertname", "test1")) co.Want(Between(3, 3.5), Alert("alertname", "test2").Active(1)) co.Want(Between(4, 4.5), Alert("alertname", "test2").Active(1)) // Silence should be over now and we receive both alerts again. co.Want(Between(5, 5.5), Alert("alertname", "test1").Active(1), Alert("alertname", "test2").Active(1), ) at.Run() t.Log(co.Check()) } func TestSilenceDelete(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) // No repeat interval is configured. Thus, we receive an alert // notification every second. amc.Push(At(1), Alert("alertname", "test1").Active(1)) amc.Push(At(1), Alert("alertname", "test2").Active(1)) // Silence everything for a long time and delete the silence after // two iterations. sil := Silence(1.5, 100).MatchRE("alertname", ".+") amc.SetSilence(At(1.3), sil) amc.DelSilence(At(3.5), sil) co.Want(Between(3.5, 4.5), Alert("alertname", "test1").Active(1), Alert("alertname", "test2").Active(1), ) at.Run() t.Log(co.Check()) } func boolPtr(b bool) *bool { return &b } func stringPtr(s string) *string { return &s } func dateTimePtr(t strfmt.DateTime) *strfmt.DateTime { return &t } ================================================ FILE: test/with_api_v2/acceptance/utf8_test.go ================================================ // Copyright 2023 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "fmt" "testing" "time" "github.com/go-openapi/strfmt" "github.com/stretchr/testify/require" "github.com/prometheus/alertmanager/api/v2/client/alert" "github.com/prometheus/alertmanager/api/v2/client/alertgroup" "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/featurecontrol" . "github.com/prometheus/alertmanager/test/with_api_v2" ) func TestAddUTF8Alerts(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 10m repeat_interval: 1h receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] // Add an alert with UTF-8 labels. now := time.Now() labels := models.LabelSet{ "a": "a", "00": "b", "Σ": "c", "\xf0\x9f\x99\x82": "dΘ", } pa := &models.PostableAlert{ StartsAt: strfmt.DateTime(now), EndsAt: strfmt.DateTime(now.Add(5 * time.Minute)), Alert: models.Alert{Labels: labels}, } postAlertParams := alert.NewPostAlertsParams() postAlertParams.Alerts = models.PostableAlerts{pa} _, err := am.Client().Alert.PostAlerts(postAlertParams) require.NoError(t, err) // Can get same alert from the API. resp, err := am.Client().Alert.GetAlerts(nil) require.NoError(t, err) require.Len(t, resp.Payload, 1) require.Equal(t, labels, resp.Payload[0].Labels) // Can filter alerts on UTF-8 labels. getAlertParams := alert.NewGetAlertsParams() getAlertParams = getAlertParams.WithFilter([]string{"00=b", "Σ=c", "\"\\xf0\\x9f\\x99\\x82\"=dΘ"}) resp, err = am.Client().Alert.GetAlerts(getAlertParams) require.NoError(t, err) require.Len(t, resp.Payload, 1) require.Equal(t, labels, resp.Payload[0].Labels) // Can get same alert in alert group from the API. alertGroupResp, err := am.Client().Alertgroup.GetAlertGroups(nil) require.NoError(t, err) require.Len(t, alertGroupResp.Payload, 1) require.Len(t, alertGroupResp.Payload[0].Alerts, 1) require.Equal(t, labels, alertGroupResp.Payload[0].Alerts[0].Labels) // Can filter alertGroups on UTF-8 labels. getAlertGroupsParams := alertgroup.NewGetAlertGroupsParams() getAlertGroupsParams.Filter = []string{"00=b", "Σ=c", "\"\\xf0\\x9f\\x99\\x82\"=dΘ"} alertGroupResp, err = am.Client().Alertgroup.GetAlertGroups(getAlertGroupsParams) require.NoError(t, err) require.Len(t, alertGroupResp.Payload, 1) require.Len(t, alertGroupResp.Payload[0].Alerts, 1) require.Equal(t, labels, alertGroupResp.Payload[0].Alerts[0].Labels) } func TestCannotAddUTF8AlertsInClassicMode(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 10m repeat_interval: 1h receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ FeatureFlags: []string{featurecontrol.FeatureClassicMode}, Tolerance: 1 * time.Second, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] // Cannot add an alert with UTF-8 labels. now := time.Now() pa := &models.PostableAlert{ StartsAt: strfmt.DateTime(now), EndsAt: strfmt.DateTime(now.Add(5 * time.Minute)), Alert: models.Alert{ Labels: models.LabelSet{ "a": "a", "00": "b", "Σ": "c", "\xf0\x9f\x99\x82": "dΘ", }, }, } alertParams := alert.NewPostAlertsParams() alertParams.Alerts = models.PostableAlerts{pa} _, err := am.Client().Alert.PostAlerts(alertParams) require.Error(t, err) require.Contains(t, err.Error(), "invalid label set") } func TestAddUTF8Silences(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] // Add a silence with UTF-8 label matchers. now := time.Now() matchers := models.Matchers{{ Name: stringPtr("fooΣ"), IsEqual: boolPtr(true), IsRegex: boolPtr(false), Value: stringPtr("bar🙂"), }} ps := models.PostableSilence{ Silence: models.Silence{ Comment: stringPtr("test"), CreatedBy: stringPtr("test"), Matchers: matchers, StartsAt: dateTimePtr(strfmt.DateTime(now)), EndsAt: dateTimePtr(strfmt.DateTime(now.Add(24 * time.Hour))), }, } postSilenceParams := silence.NewPostSilencesParams() postSilenceParams.Silence = &ps _, err := am.Client().Silence.PostSilences(postSilenceParams) require.NoError(t, err) // Can get the same silence from the API. resp, err := am.Client().Silence.GetSilences(nil) require.NoError(t, err) require.Len(t, resp.Payload, 1) require.Equal(t, matchers, resp.Payload[0].Matchers) // Can filter silences on UTF-8 label matchers. getSilenceParams := silence.NewGetSilencesParams() getSilenceParams = getSilenceParams.WithFilter([]string{"fooΣ=bar🙂"}) resp, err = am.Client().Silence.GetSilences(getSilenceParams) require.NoError(t, err) require.Len(t, resp.Payload, 1) require.Equal(t, matchers, resp.Payload[0].Matchers) } func TestCannotAddUTF8SilencesInClassicMode(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1ms receivers: - name: "default" webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ FeatureFlags: []string{featurecontrol.FeatureClassicMode}, Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) amc := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) require.NoError(t, amc.Start()) defer amc.Terminate() am := amc.Members()[0] // Cannot create a silence with UTF-8 matchers. now := time.Now() ps := models.PostableSilence{ Silence: models.Silence{ Comment: stringPtr("test"), CreatedBy: stringPtr("test"), Matchers: models.Matchers{{ Name: stringPtr("fooΣ"), IsEqual: boolPtr(true), IsRegex: boolPtr(false), Value: stringPtr("bar🙂"), }}, StartsAt: dateTimePtr(strfmt.DateTime(now)), EndsAt: dateTimePtr(strfmt.DateTime(now.Add(24 * time.Hour))), }, } silenceParams := silence.NewPostSilencesParams() silenceParams.Silence = &ps _, err := am.Client().Silence.PostSilences(silenceParams) require.Error(t, err) require.Contains(t, err.Error(), "invalid silence: invalid label matcher") } func TestSendAlertsToUTF8Route(t *testing.T) { t.Parallel() conf := ` route: receiver: default routes: - receiver: webhook matchers: - foo🙂=bar group_by: - foo🙂 group_wait: 1s receivers: - name: default - name: webhook webhook_configs: - url: 'http://%s' ` at := NewAcceptanceTest(t, &AcceptanceOpts{ Tolerance: 150 * time.Millisecond, }) co := at.Collector("webhook") wh := NewWebhook(t, co) am := at.AlertmanagerCluster(fmt.Sprintf(conf, wh.Address()), 1) am.Push(At(1), Alert("foo🙂", "bar").Active(1)) co.Want(Between(2, 2.5), Alert("foo🙂", "bar").Active(1)) at.Run() t.Log(co.Check()) } ================================================ FILE: test/with_api_v2/acceptance/web_test.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "testing" a "github.com/prometheus/alertmanager/test/with_api_v2" ) func TestWebWithPrefix(t *testing.T) { t.Parallel() conf := ` route: receiver: "default" group_by: [] group_wait: 1s group_interval: 1s repeat_interval: 1h receivers: - name: "default" ` // The test framework polls the API with the given prefix during // Alertmanager startup and thereby ensures proper configuration. at := a.NewAcceptanceTest(t, &a.AcceptanceOpts{RoutePrefix: "/foo"}) at.AlertmanagerCluster(conf, 1) at.Run() } ================================================ FILE: test/with_api_v2/acceptance.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "context" "testing" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/client/alert" "github.com/prometheus/alertmanager/api/v2/client/silence" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/test/testutils" ) // Re-export common types and functions from testutils. type ( Collector = testutils.Collector AcceptanceOpts = testutils.AcceptanceOpts ) var CompareCollectors = testutils.CompareCollectors // AcceptanceTest wraps testutils.AcceptanceTest for API-based testing. type AcceptanceTest struct { *testutils.AcceptanceTest } // NewAcceptanceTest returns a new acceptance test. func NewAcceptanceTest(t *testing.T, opts *AcceptanceOpts) *AcceptanceTest { return &AcceptanceTest{ AcceptanceTest: testutils.NewAcceptanceTest(t, opts), } } // Alertmanager wraps testutils.Alertmanager and adds API-specific methods. type Alertmanager struct { *testutils.Alertmanager } // AlertmanagerCluster wraps testutils.AlertmanagerCluster and adds API-specific methods. type AlertmanagerCluster struct { *testutils.AlertmanagerCluster } // AlertmanagerCluster returns a new AlertmanagerCluster. func (t *AcceptanceTest) AlertmanagerCluster(conf string, size int) *AlertmanagerCluster { return &AlertmanagerCluster{ AlertmanagerCluster: t.AcceptanceTest.AlertmanagerCluster(conf, size), } } // Members returns the underlying Alertmanager instances wrapped for API testing. func (amc *AlertmanagerCluster) Members() []*Alertmanager { baseMembers := amc.AlertmanagerCluster.Members() wrapped := make([]*Alertmanager, len(baseMembers)) for i, am := range baseMembers { wrapped[i] = &Alertmanager{Alertmanager: am} } return wrapped } // Push declares alerts that are to be pushed to the Alertmanager // servers at a relative point in time. func (amc *AlertmanagerCluster) Push(at float64, alerts ...*TestAlert) { for _, am := range amc.Members() { am.Push(at, alerts...) } } // Push declares alerts that are to be pushed to the Alertmanager // server at a relative point in time. func (am *Alertmanager) Push(at float64, alerts ...*TestAlert) { am.T.Do(at, func() { var cas models.PostableAlerts for i := range alerts { a := alerts[i].NativeAlert(am.Opts) alert := &models.PostableAlert{ Alert: models.Alert{ Labels: a.Labels, GeneratorURL: a.GeneratorURL, }, Annotations: a.Annotations, } if a.StartsAt != nil { alert.StartsAt = *a.StartsAt } if a.EndsAt != nil { alert.EndsAt = *a.EndsAt } cas = append(cas, alert) } params := alert.PostAlertsParams{} params.WithContext(context.Background()).WithAlerts(cas) _, err := am.Client().Alert.PostAlerts(¶ms) if err != nil { am.T.Errorf("Error pushing %v: %v", cas, err) } }) } // SetSilence updates or creates the given Silence. func (amc *AlertmanagerCluster) SetSilence(at float64, sil *TestSilence) { for _, am := range amc.Members() { am.SetSilence(at, sil) } } // SetSilence updates or creates the given Silence. func (am *Alertmanager) SetSilence(at float64, sil *TestSilence) { am.T.Do(at, func() { resp, err := am.Client().Silence.PostSilences( silence.NewPostSilencesParams().WithSilence( &models.PostableSilence{ Silence: *sil.nativeSilence(am.Opts), }, ), ) if err != nil { am.T.Errorf("Error setting silence %v: %s", sil, err) return } sil.SetID(resp.Payload.SilenceID) }) } // DelSilence deletes the silence with the sid at the given time. func (amc *AlertmanagerCluster) DelSilence(at float64, sil *TestSilence) { for _, am := range amc.Members() { am.DelSilence(at, sil) } } // DelSilence deletes the silence with the sid at the given time. func (am *Alertmanager) DelSilence(at float64, sil *TestSilence) { am.T.Do(at, func() { _, err := am.Client().Silence.DeleteSilence( silence.NewDeleteSilenceParams().WithSilenceID(strfmt.UUID(sil.ID())), ) if err != nil { am.T.Errorf("Error deleting silence %v: %s", sil, err) } }) } ================================================ FILE: test/with_api_v2/mock.go ================================================ // Copyright 2018 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package test import ( "sync" "github.com/go-openapi/strfmt" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/test/testutils" ) // Re-export common types and functions from testutils. type ( Interval = testutils.Interval TestAlert = testutils.TestAlert MockWebhook = testutils.MockWebhook ) var ( At = testutils.At Between = testutils.Between Alert = testutils.Alert NewWebhook = testutils.NewWebhook ) // TestSilence models a model.Silence with relative times. type TestSilence struct { id string match []string matchRE []string startsAt, endsAt float64 mtx sync.RWMutex } // Silence creates a new TestSilence active for the relative interval given // by start and end. func Silence(start, end float64) *TestSilence { return &TestSilence{ startsAt: start, endsAt: end, } } // Match adds a new plain matcher to the silence. func (s *TestSilence) Match(v ...string) *TestSilence { s.match = append(s.match, v...) return s } // MatchRE adds a new regex matcher to the silence. func (s *TestSilence) MatchRE(v ...string) *TestSilence { if len(v)%2 == 1 { panic("bad key/values") } s.matchRE = append(s.matchRE, v...) return s } // SetID sets the silence ID. func (s *TestSilence) SetID(ID string) { s.mtx.Lock() defer s.mtx.Unlock() s.id = ID } // ID gets the silence ID. func (s *TestSilence) ID() string { s.mtx.RLock() defer s.mtx.RUnlock() return s.id } // nativeSilence converts the declared test silence into a regular // silence with resolved times. func (s *TestSilence) nativeSilence(opts *AcceptanceOpts) *models.Silence { nsil := &models.Silence{} t := false for i := 0; i < len(s.match); i += 2 { nsil.Matchers = append(nsil.Matchers, &models.Matcher{ Name: &s.match[i], Value: &s.match[i+1], IsRegex: &t, }) } t = true for i := 0; i < len(s.matchRE); i += 2 { nsil.Matchers = append(nsil.Matchers, &models.Matcher{ Name: &s.matchRE[i], Value: &s.matchRE[i+1], IsRegex: &t, }) } if s.startsAt > 0 { start := strfmt.DateTime(opts.ExpandTime(s.startsAt)) nsil.StartsAt = &start } if s.endsAt > 0 { end := strfmt.DateTime(opts.ExpandTime(s.endsAt)) nsil.EndsAt = &end } comment := "some comment" createdBy := "admin@example.com" nsil.Comment = &comment nsil.CreatedBy = &createdBy return nsil } ================================================ FILE: timeinterval/timeinterval.go ================================================ // Copyright 2020 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package timeinterval import ( "encoding/json" "errors" "fmt" "os" "regexp" "runtime" "strconv" "strings" "time" "gopkg.in/yaml.v2" ) // Intervener determines whether a given time and active route time interval should mute outgoing notifications. // It implements the TimeMuter interface. type Intervener struct { intervals map[string][]TimeInterval } // Mutes implements the TimeMuter interface. func (i *Intervener) Mutes(names []string, now time.Time) (bool, []string, error) { var in []string for _, name := range names { interval, ok := i.intervals[name] if !ok { return false, nil, fmt.Errorf("time interval %s doesn't exist in config", name) } for _, ti := range interval { if ti.ContainsTime(now.UTC()) { in = append(in, name) } } } return len(in) > 0, in, nil } func NewIntervener(ti map[string][]TimeInterval) *Intervener { return &Intervener{ intervals: ti, } } // TimeInterval describes intervals of time. ContainsTime will tell you if a golang time is contained // within the interval. type TimeInterval struct { Times []TimeRange `yaml:"times,omitempty" json:"times,omitempty"` Weekdays []WeekdayRange `yaml:"weekdays,flow,omitempty" json:"weekdays,omitempty"` DaysOfMonth []DayOfMonthRange `yaml:"days_of_month,flow,omitempty" json:"days_of_month,omitempty"` Months []MonthRange `yaml:"months,flow,omitempty" json:"months,omitempty"` Years []YearRange `yaml:"years,flow,omitempty" json:"years,omitempty"` Location *Location `yaml:"location,flow,omitempty" json:"location,omitempty"` } // TimeRange represents a range of minutes within a 1440 minute day, exclusive of the End minute. A day consists of 1440 minutes. // For example, 4:00PM to End of the day would Begin at 1020 and End at 1440. type TimeRange struct { StartMinute int EndMinute int } // InclusiveRange is used to hold the Beginning and End values of many time interval components. type InclusiveRange struct { Begin int End int } // A WeekdayRange is an inclusive range between [0, 6] where 0 = Sunday. type WeekdayRange struct { InclusiveRange } // A DayOfMonthRange is an inclusive range that may have negative Beginning/End values that represent distance from the End of the month Beginning at -1. type DayOfMonthRange struct { InclusiveRange } // A MonthRange is an inclusive range between [1, 12] where 1 = January. type MonthRange struct { InclusiveRange } // A YearRange is a positive inclusive range. type YearRange struct { InclusiveRange } // A Location is a container for a time.Location, used for custom unmarshalling/validation logic. type Location struct { *time.Location } type yamlTimeRange struct { StartTime string `yaml:"start_time" json:"start_time"` EndTime string `yaml:"end_time" json:"end_time"` } // A range with a Beginning and End that can be represented as strings. type stringableRange interface { setBegin(int) setEnd(int) // Try to map a member of the range into an integer. memberFromString(string) (int, error) } func (ir *InclusiveRange) setBegin(n int) { ir.Begin = n } func (ir *InclusiveRange) setEnd(n int) { ir.End = n } func (ir *InclusiveRange) memberFromString(in string) (out int, err error) { out, err = strconv.Atoi(in) if err != nil { return -1, err } return out, nil } func (r *WeekdayRange) memberFromString(in string) (out int, err error) { out, ok := daysOfWeek[in] if !ok { return -1, fmt.Errorf("%s is not a valid weekday", in) } return out, nil } func (r *MonthRange) memberFromString(in string) (out int, err error) { out, ok := months[in] if !ok { out, err = strconv.Atoi(in) if err != nil { return -1, fmt.Errorf("%s is not a valid month", in) } } return out, nil } var daysOfWeek = map[string]int{ "sunday": 0, "monday": 1, "tuesday": 2, "wednesday": 3, "thursday": 4, "friday": 5, "saturday": 6, } var daysOfWeekInv = map[int]string{ 0: "sunday", 1: "monday", 2: "tuesday", 3: "wednesday", 4: "thursday", 5: "friday", 6: "saturday", } var months = map[string]int{ "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6, "july": 7, "august": 8, "september": 9, "october": 10, "november": 11, "december": 12, } var monthsInv = map[int]string{ 1: "january", 2: "february", 3: "march", 4: "april", 5: "may", 6: "june", 7: "july", 8: "august", 9: "september", 10: "october", 11: "november", 12: "december", } // UnmarshalYAML implements the Unmarshaller interface for Location. func (tz *Location) UnmarshalYAML(unmarshal func(any) error) error { var str string if err := unmarshal(&str); err != nil { return err } loc, err := time.LoadLocation(str) if err != nil { if runtime.GOOS == "windows" { if zoneinfo := os.Getenv("ZONEINFO"); zoneinfo != "" { return fmt.Errorf("%w (ZONEINFO=%q)", err, zoneinfo) } return fmt.Errorf("%w (on Windows platforms, you may have to pass the time zone database using the ZONEINFO environment variable, see https://pkg.go.dev/time#LoadLocation for details)", err) } return err } *tz = Location{loc} return nil } // UnmarshalJSON implements the json.Unmarshaler interface for Location. // It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. func (tz *Location) UnmarshalJSON(in []byte) error { return yaml.Unmarshal(in, tz) } // UnmarshalYAML implements the Unmarshaller interface for WeekdayRange. func (r *WeekdayRange) UnmarshalYAML(unmarshal func(any) error) error { var str string if err := unmarshal(&str); err != nil { return err } if err := stringableRangeFromString(str, r); err != nil { return err } if r.Begin > r.End { return errors.New("start day cannot be before end day") } if r.Begin < 0 || r.Begin > 6 { return fmt.Errorf("%s is not a valid day of the week: out of range", str) } if r.End < 0 || r.End > 6 { return fmt.Errorf("%s is not a valid day of the week: out of range", str) } return nil } // UnmarshalJSON implements the json.Unmarshaler interface for WeekdayRange. // It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. func (r *WeekdayRange) UnmarshalJSON(in []byte) error { return yaml.Unmarshal(in, r) } // UnmarshalYAML implements the Unmarshaller interface for DayOfMonthRange. func (r *DayOfMonthRange) UnmarshalYAML(unmarshal func(any) error) error { var str string if err := unmarshal(&str); err != nil { return err } if err := stringableRangeFromString(str, r); err != nil { return err } // Check beginning <= end accounting for negatives day of month indices as well. // Months != 31 days can't be addressed here and are clamped, but at least we can catch blatant errors. if r.Begin == 0 || r.Begin < -31 || r.Begin > 31 { return fmt.Errorf("%d is not a valid day of the month: out of range", r.Begin) } if r.End == 0 || r.End < -31 || r.End > 31 { return fmt.Errorf("%d is not a valid day of the month: out of range", r.End) } // Restricting here prevents errors where begin > end in longer months but not shorter months. if r.Begin < 0 && r.End > 0 { return fmt.Errorf("end day must be negative if start day is negative") } // Check begin <= end. We can't know this for sure when using negative indices // but we can prevent cases where its always invalid (using 28 day minimum length). checkBegin := r.Begin checkEnd := r.End if r.Begin < 0 { checkBegin = 28 + r.Begin } if r.End < 0 { checkEnd = 28 + r.End } if checkBegin > checkEnd { return fmt.Errorf("end day %d is always before start day %d", r.End, r.Begin) } return nil } // UnmarshalJSON implements the json.Unmarshaler interface for DayOfMonthRange. // It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. func (r *DayOfMonthRange) UnmarshalJSON(in []byte) error { return yaml.Unmarshal(in, r) } // UnmarshalYAML implements the Unmarshaller interface for MonthRange. func (r *MonthRange) UnmarshalYAML(unmarshal func(any) error) error { var str string if err := unmarshal(&str); err != nil { return err } if err := stringableRangeFromString(str, r); err != nil { return err } if r.Begin > r.End { begin := monthsInv[r.Begin] end := monthsInv[r.End] return fmt.Errorf("end month %s is before start month %s", end, begin) } return nil } // UnmarshalJSON implements the json.Unmarshaler interface for MonthRange. // It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. func (r *MonthRange) UnmarshalJSON(in []byte) error { return yaml.Unmarshal(in, r) } // UnmarshalYAML implements the Unmarshaller interface for YearRange. func (r *YearRange) UnmarshalYAML(unmarshal func(any) error) error { var str string if err := unmarshal(&str); err != nil { return err } if err := stringableRangeFromString(str, r); err != nil { return err } if r.Begin > r.End { return fmt.Errorf("end year %d is before start year %d", r.End, r.Begin) } return nil } // UnmarshalJSON implements the json.Unmarshaler interface for YearRange. // It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. func (r *YearRange) UnmarshalJSON(in []byte) error { return yaml.Unmarshal(in, r) } // UnmarshalYAML implements the Unmarshaller interface for TimeRanges. func (tr *TimeRange) UnmarshalYAML(unmarshal func(any) error) error { var y yamlTimeRange if err := unmarshal(&y); err != nil { return err } if y.EndTime == "" || y.StartTime == "" { return errors.New("both start and end times must be provided") } start, err := parseTime(y.StartTime) if err != nil { return err } end, err := parseTime(y.EndTime) if err != nil { return err } if start >= end { return errors.New("start time cannot be equal or greater than end time") } tr.StartMinute, tr.EndMinute = start, end return nil } // UnmarshalJSON implements the json.Unmarshaler interface for Timerange. // It delegates to the YAML unmarshaller as it can parse JSON and has validation logic. func (tr *TimeRange) UnmarshalJSON(in []byte) error { return yaml.Unmarshal(in, tr) } // MarshalYAML implements the yaml.Marshaler interface for WeekdayRange. func (r WeekdayRange) MarshalYAML() (any, error) { bytes, err := r.MarshalText() return string(bytes), err } // MarshalText implements the econding.TextMarshaler interface for WeekdayRange. // It converts the range into a colon-separated string, or a single weekday if possible. // E.g. "monday:friday" or "saturday". func (r WeekdayRange) MarshalText() ([]byte, error) { beginStr, ok := daysOfWeekInv[r.Begin] if !ok { return nil, fmt.Errorf("unable to convert %d into weekday string", r.Begin) } if r.Begin == r.End { return []byte(beginStr), nil } endStr, ok := daysOfWeekInv[r.End] if !ok { return nil, fmt.Errorf("unable to convert %d into weekday string", r.End) } rangeStr := fmt.Sprintf("%s:%s", beginStr, endStr) return []byte(rangeStr), nil } // MarshalYAML implements the yaml.Marshaler interface for TimeRange. func (tr TimeRange) MarshalYAML() (out any, err error) { startHr := tr.StartMinute / 60 endHr := tr.EndMinute / 60 startMin := tr.StartMinute % 60 endMin := tr.EndMinute % 60 startStr := fmt.Sprintf("%02d:%02d", startHr, startMin) endStr := fmt.Sprintf("%02d:%02d", endHr, endMin) yTr := yamlTimeRange{startStr, endStr} return any(yTr), err } // MarshalJSON implements the json.Marshaler interface for TimeRange. func (tr TimeRange) MarshalJSON() (out []byte, err error) { startHr := tr.StartMinute / 60 endHr := tr.EndMinute / 60 startMin := tr.StartMinute % 60 endMin := tr.EndMinute % 60 startStr := fmt.Sprintf("%02d:%02d", startHr, startMin) endStr := fmt.Sprintf("%02d:%02d", endHr, endMin) yTr := yamlTimeRange{startStr, endStr} return json.Marshal(yTr) } // MarshalText implements the econding.TextMarshaler interface for Location. // It marshals a Location back into a string that represents a time.Location. func (tz Location) MarshalText() ([]byte, error) { if tz.Location == nil { return nil, fmt.Errorf("unable to convert nil location into string") } return []byte(tz.String()), nil } // MarshalYAML implements the yaml.Marshaler interface for Location. func (tz Location) MarshalYAML() (any, error) { bytes, err := tz.MarshalText() return string(bytes), err } // MarshalJSON implements the json.Marshaler interface for Location. func (tz Location) MarshalJSON() (out []byte, err error) { return json.Marshal(tz.String()) } // MarshalText implements the encoding.TextMarshaler interface for InclusiveRange. // It converts the struct into a colon-separated string, or a single element if // appropriate. E.g. "monday:friday" or "monday". func (ir InclusiveRange) MarshalText() ([]byte, error) { if ir.Begin == ir.End { return []byte(strconv.Itoa(ir.Begin)), nil } out := fmt.Sprintf("%d:%d", ir.Begin, ir.End) return []byte(out), nil } // MarshalYAML implements the yaml.Marshaler interface for InclusiveRange. func (ir InclusiveRange) MarshalYAML() (any, error) { bytes, err := ir.MarshalText() return string(bytes), err } var ( validTime = "^((([01][0-9])|(2[0-3])):[0-5][0-9])$|(^24:00$)" validTimeRE = regexp.MustCompile(validTime) ) // Given a time, determines the number of days in the month that time occurs in. func daysInMonth(t time.Time) int { monthStart := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) monthEnd := monthStart.AddDate(0, 1, 0) diff := monthEnd.Sub(monthStart) return int(diff.Hours() / 24) } func clamp(n, min, max int) int { if n <= min { return min } if n >= max { return max } return n } // ContainsTime returns true if the TimeInterval contains the given time, otherwise returns false. func (tp TimeInterval) ContainsTime(t time.Time) bool { if tp.Location != nil { t = t.In(tp.Location.Location) } if tp.Times != nil { in := false for _, validMinutes := range tp.Times { if (t.Hour()*60+t.Minute()) >= validMinutes.StartMinute && (t.Hour()*60+t.Minute()) < validMinutes.EndMinute { in = true break } } if !in { return false } } if tp.DaysOfMonth != nil { in := false for _, validDates := range tp.DaysOfMonth { var begin, end int daysInMonth := daysInMonth(t) if validDates.Begin < 0 { begin = daysInMonth + validDates.Begin + 1 } else { begin = validDates.Begin } if validDates.End < 0 { end = daysInMonth + validDates.End + 1 } else { end = validDates.End } // Skip clamping if the beginning date is after the end of the month. if begin > daysInMonth { continue } // Clamp to the boundaries of the month to prevent crossing into other months. begin = clamp(begin, -1*daysInMonth, daysInMonth) end = clamp(end, -1*daysInMonth, daysInMonth) if t.Day() >= begin && t.Day() <= end { in = true break } } if !in { return false } } if tp.Months != nil { in := false for _, validMonths := range tp.Months { if t.Month() >= time.Month(validMonths.Begin) && t.Month() <= time.Month(validMonths.End) { in = true break } } if !in { return false } } if tp.Weekdays != nil { in := false for _, validDays := range tp.Weekdays { if t.Weekday() >= time.Weekday(validDays.Begin) && t.Weekday() <= time.Weekday(validDays.End) { in = true break } } if !in { return false } } if tp.Years != nil { in := false for _, validYears := range tp.Years { if t.Year() >= validYears.Begin && t.Year() <= validYears.End { in = true break } } if !in { return false } } return true } // Converts a string of the form "HH:MM" into the number of minutes elapsed in the day. func parseTime(in string) (mins int, err error) { if !validTimeRE.MatchString(in) { return 0, fmt.Errorf("couldn't parse timestamp %s, invalid format", in) } timestampComponents := strings.Split(in, ":") if len(timestampComponents) != 2 { return 0, fmt.Errorf("invalid timestamp format: %s", in) } timeStampHours, err := strconv.Atoi(timestampComponents[0]) if err != nil { return 0, err } timeStampMinutes, err := strconv.Atoi(timestampComponents[1]) if err != nil { return 0, err } if timeStampHours < 0 || timeStampHours > 24 || timeStampMinutes < 0 || timeStampMinutes > 60 { return 0, fmt.Errorf("timestamp %s out of range", in) } // Timestamps are stored as minutes elapsed in the day, so multiply hours by 60. mins = timeStampHours*60 + timeStampMinutes return mins, nil } // Converts a range that can be represented as strings (e.g. monday:wednesday) into an equivalent integer-represented range. func stringableRangeFromString(in string, r stringableRange) (err error) { in = strings.ToLower(in) if strings.ContainsRune(in, ':') { components := strings.Split(in, ":") if len(components) != 2 { return fmt.Errorf("couldn't parse range %s, invalid format", in) } start, err := r.memberFromString(components[0]) if err != nil { return err } End, err := r.memberFromString(components[1]) if err != nil { return err } r.setBegin(start) r.setEnd(End) return nil } val, err := r.memberFromString(in) if err != nil { return err } r.setBegin(val) r.setEnd(val) return nil } ================================================ FILE: timeinterval/timeinterval_test.go ================================================ // Copyright 2020 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package timeinterval import ( "encoding/json" "reflect" "sort" "testing" "time" "github.com/stretchr/testify/require" "gopkg.in/yaml.v2" ) var timeIntervalTestCases = []struct { validTimeStrings []string invalidTimeStrings []string timeInterval TimeInterval }{ { timeInterval: TimeInterval{}, validTimeStrings: []string{ "02 Jan 06 15:04 +0000", "03 Jan 07 10:04 +0000", "04 Jan 06 09:04 +0000", }, invalidTimeStrings: []string{}, }, { // 9am to 5pm, monday to friday timeInterval: TimeInterval{ Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, }, validTimeStrings: []string{ "04 May 20 15:04 +0000", "05 May 20 10:04 +0000", "09 Jun 20 09:04 +0000", }, invalidTimeStrings: []string{ "03 May 20 15:04 +0000", "04 May 20 08:59 +0000", "05 May 20 05:00 +0000", }, }, { // Easter 2020 timeInterval: TimeInterval{ DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: 4, End: 6}}}, Months: []MonthRange{{InclusiveRange{Begin: 4, End: 4}}}, Years: []YearRange{{InclusiveRange{Begin: 2020, End: 2020}}}, }, validTimeStrings: []string{ "04 Apr 20 15:04 +0000", "05 Apr 20 00:00 +0000", "06 Apr 20 23:05 +0000", }, invalidTimeStrings: []string{ "03 May 18 15:04 +0000", "03 Apr 20 23:59 +0000", "04 Jun 20 23:59 +0000", "06 Apr 19 23:59 +0000", "07 Apr 20 00:00 +0000", }, }, { // Check negative days of month, last 3 days of each month timeInterval: TimeInterval{ DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: -3, End: -1}}}, }, validTimeStrings: []string{ "31 Jan 20 15:04 +0000", "30 Jan 20 15:04 +0000", "29 Jan 20 15:04 +0000", "30 Jun 20 00:00 +0000", "29 Feb 20 23:05 +0000", }, invalidTimeStrings: []string{ "03 May 18 15:04 +0000", "27 Jan 20 15:04 +0000", "03 Apr 20 23:59 +0000", "04 Jun 20 23:59 +0000", "06 Apr 19 23:59 +0000", "07 Apr 20 00:00 +0000", "01 Mar 20 00:00 +0000", }, }, { // Check out of bound days are clamped to month boundaries timeInterval: TimeInterval{ Months: []MonthRange{{InclusiveRange{Begin: 6, End: 6}}}, DaysOfMonth: []DayOfMonthRange{{InclusiveRange{Begin: -31, End: 31}}}, }, validTimeStrings: []string{ "30 Jun 20 00:00 +0000", "01 Jun 20 00:00 +0000", }, invalidTimeStrings: []string{ "31 May 20 00:00 +0000", "1 Jul 20 00:00 +0000", }, }, { // Check alternative timezones can be used to compare times. // AEST 9AM to 5PM, Monday to Friday. timeInterval: TimeInterval{ Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, Location: &Location{mustLoadLocation("Australia/Sydney")}, }, validTimeStrings: []string{ "06 Apr 21 13:00 +1000", }, invalidTimeStrings: []string{ "06 Apr 21 13:00 +0000", }, }, { // Check an alternative timezone during daylight savings time. timeInterval: TimeInterval{ Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, Months: []MonthRange{{InclusiveRange{Begin: 11, End: 11}}}, Location: &Location{mustLoadLocation("Australia/Sydney")}, }, validTimeStrings: []string{ "01 Nov 21 09:00 +1100", "31 Oct 21 22:00 +0000", }, invalidTimeStrings: []string{ "31 Oct 21 21:00 +0000", }, }, } var timeStringTestCases = []struct { timeString string TimeRange TimeRange expectError bool }{ { timeString: "{'start_time': '00:00', 'end_time': '24:00'}", TimeRange: TimeRange{StartMinute: 0, EndMinute: 1440}, expectError: false, }, { timeString: "{'start_time': '01:35', 'end_time': '17:39'}", TimeRange: TimeRange{StartMinute: 95, EndMinute: 1059}, expectError: false, }, { timeString: "{'start_time': '09:35', 'end_time': '09:39'}", TimeRange: TimeRange{StartMinute: 575, EndMinute: 579}, expectError: false, }, { // Error: Begin and End times are the same timeString: "{'start_time': '17:31', 'end_time': '17:31'}", TimeRange: TimeRange{}, expectError: true, }, { // Error: End time out of range timeString: "{'start_time': '12:30', 'end_time': '24:01'}", TimeRange: TimeRange{}, expectError: true, }, { // Error: Start time greater than End time timeString: "{'start_time': '09:30', 'end_time': '07:41'}", TimeRange: TimeRange{}, expectError: true, }, { // Error: Start time out of range and greater than End time timeString: "{'start_time': '24:00', 'end_time': '17:41'}", TimeRange: TimeRange{}, expectError: true, }, { // Error: No range specified timeString: "{'start_time': '14:03'}", TimeRange: TimeRange{}, expectError: true, }, } var yamlUnmarshalTestCases = []struct { in string intervals []TimeInterval contains []string excludes []string expectError bool err string }{ { // Simple business hours test in: ` --- - weekdays: ['monday:friday'] times: - start_time: '09:00' end_time: '17:00' `, intervals: []TimeInterval{ { Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, }, }, contains: []string{ "08 Jul 20 09:00 +0000", "08 Jul 20 16:59 +0000", }, excludes: []string{ "08 Jul 20 05:00 +0000", "08 Jul 20 08:59 +0000", }, expectError: false, }, { // More advanced test with negative indices and ranges in: ` --- # Last week, excluding Saturday, of the first quarter of the year during business hours from 2020 to 2025 and 2030-2035 - weekdays: ['monday:friday', 'sunday'] months: ['january:march'] days_of_month: ['-7:-1'] years: ['2020:2025', '2030:2035'] times: - start_time: '09:00' end_time: '17:00' `, intervals: []TimeInterval{ { Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}, {InclusiveRange{Begin: 0, End: 0}}}, Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, Months: []MonthRange{{InclusiveRange{1, 3}}}, DaysOfMonth: []DayOfMonthRange{{InclusiveRange{-7, -1}}}, Years: []YearRange{{InclusiveRange{2020, 2025}}, {InclusiveRange{2030, 2035}}}, }, }, contains: []string{ "27 Jan 21 09:00 +0000", "28 Jan 21 16:59 +0000", "29 Jan 21 13:00 +0000", "31 Mar 25 13:00 +0000", "31 Mar 25 13:00 +0000", "31 Jan 35 13:00 +0000", }, excludes: []string{ "30 Jan 21 13:00 +0000", // Saturday "01 Apr 21 13:00 +0000", // 4th month "30 Jan 26 13:00 +0000", // 2026 "31 Jan 35 17:01 +0000", // After 5pm }, expectError: false, }, { in: ` --- - weekdays: ['monday:friday'] times: - start_time: '09:00' end_time: '17:00'`, intervals: []TimeInterval{ { Weekdays: []WeekdayRange{{InclusiveRange{Begin: 1, End: 5}}}, Times: []TimeRange{{StartMinute: 540, EndMinute: 1020}}, }, }, contains: []string{ "01 Apr 21 13:00 +0000", }, }, { // Invalid start time. in: ` --- - times: - start_time: '01:99' end_time: '23:59'`, expectError: true, err: "couldn't parse timestamp 01:99, invalid format", }, { // Invalid end time. in: ` --- - times: - start_time: '00:00' end_time: '99:99'`, expectError: true, err: "couldn't parse timestamp 99:99, invalid format", }, { // Start day before end day. in: ` --- - weekdays: ['friday:monday']`, expectError: true, err: "start day cannot be before end day", }, { // Invalid weekdays. in: ` --- - weekdays: ['blurgsday:flurgsday'] `, expectError: true, err: "blurgsday is not a valid weekday", }, { // Numeric weekdays aren't allowed. in: ` --- - weekdays: ['1:3'] `, expectError: true, err: "1 is not a valid weekday", }, { // Negative numeric weekdays aren't allowed. in: ` --- - weekdays: ['-2:-1'] `, expectError: true, err: "-2 is not a valid weekday", }, { // 0 day of month. in: ` --- - days_of_month: ['0'] `, expectError: true, err: "0 is not a valid day of the month: out of range", }, { // Start day of month < 0. in: ` --- - days_of_month: ['-50:-20'] `, expectError: true, err: "-50 is not a valid day of the month: out of range", }, { // End day of month > 31. in: ` --- - days_of_month: ['1:50'] `, expectError: true, err: "50 is not a valid day of the month: out of range", }, { // Negative indices should work. in: ` --- - days_of_month: ['1:-1'] `, intervals: []TimeInterval{ { DaysOfMonth: []DayOfMonthRange{{InclusiveRange{1, -1}}}, }, }, expectError: false, }, { // End day must be negative if begin day is negative. in: ` --- - days_of_month: ['-15:5'] `, expectError: true, err: "end day must be negative if start day is negative", }, { // Negative end date before positive positive start date. in: ` --- - days_of_month: ['10:-25'] `, expectError: true, err: "end day -25 is always before start day 10", }, { // Months should work regardless of case in: ` --- - months: ['January:december'] `, expectError: false, intervals: []TimeInterval{ { Months: []MonthRange{{InclusiveRange{1, 12}}}, }, }, }, { // Time zones may be specified by location. in: ` --- - years: ['2020:2022'] location: 'Australia/Sydney' `, expectError: false, intervals: []TimeInterval{ { Years: []YearRange{{InclusiveRange{2020, 2022}}}, Location: &Location{mustLoadLocation("Australia/Sydney")}, }, }, }, { // Invalid start month. in: ` --- - months: ['martius:june'] `, expectError: true, err: "martius is not a valid month", }, { // Invalid end month. in: ` --- - months: ['march:junius'] `, expectError: true, err: "junius is not a valid month", }, { // Start month after end month. in: ` --- - months: ['december:january'] `, expectError: true, err: "end month january is before start month december", }, { // Start year after end year. in: ` --- - years: ['2022:2020'] `, expectError: true, err: "end year 2020 is before start year 2022", }, } func TestYamlUnmarshal(t *testing.T) { for _, tc := range yamlUnmarshalTestCases { var ti []TimeInterval err := yaml.Unmarshal([]byte(tc.in), &ti) if err != nil && !tc.expectError { t.Errorf("Received unexpected error: %v when parsing %v", err, tc.in) } else if err == nil && tc.expectError { t.Errorf("Expected error when unmarshalling %s but didn't receive one", tc.in) } else if err != nil && tc.expectError { if err.Error() != tc.err { t.Errorf("Incorrect error: Want %s, got %s", tc.err, err.Error()) } continue } if !reflect.DeepEqual(ti, tc.intervals) { t.Errorf("Error unmarshalling %s: Want %+v, got %+v", tc.in, tc.intervals, ti) } for _, ts := range tc.contains { _t, _ := time.Parse(time.RFC822Z, ts) isContained := false for _, interval := range ti { if interval.ContainsTime(_t) { isContained = true } } if !isContained { t.Errorf("Expected intervals to contain time %s", _t) } } for _, ts := range tc.excludes { _t, _ := time.Parse(time.RFC822Z, ts) isContained := false for _, interval := range ti { if interval.ContainsTime(_t) { isContained = true } } if isContained { t.Errorf("Expected intervals to exclude time %s", _t) } } } } func TestContainsTime(t *testing.T) { for _, tc := range timeIntervalTestCases { for _, ts := range tc.validTimeStrings { _t, _ := time.Parse(time.RFC822Z, ts) if !tc.timeInterval.ContainsTime(_t) { t.Errorf("Expected period %+v to contain %+v", tc.timeInterval, _t) } } for _, ts := range tc.invalidTimeStrings { _t, _ := time.Parse(time.RFC822Z, ts) if tc.timeInterval.ContainsTime(_t) { t.Errorf("Period %+v not expected to contain %+v", tc.timeInterval, _t) } } } } func TestParseTimeString(t *testing.T) { for _, tc := range timeStringTestCases { var tr TimeRange err := yaml.Unmarshal([]byte(tc.timeString), &tr) if err != nil && !tc.expectError { t.Errorf("Received unexpected error: %v when parsing %v", err, tc.timeString) } else if err == nil && tc.expectError { t.Errorf("Expected error for invalid string %s but didn't receive one", tc.timeString) } else if !reflect.DeepEqual(tr, tc.TimeRange) { t.Errorf("Error parsing time string %s: Want %+v, got %+v", tc.timeString, tc.TimeRange, tr) } } } func TestYamlMarshal(t *testing.T) { for _, tc := range yamlUnmarshalTestCases { if tc.expectError { continue } var ti []TimeInterval err := yaml.Unmarshal([]byte(tc.in), &ti) if err != nil { t.Error(err) } out, err := yaml.Marshal(&ti) if err != nil { t.Error(err) } var ti2 []TimeInterval yaml.Unmarshal(out, &ti2) if !reflect.DeepEqual(ti, ti2) { t.Errorf("Re-marshalling %s produced a different TimeInterval.", tc.in) } } } // Test JSON marshalling by marshalling a time interval // and then unmarshalling to ensure they're identical. func TestJsonMarshal(t *testing.T) { for _, tc := range yamlUnmarshalTestCases { if tc.expectError { continue } var ti []TimeInterval err := yaml.Unmarshal([]byte(tc.in), &ti) if err != nil { t.Error(err) } out, err := json.Marshal(&ti) if err != nil { t.Error(err) } var ti2 []TimeInterval json.Unmarshal(out, &ti2) if !reflect.DeepEqual(ti, ti2) { t.Errorf("Re-marshalling %s produced a different TimeInterval. Used:\n%s and got:\n%v", tc.in, out, ti2) } } } var completeTestCases = []struct { in string contains []string excludes []string }{ { in: ` --- weekdays: ['monday:wednesday', 'saturday', 'sunday'] times: - start_time: '13:00' end_time: '15:00' days_of_month: ['1', '10', '20:-1'] years: ['2020:2023'] months: ['january:march'] `, contains: []string{ "10 Jan 21 13:00 +0000", "30 Jan 21 14:24 +0000", }, excludes: []string{ "09 Jan 21 13:00 +0000", "20 Jan 21 12:59 +0000", "02 Feb 21 13:00 +0000", }, }, { // Check for broken clamping (clamping begin date after end of month to the end of the month) in: ` --- days_of_month: ['30:31'] years: ['2020:2023'] months: ['february'] `, excludes: []string{ "28 Feb 21 13:00 +0000", }, }, } // Tests the entire flow from unmarshalling to containing a time. func TestTimeIntervalComplete(t *testing.T) { for _, tc := range completeTestCases { var ti TimeInterval if err := yaml.Unmarshal([]byte(tc.in), &ti); err != nil { t.Error(err) } for _, ts := range tc.contains { tt, err := time.Parse(time.RFC822Z, ts) if err != nil { t.Error(err) } if !ti.ContainsTime(tt) { t.Errorf("Expected %s to contain %s", tc.in, ts) } } for _, ts := range tc.excludes { tt, err := time.Parse(time.RFC822Z, ts) if err != nil { t.Error(err) } if ti.ContainsTime(tt) { t.Errorf("Expected %s to exclude %s", tc.in, ts) } } } } // Utility function for declaring time locations in test cases. Panic if the location can't be loaded. func mustLoadLocation(name string) *time.Location { loc, err := time.LoadLocation(name) if err != nil { panic(err) } return loc } func TestIntervener_Mutes(t *testing.T) { sydney, err := time.LoadLocation("Australia/Sydney") if err != nil { t.Fatalf("Failed to load location Australia/Sydney: %s", err) } eveningsAndWeekends := map[string][]TimeInterval{ "evenings": {{ Times: []TimeRange{{ StartMinute: 0, // 00:00 EndMinute: 540, // 09:00 }, { StartMinute: 1020, // 17:00 EndMinute: 1440, // 24:00 }}, Location: &Location{Location: sydney}, }}, "weekends": {{ Weekdays: []WeekdayRange{{ InclusiveRange: InclusiveRange{Begin: 6, End: 6}, // Saturday }, { InclusiveRange: InclusiveRange{Begin: 0, End: 0}, // Sunday }}, Location: &Location{Location: sydney}, }}, } tests := []struct { name string intervals map[string][]TimeInterval now time.Time mutedBy []string }{{ name: "Should be muted outside working hours", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 1, 0, 0, 0, 0, sydney), mutedBy: []string{"evenings"}, }, { name: "Should not be muted during working hours", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 1, 9, 0, 0, 0, sydney), mutedBy: nil, }, { name: "Should be muted during weekends", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 6, 10, 0, 0, 0, sydney), mutedBy: []string{"weekends"}, }, { name: "Should be muted during weekend evenings", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 6, 17, 0, 0, 0, sydney), mutedBy: []string{"evenings", "weekends"}, }, { name: "Should be muted at 12pm UTC on a weekday", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC), mutedBy: []string{"evenings"}, }, { name: "Should be muted at 12pm UTC on a weekend", intervals: eveningsAndWeekends, now: time.Date(2024, 1, 6, 10, 0, 0, 0, time.UTC), mutedBy: []string{"evenings", "weekends"}, }} for _, test := range tests { t.Run(test.name, func(t *testing.T) { intervener := NewIntervener(test.intervals) // Get the names of all time intervals for the context. timeIntervalNames := make([]string, 0, len(test.intervals)) for name := range test.intervals { timeIntervalNames = append(timeIntervalNames, name) } // Sort the names so we can compare mutedBy with test.mutedBy. sort.Strings(timeIntervalNames) isMuted, mutedBy, err := intervener.Mutes(timeIntervalNames, test.now) require.NoError(t, err) if len(test.mutedBy) == 0 { require.False(t, isMuted) require.Empty(t, mutedBy) } else { require.True(t, isMuted) require.Equal(t, test.mutedBy, mutedBy) } }) } } ================================================ FILE: tracing/config.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package tracing import ( "errors" "fmt" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/model" ) // TODO: probably move these into prometheus/common since they're copied from // prometheus/prometheus? type TracingClientType string const ( TracingClientHTTP TracingClientType = "http" TracingClientGRPC TracingClientType = "grpc" GzipCompression = "gzip" ) // UnmarshalYAML implements the yaml.Unmarshaler interface. func (t *TracingClientType) UnmarshalYAML(unmarshal func(any) error) error { *t = TracingClientType("") type plain TracingClientType if err := unmarshal((*plain)(t)); err != nil { return err } switch *t { case TracingClientHTTP, TracingClientGRPC: return nil default: return fmt.Errorf("expected tracing client type to be to be %s or %s, but got %s", TracingClientHTTP, TracingClientGRPC, *t, ) } } // TracingConfig configures the tracing options. type TracingConfig struct { ClientType TracingClientType `yaml:"client_type,omitempty"` Endpoint string `yaml:"endpoint,omitempty"` SamplingFraction float64 `yaml:"sampling_fraction,omitempty"` Insecure bool `yaml:"insecure,omitempty"` TLSConfig *commoncfg.TLSConfig `yaml:"tls_config,omitempty"` Headers *commoncfg.Headers `yaml:"headers,omitempty"` Compression string `yaml:"compression,omitempty"` Timeout model.Duration `yaml:"timeout,omitempty"` } // SetDirectory joins any relative file paths with dir. func (t *TracingConfig) SetDirectory(dir string) { t.TLSConfig.SetDirectory(dir) t.Headers.SetDirectory(dir) } // UnmarshalYAML implements the yaml.Unmarshaler interface. func (t *TracingConfig) UnmarshalYAML(unmarshal func(any) error) error { *t = TracingConfig{ ClientType: TracingClientGRPC, } type plain TracingConfig if err := unmarshal((*plain)(t)); err != nil { return err } if t.Endpoint == "" { return errors.New("tracing endpoint must be set") } if t.Compression != "" && t.Compression != GzipCompression { return fmt.Errorf("invalid compression type %s provided, valid options: %s", t.Compression, GzipCompression) } return nil } ================================================ FILE: tracing/http.go ================================================ // Copyright 2024 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package tracing import ( "context" "fmt" "net/http" "net/http/httptrace" "go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // TODO: maybe move these into prometheus/common? // Transport wraps the provided http.RoundTripper with one that starts a span // and injects the span context into the outbound request headers. If the // provided http.RoundTripper is nil, http.DefaultTransport will be used as the // base http.RoundTripper. func Transport(rt http.RoundTripper) http.RoundTripper { rt = otelhttp.NewTransport(rt, otelhttp.WithClientTrace(func(ctx context.Context) *httptrace.ClientTrace { return otelhttptrace.NewClientTrace(ctx) }), ) return rt } // Middleware returns a new HTTP handler that will trace all requests with the // HTTP method and path as the span name. func Middleware(handler http.Handler) http.Handler { return otelhttp.NewHandler(handler, "", otelhttp.WithSpanNameFormatter(func(_ string, r *http.Request) string { return fmt.Sprintf("%s %s", r.Method, r.URL.Path) }), ) } ================================================ FILE: tracing/testdata/ca.cer ================================================ -----BEGIN CERTIFICATE----- MIIDkTCCAnmgAwIBAgIJAJNsnimNN3tmMA0GCSqGSIb3DQEBCwUAMF8xCzAJBgNV BAYTAlhYMRUwEwYDVQQHDAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQg Q29tcGFueSBMdGQxGzAZBgNVBAMMElByb21ldGhldXMgVGVzdCBDQTAeFw0xNTA4 MDQxNDA5MjFaFw0yNTA4MDExNDA5MjFaMF8xCzAJBgNVBAYTAlhYMRUwEwYDVQQH DAxEZWZhdWx0IENpdHkxHDAaBgNVBAoME0RlZmF1bHQgQ29tcGFueSBMdGQxGzAZ BgNVBAMMElByb21ldGhldXMgVGVzdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP ADCCAQoCggEBAOlSBU3yWpUELbhzizznR0hnAL7dbEHzfEtEc6N3PoSvMNcqrUVq t4kjBRWzqkZ5uJVkzBPERKEBoOI9pWcrqtMTBkMzHJY2Ep7GHTab10e9KC2IFQT6 FKP/jCYixaIVx3azEfajRJooD8r79FGoagWUfHdHyCFWJb/iLt8z8+S91kelSRMS yB9M1ypWomzBz1UFXZp1oiNO5o7/dgXW4MgLUfC2obJ9j5xqpc6GkhWMW4ZFwEr/ VLjuzxG9B8tLfQuhnXKGn1W8+WzZVWCWMD/sLfZfmjKaWlwcXzL51g8E+IEIBJqV w51aMI6lDkcvAM7gLq1auLZMVXyKWSKw7XMCAwEAAaNQME4wHQYDVR0OBBYEFMz1 BZnlqxJp2HiJSjHK8IsLrWYbMB8GA1UdIwQYMBaAFMz1BZnlqxJp2HiJSjHK8IsL rWYbMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAI2iA3w3TK5J15Pu e4fPFB4jxQqsbUwuyXbCCv/jKLeFNCD4BjM181WZEYjPMumeTBVzU3aF45LWQIG1 0DJcrCL4mjMz9qgAoGqA7aDDXiJGbukMgYYsn7vrnVmrZH8T3E8ySlltr7+W578k pJ5FxnbCroQwn0zLyVB3sFbS8E3vpBr3L8oy8PwPHhIScexcNVc3V6/m4vTZsXTH U+vUm1XhDgpDcFMTg2QQiJbfpOYUkwIgnRDAT7t282t2KQWtnlqc3zwPQ1F/6Cpx j19JeNsaF1DArkD7YlyKj/GhZLtHwFHG5cxznH0mLDJTW7bQvqqh2iQTeXmBk1lU mM5lH/s= -----END CERTIFICATE----- ================================================ FILE: tracing/tracing.go ================================================ // Copyright 2021 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package tracing import ( "context" "fmt" "log/slog" "reflect" "time" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/version" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" tracesdk "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.39.0" "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" "google.golang.org/grpc/credentials" ) const serviceName = "alertmanager" // Manager is capable of building, (re)installing and shutting down // the tracer provider. type Manager struct { logger *slog.Logger done chan struct{} config TracingConfig shutdownFunc func() error } // NewManager creates a new tracing manager. func NewManager(logger *slog.Logger) *Manager { return &Manager{ logger: logger, done: make(chan struct{}), } } // Run starts the tracing manager. It registers the global text map propagator and error handler. // It is blocking. func (m *Manager) Run() { otel.SetTextMapPropagator(propagation.TraceContext{}) otel.SetErrorHandler(otelErrHandler(func(err error) { m.logger.Error("OpenTelemetry handler returned an error", "err", err) })) <-m.done } // ApplyConfig takes care of refreshing the tracing configuration by shutting down // the current tracer provider (if any is registered) and installing a new one. func (m *Manager) ApplyConfig(cfg TracingConfig) error { // Update only if a config change is detected. If TLS configuration is // set, we have to restart the manager to make sure that new TLS // certificates are picked up. var blankTLSConfig commoncfg.TLSConfig if reflect.DeepEqual(m.config, cfg) && (m.config.TLSConfig == nil || *m.config.TLSConfig == blankTLSConfig) { return nil } if m.shutdownFunc != nil { if err := m.shutdownFunc(); err != nil { return fmt.Errorf("failed to shut down the tracer provider: %w", err) } } // If no endpoint is set, assume tracing should be disabled. if cfg.Endpoint == "" { m.config = cfg m.shutdownFunc = nil otel.SetTracerProvider(noop.NewTracerProvider()) m.logger.Info("Tracing provider uninstalled.") return nil } tp, shutdownFunc, err := buildTracerProvider(context.Background(), cfg) if err != nil { return fmt.Errorf("failed to install a new tracer provider: %w", err) } m.shutdownFunc = shutdownFunc m.config = cfg otel.SetTracerProvider(tp) m.logger.Info("Successfully installed a new tracer provider.") return nil } // Stop gracefully shuts down the tracer provider and stops the tracing manager. func (m *Manager) Stop() { defer close(m.done) if m.shutdownFunc == nil { return } if err := m.shutdownFunc(); err != nil { m.logger.Error("failed to shut down the tracer provider", "err", err) } m.logger.Info("Tracing manager stopped") } type otelErrHandler func(err error) func (o otelErrHandler) Handle(err error) { o(err) } // buildTracerProvider return a new tracer provider ready for installation, together // with a shutdown function. func buildTracerProvider(ctx context.Context, tracingCfg TracingConfig) (trace.TracerProvider, func() error, error) { client, err := getClient(tracingCfg) if err != nil { return nil, nil, err } exp, err := otlptrace.New(ctx, client) if err != nil { return nil, nil, err } // Create a resource describing the service and the runtime. res, err := resource.New( ctx, resource.WithSchemaURL(semconv.SchemaURL), resource.WithAttributes( semconv.ServiceNameKey.String(serviceName), semconv.ServiceVersionKey.String(version.Version), ), resource.WithProcessRuntimeDescription(), resource.WithTelemetrySDK(), ) if err != nil { return nil, nil, err } tp := tracesdk.NewTracerProvider( tracesdk.WithBatcher(exp), tracesdk.WithSampler(tracesdk.ParentBased( tracesdk.TraceIDRatioBased(tracingCfg.SamplingFraction), )), tracesdk.WithResource(res), ) return tp, func() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := tp.Shutdown(ctx) if err != nil { return err } return nil }, nil } // headersToMap converts prometheus/common Headers to a simple map[string]string. // It takes the first value from Values, Secrets, or Files for each header. func headersToMap(headers *commoncfg.Headers) (map[string]string, error) { if headers == nil || len(headers.Headers) == 0 { return nil, nil } result := make(map[string]string) for name, header := range headers.Headers { if len(header.Values) > 0 { result[name] = header.Values[0] } else if len(header.Secrets) > 0 { result[name] = string(header.Secrets[0]) } else if len(header.Files) > 0 { // Note: Files would need to be read at runtime. For tracing config, // we only support direct values and secrets. return nil, fmt.Errorf("header files are not supported for tracing configuration") } } return result, nil } // getClient returns an appropriate OTLP client (either gRPC or HTTP), based // on the provided tracing configuration. func getClient(tracingCfg TracingConfig) (otlptrace.Client, error) { var client otlptrace.Client switch tracingCfg.ClientType { case TracingClientGRPC: opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(tracingCfg.Endpoint)} switch { case tracingCfg.Insecure: opts = append(opts, otlptracegrpc.WithInsecure()) case tracingCfg.TLSConfig != nil: // Use of TLS Credentials forces the use of TLS. Therefore it can // only be set when `insecure` is set to false. tlsConf, err := commoncfg.NewTLSConfig(tracingCfg.TLSConfig) if err != nil { return nil, err } opts = append(opts, otlptracegrpc.WithTLSCredentials(credentials.NewTLS(tlsConf))) } if tracingCfg.Compression != "" { opts = append(opts, otlptracegrpc.WithCompressor(tracingCfg.Compression)) } headers, err := headersToMap(tracingCfg.Headers) if err != nil { return nil, err } if len(headers) > 0 { opts = append(opts, otlptracegrpc.WithHeaders(headers)) } if tracingCfg.Timeout != 0 { opts = append(opts, otlptracegrpc.WithTimeout(time.Duration(tracingCfg.Timeout))) } client = otlptracegrpc.NewClient(opts...) case TracingClientHTTP: opts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(tracingCfg.Endpoint)} switch { case tracingCfg.Insecure: opts = append(opts, otlptracehttp.WithInsecure()) case tracingCfg.TLSConfig != nil: tlsConf, err := commoncfg.NewTLSConfig(tracingCfg.TLSConfig) if err != nil { return nil, err } opts = append(opts, otlptracehttp.WithTLSClientConfig(tlsConf)) } if tracingCfg.Compression == GzipCompression { opts = append(opts, otlptracehttp.WithCompression(otlptracehttp.GzipCompression)) } headers, err := headersToMap(tracingCfg.Headers) if err != nil { return nil, err } if len(headers) > 0 { opts = append(opts, otlptracehttp.WithHeaders(headers)) } if tracingCfg.Timeout != 0 { opts = append(opts, otlptracehttp.WithTimeout(time.Duration(tracingCfg.Timeout))) } client = otlptracehttp.NewClient(opts...) default: return nil, fmt.Errorf("unknown tracing client type: %s", tracingCfg.ClientType) } return client, nil } ================================================ FILE: tracing/tracing_test.go ================================================ // Copyright 2021 The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package tracing import ( "testing" commoncfg "github.com/prometheus/common/config" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace/noop" ) func TestInstallingNewTracerProvider(t *testing.T) { tpBefore := otel.GetTracerProvider() m := NewManager(promslog.NewNopLogger()) cfg := TracingConfig{ Endpoint: "localhost:1234", ClientType: TracingClientGRPC, } require.NoError(t, m.ApplyConfig(cfg)) require.NotEqual(t, tpBefore, otel.GetTracerProvider()) } func TestReinstallingTracerProvider(t *testing.T) { m := NewManager(promslog.NewNopLogger()) cfg := TracingConfig{ Endpoint: "localhost:1234", ClientType: TracingClientGRPC, Headers: &commoncfg.Headers{ Headers: map[string]commoncfg.Header{ "foo": {Values: []string{"bar"}}, }, }, } require.NoError(t, m.ApplyConfig(cfg)) tpFirstConfig := otel.GetTracerProvider() // Trying to apply the same config should not reinstall provider. require.NoError(t, m.ApplyConfig(cfg)) require.Equal(t, tpFirstConfig, otel.GetTracerProvider()) cfg2 := TracingConfig{ Endpoint: "localhost:1234", ClientType: TracingClientHTTP, Headers: &commoncfg.Headers{ Headers: map[string]commoncfg.Header{ "bar": {Values: []string{"foo"}}, }, }, } require.NoError(t, m.ApplyConfig(cfg2)) require.NotEqual(t, tpFirstConfig, otel.GetTracerProvider()) tpSecondConfig := otel.GetTracerProvider() // Setting previously unset option should reinstall provider. cfg2.Compression = "gzip" require.NoError(t, m.ApplyConfig(cfg2)) require.NotEqual(t, tpSecondConfig, otel.GetTracerProvider()) } func TestReinstallingTracerProviderWithTLS(t *testing.T) { m := NewManager(promslog.NewNopLogger()) cfg := TracingConfig{ Endpoint: "localhost:1234", ClientType: TracingClientGRPC, TLSConfig: &commoncfg.TLSConfig{ CAFile: "testdata/ca.cer", }, } require.NoError(t, m.ApplyConfig(cfg)) tpFirstConfig := otel.GetTracerProvider() // Trying to apply the same config with TLS should reinstall provider. require.NoError(t, m.ApplyConfig(cfg)) require.NotEqual(t, tpFirstConfig, otel.GetTracerProvider()) } func TestUninstallingTracerProvider(t *testing.T) { m := NewManager(promslog.NewNopLogger()) cfg := TracingConfig{ Endpoint: "localhost:1234", ClientType: TracingClientGRPC, } require.NoError(t, m.ApplyConfig(cfg)) require.NotEqual(t, noop.NewTracerProvider(), otel.GetTracerProvider()) // Uninstall by passing empty config. cfg2 := TracingConfig{} require.NoError(t, m.ApplyConfig(cfg2)) // Make sure we get a no-op tracer provider after uninstallation. require.Equal(t, noop.NewTracerProvider(), otel.GetTracerProvider()) } func TestTracerProviderShutdown(t *testing.T) { m := NewManager(promslog.NewNopLogger()) cfg := TracingConfig{ Endpoint: "localhost:1234", ClientType: TracingClientGRPC, } require.NoError(t, m.ApplyConfig(cfg)) m.Stop() // Check if we closed the done channel. _, ok := <-m.done require.False(t, ok) } ================================================ FILE: types/types.go ================================================ // Copyright 2015 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types import ( "sync" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/prometheus/alertmanager/alert" ) // Deprecated: Use alert.Alert directly. type Alert = alert.Alert // Deprecated: Use alert.AlertSlice directly. type AlertSlice = alert.AlertSlice // Deprecated: Use alert.Alerts directly. var Alerts = alert.Alerts // Deprecated: Use alert.AlertState constants directly. type AlertState = alert.AlertState // Deprecated: Use alert.AlertStateActive directly. const AlertStateActive AlertState = alert.AlertStateActive // Deprecated: Use alert.AlertStateSuppressed directly. const AlertStateSuppressed AlertState = alert.AlertStateSuppressed // Deprecated: Use alert.AlertStateUnprocessed directly. const AlertStateUnprocessed AlertState = alert.AlertStateUnprocessed // Deprecated: Use alert.AlertStatus directly. type AlertStatus = alert.AlertStatus // groupStatus stores the state of the group, and, as applicable, the names // of all active and mute time intervals that are muting it. type groupStatus struct { // mutedBy contains the names of all active and mute time intervals that // are muting it. mutedBy []string } // AlertMarker helps to mark alerts as silenced and/or inhibited. // All methods are goroutine-safe. type AlertMarker interface { // SetActiveOrSilenced replaces the previous SilencedBy by the provided IDs of // active silences. The set of provided IDs is supposed to represent the // complete set of relevant silences. If no active silence IDs are provided and // InhibitedBy is already empty, it sets the provided alert to AlertStateActive. // Otherwise, it sets the provided alert to AlertStateSuppressed. SetActiveOrSilenced(alert model.Fingerprint, activeSilenceIDs []string) // SetInhibited replaces the previous InhibitedBy by the provided IDs of // alerts. In contrast to SetActiveOrSilenced, the set of provided IDs is not // expected to represent the complete set of inhibiting alerts. (In // practice, this method is only called with one or zero IDs. However, // this expectation might change in the future. If no IDs are provided // and InhibitedBy is already empty, it sets the provided alert to // AlertStateActive. Otherwise, it sets the provided alert to // AlertStateSuppressed. SetInhibited(alert model.Fingerprint, alertIDs ...string) // Count alerts of the given state(s). With no state provided, count all // alerts. Count(...AlertState) int // Status of the given alert. Status(model.Fingerprint) AlertStatus // Delete the given alert. Delete(...model.Fingerprint) // Various methods to inquire if the given alert is in a certain // AlertState. Silenced also returns all the active silences, // while Inhibited may return only a subset of inhibiting alerts. Unprocessed(model.Fingerprint) bool Active(model.Fingerprint) bool Silenced(model.Fingerprint) (activeIDs []string, silenced bool) Inhibited(model.Fingerprint) ([]string, bool) } // GroupMarker helps to mark groups as active or muted. // All methods are goroutine-safe. // // TODO(grobinson): routeID is used in Muted and SetMuted because groupKey // is not unique (see #3817). Once groupKey uniqueness is fixed routeID can // be removed from the GroupMarker interface. type GroupMarker interface { // Muted returns true if the group is muted, otherwise false. If the group // is muted then it also returns the names of the time intervals that muted // it. Muted(routeID, groupKey string) ([]string, bool) // SetMuted marks the group as muted, and sets the names of the time // intervals that mute it. If the list of names is nil or the empty slice // then the muted marker is removed. SetMuted(routeID, groupKey string, timeIntervalNames []string) // DeleteByGroupKey removes all markers for the GroupKey. DeleteByGroupKey(routeID, groupKey string) } // NewMarker returns an instance of a AlertMarker implementation. func NewMarker(r prometheus.Registerer) *MemMarker { m := &MemMarker{ alerts: map[model.Fingerprint]*AlertStatus{}, groups: map[string]*groupStatus{}, } m.registerMetrics(r) return m } type MemMarker struct { alerts map[model.Fingerprint]*AlertStatus groups map[string]*groupStatus mtx sync.RWMutex } // Muted implements GroupMarker. func (m *MemMarker) Muted(routeID, groupKey string) ([]string, bool) { m.mtx.Lock() defer m.mtx.Unlock() status, ok := m.groups[routeID+groupKey] if !ok { return nil, false } return status.mutedBy, len(status.mutedBy) > 0 } // SetMuted implements GroupMarker. func (m *MemMarker) SetMuted(routeID, groupKey string, timeIntervalNames []string) { m.mtx.Lock() defer m.mtx.Unlock() status, ok := m.groups[routeID+groupKey] if !ok { status = &groupStatus{} m.groups[routeID+groupKey] = status } status.mutedBy = timeIntervalNames } func (m *MemMarker) DeleteByGroupKey(routeID, groupKey string) { m.mtx.Lock() defer m.mtx.Unlock() delete(m.groups, routeID+groupKey) } func (m *MemMarker) registerMetrics(r prometheus.Registerer) { newMarkedAlertMetricByState := func(st AlertState) prometheus.GaugeFunc { return prometheus.NewGaugeFunc( prometheus.GaugeOpts{ Name: "alertmanager_marked_alerts", Help: "How many alerts by state are currently marked in the Alertmanager regardless of their expiry.", ConstLabels: prometheus.Labels{"state": string(st)}, }, func() float64 { return float64(m.Count(st)) }, ) } alertsActive := newMarkedAlertMetricByState(AlertStateActive) alertsSuppressed := newMarkedAlertMetricByState(AlertStateSuppressed) alertStateUnprocessed := newMarkedAlertMetricByState(AlertStateUnprocessed) r.MustRegister(alertsActive) r.MustRegister(alertsSuppressed) r.MustRegister(alertStateUnprocessed) } // Count implements AlertMarker. func (m *MemMarker) Count(states ...AlertState) int { m.mtx.RLock() defer m.mtx.RUnlock() if len(states) == 0 { return len(m.alerts) } var count int for _, status := range m.alerts { for _, state := range states { if status.State == state { count++ } } } return count } // SetActiveOrSilenced implements AlertMarker. func (m *MemMarker) SetActiveOrSilenced(alert model.Fingerprint, activeIDs []string) { m.mtx.Lock() defer m.mtx.Unlock() s, found := m.alerts[alert] if !found { s = &AlertStatus{} m.alerts[alert] = s } s.SilencedBy = activeIDs // If there are any silence or alert IDs associated with the // fingerprint, it is suppressed. Otherwise, set it to // AlertStateActive. if len(activeIDs) == 0 && len(s.InhibitedBy) == 0 { s.State = AlertStateActive return } s.State = AlertStateSuppressed } // SetInhibited implements AlertMarker. func (m *MemMarker) SetInhibited(alert model.Fingerprint, ids ...string) { m.mtx.Lock() defer m.mtx.Unlock() s, found := m.alerts[alert] if !found { s = &AlertStatus{} m.alerts[alert] = s } s.InhibitedBy = ids // If there are any silence or alert IDs associated with the // fingerprint, it is suppressed. Otherwise, set it to // AlertStateActive. if len(ids) == 0 && len(s.SilencedBy) == 0 { s.State = AlertStateActive return } s.State = AlertStateSuppressed } // Status implements AlertMarker. func (m *MemMarker) Status(alert model.Fingerprint) AlertStatus { m.mtx.RLock() defer m.mtx.RUnlock() if s, found := m.alerts[alert]; found { return *s } return AlertStatus{ State: AlertStateUnprocessed, SilencedBy: []string{}, InhibitedBy: []string{}, } } // Delete implements AlertMarker. func (m *MemMarker) Delete(alerts ...model.Fingerprint) { m.mtx.Lock() defer m.mtx.Unlock() for _, alert := range alerts { delete(m.alerts, alert) } } // Unprocessed implements AlertMarker. func (m *MemMarker) Unprocessed(alert model.Fingerprint) bool { return m.Status(alert).State == AlertStateUnprocessed } // Active implements AlertMarker. func (m *MemMarker) Active(alert model.Fingerprint) bool { return m.Status(alert).State == AlertStateActive } // Inhibited implements AlertMarker. func (m *MemMarker) Inhibited(alert model.Fingerprint) ([]string, bool) { s := m.Status(alert) return s.InhibitedBy, s.State == AlertStateSuppressed && len(s.InhibitedBy) > 0 } // Silenced returns whether the alert for the given Fingerprint is in the // Silenced state, any associated silence IDs, and the silences state version // the result is based on. func (m *MemMarker) Silenced(alert model.Fingerprint) (activeIDs []string, silenced bool) { s := m.Status(alert) return s.SilencedBy, s.State == AlertStateSuppressed && len(s.SilencedBy) > 0 } ================================================ FILE: types/types_test.go ================================================ // Copyright 2015 Prometheus Team // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package types //nolint:revive import ( "testing" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/common/model" "github.com/stretchr/testify/require" ) func TestMemMarker_Muted(t *testing.T) { r := prometheus.NewRegistry() marker := NewMarker(r) // No groups should be muted. timeIntervalNames, isMuted := marker.Muted("route1", "group1") require.False(t, isMuted) require.Empty(t, timeIntervalNames) // Mark the group as muted because it's the weekend. marker.SetMuted("route1", "group1", []string{"weekends"}) timeIntervalNames, isMuted = marker.Muted("route1", "group1") require.True(t, isMuted) require.Equal(t, []string{"weekends"}, timeIntervalNames) // Other groups should not be marked as muted. timeIntervalNames, isMuted = marker.Muted("route1", "group2") require.False(t, isMuted) require.Empty(t, timeIntervalNames) // Other routes should not be marked as muted either. timeIntervalNames, isMuted = marker.Muted("route2", "group1") require.False(t, isMuted) require.Empty(t, timeIntervalNames) // The group is no longer muted. marker.SetMuted("route1", "group1", nil) timeIntervalNames, isMuted = marker.Muted("route1", "group1") require.False(t, isMuted) require.Empty(t, timeIntervalNames) } func TestMemMarker_DeleteByGroupKey(t *testing.T) { r := prometheus.NewRegistry() marker := NewMarker(r) // Mark the group and check that it is muted. marker.SetMuted("route1", "group1", []string{"weekends"}) timeIntervalNames, isMuted := marker.Muted("route1", "group1") require.True(t, isMuted) require.Equal(t, []string{"weekends"}, timeIntervalNames) // Delete the markers for a different group key. The group should // still be muted. marker.DeleteByGroupKey("route1", "group2") timeIntervalNames, isMuted = marker.Muted("route1", "group1") require.True(t, isMuted) require.Equal(t, []string{"weekends"}, timeIntervalNames) // Delete the markers for the correct group key. The group should // no longer be muted. marker.DeleteByGroupKey("route1", "group1") timeIntervalNames, isMuted = marker.Muted("route1", "group1") require.False(t, isMuted) require.Empty(t, timeIntervalNames) } func TestMemMarker_Count(t *testing.T) { r := prometheus.NewRegistry() marker := NewMarker(r) now := time.Now() states := []AlertState{AlertStateSuppressed, AlertStateActive, AlertStateUnprocessed} countByState := func(state AlertState) int { return marker.Count(state) } countTotal := func() int { var count int for _, s := range states { count += countByState(s) } return count } require.Equal(t, 0, countTotal()) a1 := model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now.Add(2 * time.Minute), Labels: model.LabelSet{"test": "active"}, } a2 := model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now.Add(2 * time.Minute), Labels: model.LabelSet{"test": "suppressed"}, } a3 := model.Alert{ StartsAt: now.Add(-2 * time.Minute), EndsAt: now.Add(-1 * time.Minute), Labels: model.LabelSet{"test": "resolved"}, } // Insert an active alert. marker.SetActiveOrSilenced(a1.Fingerprint(), nil) require.Equal(t, 1, countByState(AlertStateActive)) require.Equal(t, 1, countTotal()) // Insert a silenced alert. marker.SetActiveOrSilenced(a2.Fingerprint(), []string{"1"}) require.Equal(t, 1, countByState(AlertStateSuppressed)) require.Equal(t, 2, countTotal()) // Insert a resolved silenced alert - it'll count as suppressed. marker.SetActiveOrSilenced(a3.Fingerprint(), []string{"1"}) require.Equal(t, 2, countByState(AlertStateSuppressed)) require.Equal(t, 3, countTotal()) // Remove the silence from a3 - it'll count as active. marker.SetActiveOrSilenced(a3.Fingerprint(), nil) require.Equal(t, 2, countByState(AlertStateActive)) require.Equal(t, 3, countTotal()) } type fakeRegisterer struct { registeredCollectors []prometheus.Collector } func (r *fakeRegisterer) Register(prometheus.Collector) error { return nil } func (r *fakeRegisterer) MustRegister(c ...prometheus.Collector) { r.registeredCollectors = append(r.registeredCollectors, c...) } func (r *fakeRegisterer) Unregister(prometheus.Collector) bool { return false } func TestNewMarkerRegistersMetrics(t *testing.T) { fr := fakeRegisterer{} NewMarker(&fr) if len(fr.registeredCollectors) == 0 { t.Error("expected NewMarker to register metrics on the given registerer") } } ================================================ FILE: ui/Dockerfile ================================================ FROM node:22-bookworm ENV NPM_CONFIG_PREFIX=/home/node/.npm-global ENV PATH=$PATH:/home/node/.npm-global/bin RUN mkdir -p $NPM_CONFIG_PREFIX; yarn global add \ elm@0.19.1 \ elm-format@0.8.7 \ elm-test@0.19.1-revision6 \ uglify-js@3.13.4 \ elm-review@2.5.0 ================================================ FILE: ui/app/.gitignore ================================================ dist/ elm-stuff/ .elm /elm-* /openapi-* /build ================================================ FILE: ui/app/CONTRIBUTING.md ================================================ # Contributing This document describes how to: - Set up your dev environment - Become familiar with [Elm](http://elm-lang.org/) - Develop against AlertManager ## Dev Environment Setup You can either use our default Docker setup or install all dev dependencies locally. For the former you only need Docker installed, for the latter you need to set the environment flag `NO_DOCKER` to `true` and have the following dependencies installed: - [Elm](https://guide.elm-lang.org/install.html#install) - [Elm-Format](https://github.com/avh4/elm-format) is installed In addition for easier development you can [configure](https://guide.elm-lang.org/install.html#configure-your-editor) your editor. **All submitted elm code must be formatted with `elm-format`**. Install and execute it however works best for you. We recommend having formatting the file on save, similar to how many developers use `gofmt`. If you prefer, there's a make target available to format all elm source files: ``` # make format ``` ## Elm Resources - The [Official Elm Guide](https://guide.elm-lang.org/) is a great place to start. Going through the entire guide takes about an hour, and is a good primer to get involved in our codebase. Once you've worked through it, you should be able to start writing your feature with the help of the compiler. - Check the [syntax reference](http://elm-lang.org/docs/syntax) when you need a reminder of how the language works. - Read up on [how to write elm code](http://elm-lang.org/docs/style-guide). - Watch videos from the latest [elm-conf](https://www.youtube.com/channel/UCOpGiN9AkczVjlpGDaBwQrQ) - Learn how to use the debugger! Elm comes packaged with an excellent [debugger](http://elm-lang.org/blog/the-perfect-bug-report). We've found this tool to be invaluable in understanding how the app is working as we're debugging behavior. ## Local development workflow At the top level of this repo, follow the HA AlertManager instructions. Compile the binary, then run with `goreman`. Add example alerts with the file provided in the HA example folder. Then start the development server: ``` # cd ui/app # make dev-server ``` Your app should be available at `http://localhost:`. Navigate to `src/Main.elm`. Any changes to the file system are detected automatically, triggering a recompile of the project. ## Committing changes Before you commit changes, please run `make build-all` on the root level Makefile. ================================================ FILE: ui/app/Makefile ================================================ # Use `=` instead of `:=` expanding variable lazely, not at beginning. Needed as # elm files change during execution. ELM_FILES = $(shell find src -iname *.elm) DOCKER_IMG := elm-env DOCKER_RUN_CURRENT_USER := docker run --user=$(shell id -u $(USER)):$(shell id -g $(USER)) DOCKER_CMD := $(DOCKER_RUN_CURRENT_USER) --rm -t -v $(PWD):/app -w /app -e "ELM_HOME=/app/.elm" $(DOCKER_IMG) # If JUNIT_DIR is set, the tests are executed with the JUnit reporter and the result is stored in the given directory. JUNIT_DIR ?= ifeq ($(NO_DOCKER), true) DOCKER_CMD= endif all: script.js test elm-env: @(if [ "$(NO_DOCKER)" != "true" ] ; then \ echo ">> building elm-env docker image"; \ docker build --platform=linux/amd64 -t $(DOCKER_IMG) ../. > /dev/null; \ fi; ) format: elm-env $(ELM_FILES) @echo ">> format front-end code" @$(DOCKER_CMD) elm-format --yes $(ELM_FILES) review: src/Data elm-env @$(DOCKER_CMD) elm-review --fix test: src/Data elm-env @$(DOCKER_CMD) rm -rf elm-stuff/generated-code @$(DOCKER_CMD) elm-format $(ELM_FILES) --validate @$(DOCKER_CMD) elm-review ifneq ($(JUNIT_DIR),) mkdir -p $(JUNIT_DIR) @$(DOCKER_CMD) elm-test --report=junit | tee $(JUNIT_DIR)/junit.xml else @$(DOCKER_CMD) elm-test endif dev-server: elm reactor # macOS requires mktemp template to be at the end of the filename, # however --output flag for elm make must end in .js or .html. script.js: export TEMPFILE := "$(shell mktemp ./elm-XXXXXXXXXX)" script.js: export TEMPFILE_JS := "$(TEMPFILE).js" script.js: src/Data elm-env format $(ELM_FILES) @echo ">> building script.js" @$(DOCKER_CMD) rm -rf elm-stuff @$(DOCKER_CMD) elm make src/Main.elm --optimize --output $(TEMPFILE_JS) @$(DOCKER_CMD) uglifyjs $(TEMPFILE_JS) --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' --mangle --output $(@) @rm -rf $(TEMPFILE_JS) @rm -rf $(TEMPFILE) src/Data: export TEMPOPENAPI := $(shell mktemp -d ./openapi-XXXXXXXXXX) src/Data: ../../api/v2/openapi.yaml -rm -r src/Data $(DOCKER_RUN_CURRENT_USER) --rm -v ${PWD}/../..:/local openapitools/openapi-generator-cli:v3.3.4 generate \ -i /local/api/v2/openapi.yaml \ -g elm \ -o /local/ui/app/$(TEMPOPENAPI) # We only want data directory & DateTime package. cp -r $(TEMPOPENAPI)/src/Data src/Data cp -r $(TEMPOPENAPI)/src/DateTime.elm src/DateTime.elm rm -rf $(TEMPOPENAPI) clean: - @rm -rf script.js elm-stuff src/Data src/DateTime.elm openapi-* elm-* - @if [ ! -z "$(docker images -q $(DOCKER_IMG))" ]; then \ docker rmi $(DOCKER_IMG); fi ================================================ FILE: ui/app/README.md ================================================ # Alertmanager UI This is a re-write of the Alertmanager UI in [elm-lang](http://elm-lang.org/). ## Usage ### Filtering on the alerts page By default, the alerts page only shows active (not silenced) alerts. Adding a query string containing the following will additionally show silenced alerts. ``` http://alertmanager/#/alerts?silenced=true ``` In order to show _only_ silenced alerts, update the query string to hide active alerts. ``` http://alertmanager/#/alerts?silenced=true&active=false ``` The alerts page can also be filtered by the receivers for a page. Receivers are configured in Alertmanager's yaml configuration file. ``` http://alertmanager/#/alerts?receiver=backend ``` Filtering based on label matchers is available. They can easily be added and modified through the UI. ``` http://alertmanager/#/alerts?filter=%7Bseverity%3D%22warning%22%2C%20owner%3D%22backend%22%7D ``` These filters can be used in conjunction. ### Filtering on the silences page Filtering based on label matchers is available. They can easily be added and modified through the UI. ``` http://alertmanager/#/silences?filter=%7Bseverity%3D%22warning%22%2C%20owner%3D%22backend%22%7D ``` ### Note on filtering via label matchers Filtering via label matchers follows the same syntax and semantics as Prometheus. A properly formatted filter is a set of label matchers joined by accepted matching operators, surrounded by curly braces: ``` {foo="bar", baz=~"quu.*"} ``` Operators include: - `=` - `!=` - `=~` - `!~` See the official documentation for additional information: https://prometheus.io/docs/querying/basics/#instant-vector-selectors ================================================ FILE: ui/app/elm.json ================================================ { "type": "application", "source-directories": [ "src" ], "elm-version": "0.19.1", "dependencies": { "direct": { "elm/browser": "1.0.0", "elm/core": "1.0.0", "elm/html": "1.0.0", "elm/http": "1.0.0", "elm/json": "1.0.0", "elm/parser": "1.1.0", "elm/regex": "1.0.0", "elm/time": "1.0.0", "elm/url": "1.0.0", "rtfeldman/elm-iso8601-date-strings": "1.1.2", "NoRedInk/elm-json-decode-pipeline": "1.0.0", "justinmimbs/time-extra": "1.1.0" }, "indirect": { "elm/virtual-dom": "1.0.0", "elm/random": "1.0.0", "justinmimbs/date": "3.2.0" } }, "test-dependencies": { "direct": { "elm-explorations/test": "1.0.0" }, "indirect": {} } } ================================================ FILE: ui/app/index.html ================================================ Alertmanager ================================================ FILE: ui/app/lib/elm-datepicker/css/elm-datepicker.css ================================================ .cursor-pointer {cursor: pointer;} .month { height: 270px; } .calendar_ .date-container { } .calendar_ .weekheader { margin-top: 5px; } .calendar_ .date { color: #C0C0C0; cursor: pointer; height: 30px; width: 40px; display: inline-flex; justify-content: center; align-items: center; font-size: .75rem; background-color: #fff; } .calendar_ .date.thismonth { color: #22292f; } .calendar_ .date.front { background-color: rgba(0,0,0,0); } .calendar_ .date.back { margin-bottom: 5px; } .calendar_ .date.front.mouseover { background-color: #EEEEEE; border-radius: 50%; } .calendar_ .date.front.start { background-color: #b0c4de; border-radius: 50%; } .calendar_ .date.front.end { background-color: #b0c4de; border-radius: 50%; } .calendar_ .date.back.start { background-color: #b0c4de; border-top-left-radius: 50%; border-bottom-left-radius: 50%; } .calendar_ .date.back.end { background-color: #b0c4de; border-top-right-radius: 50%; border-bottom-right-radius: 50%; } .calendar_ .date.back.between { background-color: #b0c4de; } .timepicker { height:60px; width:80%; margin-top: 10px; } .timepicker .subject { padding:10px; width:20%; vertical-align:middle; } .timepicker .hour { width:10%; } .timepicker .minute { width:10%; } .timepicker .view { width:100%; height:50%; text-align:center; border: 0px none; } .timepicker .up-button { width:100%; height:25%; border: 0px none; } .timepicker .down-button { width:100%; height:25%; border: 0px none; } .timepicker .colon { width: 5%; } .timepicker .timeview { width:50%; } .month-header { width:70%; margin: 0 auto; } .month-header .prev-month { width:20%; } .month-header .month-text { width:60%; font-weight: bold; font-size: 12px; text-align: center; } .month-header .next-month { width:20%; } .d-flex-center { display:flex; align-items: center; justify-content: space-around; } ================================================ FILE: ui/app/lib/font-awesome-4.7.0/css/font-awesome.css ================================================ /*! * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) */ /* FONT PATH * -------------------------- */ @font-face { font-family: 'FontAwesome'; src: url('../fonts/fontawesome-webfont.eot?v=4.7.0'); src: url('../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('../fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('../fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), url('../fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), url('../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg'); font-weight: normal; font-style: normal; } .fa { display: inline-block; font: normal normal normal 14px/1 FontAwesome; font-size: inherit; text-rendering: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } /* makes the font 33% larger relative to the icon container */ .fa-lg { font-size: 1.33333333em; line-height: 0.75em; vertical-align: -15%; } .fa-2x { font-size: 2em; } .fa-3x { font-size: 3em; } .fa-4x { font-size: 4em; } .fa-5x { font-size: 5em; } .fa-fw { width: 1.28571429em; text-align: center; } .fa-ul { padding-left: 0; margin-left: 2.14285714em; list-style-type: none; } .fa-ul > li { position: relative; } .fa-li { position: absolute; left: -2.14285714em; width: 2.14285714em; top: 0.14285714em; text-align: center; } .fa-li.fa-lg { left: -1.85714286em; } .fa-border { padding: .2em .25em .15em; border: solid 0.08em #eeeeee; border-radius: .1em; } .fa-pull-left { float: left; } .fa-pull-right { float: right; } .fa.fa-pull-left { margin-right: .3em; } .fa.fa-pull-right { margin-left: .3em; } /* Deprecated as of 4.4.0 */ .pull-right { float: right; } .pull-left { float: left; } .fa.pull-left { margin-right: .3em; } .fa.pull-right { margin-left: .3em; } .fa-spin { -webkit-animation: fa-spin 2s infinite linear; animation: fa-spin 2s infinite linear; } .fa-pulse { -webkit-animation: fa-spin 1s infinite steps(8); animation: fa-spin 1s infinite steps(8); } @-webkit-keyframes fa-spin { 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } 100% { -webkit-transform: rotate(359deg); transform: rotate(359deg); } } @keyframes fa-spin { 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } 100% { -webkit-transform: rotate(359deg); transform: rotate(359deg); } } .fa-rotate-90 { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; -webkit-transform: rotate(90deg); -ms-transform: rotate(90deg); transform: rotate(90deg); } .fa-rotate-180 { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; -webkit-transform: rotate(180deg); -ms-transform: rotate(180deg); transform: rotate(180deg); } .fa-rotate-270 { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; -webkit-transform: rotate(270deg); -ms-transform: rotate(270deg); transform: rotate(270deg); } .fa-flip-horizontal { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)"; -webkit-transform: scale(-1, 1); -ms-transform: scale(-1, 1); transform: scale(-1, 1); } .fa-flip-vertical { -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; -webkit-transform: scale(1, -1); -ms-transform: scale(1, -1); transform: scale(1, -1); } :root .fa-rotate-90, :root .fa-rotate-180, :root .fa-rotate-270, :root .fa-flip-horizontal, :root .fa-flip-vertical { filter: none; } .fa-stack { position: relative; display: inline-block; width: 2em; height: 2em; line-height: 2em; vertical-align: middle; } .fa-stack-1x, .fa-stack-2x { position: absolute; left: 0; width: 100%; text-align: center; } .fa-stack-1x { line-height: inherit; } .fa-stack-2x { font-size: 2em; } .fa-inverse { color: #ffffff; } /* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen readers do not read off random characters that represent icons */ .fa-glass:before { content: "\f000"; } .fa-music:before { content: "\f001"; } .fa-search:before { content: "\f002"; } .fa-envelope-o:before { content: "\f003"; } .fa-heart:before { content: "\f004"; } .fa-star:before { content: "\f005"; } .fa-star-o:before { content: "\f006"; } .fa-user:before { content: "\f007"; } .fa-film:before { content: "\f008"; } .fa-th-large:before { content: "\f009"; } .fa-th:before { content: "\f00a"; } .fa-th-list:before { content: "\f00b"; } .fa-check:before { content: "\f00c"; } .fa-remove:before, .fa-close:before, .fa-times:before { content: "\f00d"; } .fa-search-plus:before { content: "\f00e"; } .fa-search-minus:before { content: "\f010"; } .fa-power-off:before { content: "\f011"; } .fa-signal:before { content: "\f012"; } .fa-gear:before, .fa-cog:before { content: "\f013"; } .fa-trash-o:before { content: "\f014"; } .fa-home:before { content: "\f015"; } .fa-file-o:before { content: "\f016"; } .fa-clock-o:before { content: "\f017"; } .fa-road:before { content: "\f018"; } .fa-download:before { content: "\f019"; } .fa-arrow-circle-o-down:before { content: "\f01a"; } .fa-arrow-circle-o-up:before { content: "\f01b"; } .fa-inbox:before { content: "\f01c"; } .fa-play-circle-o:before { content: "\f01d"; } .fa-rotate-right:before, .fa-repeat:before { content: "\f01e"; } .fa-refresh:before { content: "\f021"; } .fa-list-alt:before { content: "\f022"; } .fa-lock:before { content: "\f023"; } .fa-flag:before { content: "\f024"; } .fa-headphones:before { content: "\f025"; } .fa-volume-off:before { content: "\f026"; } .fa-volume-down:before { content: "\f027"; } .fa-volume-up:before { content: "\f028"; } .fa-qrcode:before { content: "\f029"; } .fa-barcode:before { content: "\f02a"; } .fa-tag:before { content: "\f02b"; } .fa-tags:before { content: "\f02c"; } .fa-book:before { content: "\f02d"; } .fa-bookmark:before { content: "\f02e"; } .fa-print:before { content: "\f02f"; } .fa-camera:before { content: "\f030"; } .fa-font:before { content: "\f031"; } .fa-bold:before { content: "\f032"; } .fa-italic:before { content: "\f033"; } .fa-text-height:before { content: "\f034"; } .fa-text-width:before { content: "\f035"; } .fa-align-left:before { content: "\f036"; } .fa-align-center:before { content: "\f037"; } .fa-align-right:before { content: "\f038"; } .fa-align-justify:before { content: "\f039"; } .fa-list:before { content: "\f03a"; } .fa-dedent:before, .fa-outdent:before { content: "\f03b"; } .fa-indent:before { content: "\f03c"; } .fa-video-camera:before { content: "\f03d"; } .fa-photo:before, .fa-image:before, .fa-picture-o:before { content: "\f03e"; } .fa-pencil:before { content: "\f040"; } .fa-map-marker:before { content: "\f041"; } .fa-adjust:before { content: "\f042"; } .fa-tint:before { content: "\f043"; } .fa-edit:before, .fa-pencil-square-o:before { content: "\f044"; } .fa-share-square-o:before { content: "\f045"; } .fa-check-square-o:before { content: "\f046"; } .fa-arrows:before { content: "\f047"; } .fa-step-backward:before { content: "\f048"; } .fa-fast-backward:before { content: "\f049"; } .fa-backward:before { content: "\f04a"; } .fa-play:before { content: "\f04b"; } .fa-pause:before { content: "\f04c"; } .fa-stop:before { content: "\f04d"; } .fa-forward:before { content: "\f04e"; } .fa-fast-forward:before { content: "\f050"; } .fa-step-forward:before { content: "\f051"; } .fa-eject:before { content: "\f052"; } .fa-chevron-left:before { content: "\f053"; } .fa-chevron-right:before { content: "\f054"; } .fa-plus-circle:before { content: "\f055"; } .fa-minus-circle:before { content: "\f056"; } .fa-times-circle:before { content: "\f057"; } .fa-check-circle:before { content: "\f058"; } .fa-question-circle:before { content: "\f059"; } .fa-info-circle:before { content: "\f05a"; } .fa-crosshairs:before { content: "\f05b"; } .fa-times-circle-o:before { content: "\f05c"; } .fa-check-circle-o:before { content: "\f05d"; } .fa-ban:before { content: "\f05e"; } .fa-arrow-left:before { content: "\f060"; } .fa-arrow-right:before { content: "\f061"; } .fa-arrow-up:before { content: "\f062"; } .fa-arrow-down:before { content: "\f063"; } .fa-mail-forward:before, .fa-share:before { content: "\f064"; } .fa-expand:before { content: "\f065"; } .fa-compress:before { content: "\f066"; } .fa-plus:before { content: "\f067"; } .fa-minus:before { content: "\f068"; } .fa-asterisk:before { content: "\f069"; } .fa-exclamation-circle:before { content: "\f06a"; } .fa-gift:before { content: "\f06b"; } .fa-leaf:before { content: "\f06c"; } .fa-fire:before { content: "\f06d"; } .fa-eye:before { content: "\f06e"; } .fa-eye-slash:before { content: "\f070"; } .fa-warning:before, .fa-exclamation-triangle:before { content: "\f071"; } .fa-plane:before { content: "\f072"; } .fa-calendar:before { content: "\f073"; } .fa-random:before { content: "\f074"; } .fa-comment:before { content: "\f075"; } .fa-magnet:before { content: "\f076"; } .fa-chevron-up:before { content: "\f077"; } .fa-chevron-down:before { content: "\f078"; } .fa-retweet:before { content: "\f079"; } .fa-shopping-cart:before { content: "\f07a"; } .fa-folder:before { content: "\f07b"; } .fa-folder-open:before { content: "\f07c"; } .fa-arrows-v:before { content: "\f07d"; } .fa-arrows-h:before { content: "\f07e"; } .fa-bar-chart-o:before, .fa-bar-chart:before { content: "\f080"; } .fa-twitter-square:before { content: "\f081"; } .fa-facebook-square:before { content: "\f082"; } .fa-camera-retro:before { content: "\f083"; } .fa-key:before { content: "\f084"; } .fa-gears:before, .fa-cogs:before { content: "\f085"; } .fa-comments:before { content: "\f086"; } .fa-thumbs-o-up:before { content: "\f087"; } .fa-thumbs-o-down:before { content: "\f088"; } .fa-star-half:before { content: "\f089"; } .fa-heart-o:before { content: "\f08a"; } .fa-sign-out:before { content: "\f08b"; } .fa-linkedin-square:before { content: "\f08c"; } .fa-thumb-tack:before { content: "\f08d"; } .fa-external-link:before { content: "\f08e"; } .fa-sign-in:before { content: "\f090"; } .fa-trophy:before { content: "\f091"; } .fa-github-square:before { content: "\f092"; } .fa-upload:before { content: "\f093"; } .fa-lemon-o:before { content: "\f094"; } .fa-phone:before { content: "\f095"; } .fa-square-o:before { content: "\f096"; } .fa-bookmark-o:before { content: "\f097"; } .fa-phone-square:before { content: "\f098"; } .fa-twitter:before { content: "\f099"; } .fa-facebook-f:before, .fa-facebook:before { content: "\f09a"; } .fa-github:before { content: "\f09b"; } .fa-unlock:before { content: "\f09c"; } .fa-credit-card:before { content: "\f09d"; } .fa-feed:before, .fa-rss:before { content: "\f09e"; } .fa-hdd-o:before { content: "\f0a0"; } .fa-bullhorn:before { content: "\f0a1"; } .fa-bell:before { content: "\f0f3"; } .fa-certificate:before { content: "\f0a3"; } .fa-hand-o-right:before { content: "\f0a4"; } .fa-hand-o-left:before { content: "\f0a5"; } .fa-hand-o-up:before { content: "\f0a6"; } .fa-hand-o-down:before { content: "\f0a7"; } .fa-arrow-circle-left:before { content: "\f0a8"; } .fa-arrow-circle-right:before { content: "\f0a9"; } .fa-arrow-circle-up:before { content: "\f0aa"; } .fa-arrow-circle-down:before { content: "\f0ab"; } .fa-globe:before { content: "\f0ac"; } .fa-wrench:before { content: "\f0ad"; } .fa-tasks:before { content: "\f0ae"; } .fa-filter:before { content: "\f0b0"; } .fa-briefcase:before { content: "\f0b1"; } .fa-arrows-alt:before { content: "\f0b2"; } .fa-group:before, .fa-users:before { content: "\f0c0"; } .fa-chain:before, .fa-link:before { content: "\f0c1"; } .fa-cloud:before { content: "\f0c2"; } .fa-flask:before { content: "\f0c3"; } .fa-cut:before, .fa-scissors:before { content: "\f0c4"; } .fa-copy:before, .fa-files-o:before { content: "\f0c5"; } .fa-paperclip:before { content: "\f0c6"; } .fa-save:before, .fa-floppy-o:before { content: "\f0c7"; } .fa-square:before { content: "\f0c8"; } .fa-navicon:before, .fa-reorder:before, .fa-bars:before { content: "\f0c9"; } .fa-list-ul:before { content: "\f0ca"; } .fa-list-ol:before { content: "\f0cb"; } .fa-strikethrough:before { content: "\f0cc"; } .fa-underline:before { content: "\f0cd"; } .fa-table:before { content: "\f0ce"; } .fa-magic:before { content: "\f0d0"; } .fa-truck:before { content: "\f0d1"; } .fa-pinterest:before { content: "\f0d2"; } .fa-pinterest-square:before { content: "\f0d3"; } .fa-google-plus-square:before { content: "\f0d4"; } .fa-google-plus:before { content: "\f0d5"; } .fa-money:before { content: "\f0d6"; } .fa-caret-down:before { content: "\f0d7"; } .fa-caret-up:before { content: "\f0d8"; } .fa-caret-left:before { content: "\f0d9"; } .fa-caret-right:before { content: "\f0da"; } .fa-columns:before { content: "\f0db"; } .fa-unsorted:before, .fa-sort:before { content: "\f0dc"; } .fa-sort-down:before, .fa-sort-desc:before { content: "\f0dd"; } .fa-sort-up:before, .fa-sort-asc:before { content: "\f0de"; } .fa-envelope:before { content: "\f0e0"; } .fa-linkedin:before { content: "\f0e1"; } .fa-rotate-left:before, .fa-undo:before { content: "\f0e2"; } .fa-legal:before, .fa-gavel:before { content: "\f0e3"; } .fa-dashboard:before, .fa-tachometer:before { content: "\f0e4"; } .fa-comment-o:before { content: "\f0e5"; } .fa-comments-o:before { content: "\f0e6"; } .fa-flash:before, .fa-bolt:before { content: "\f0e7"; } .fa-sitemap:before { content: "\f0e8"; } .fa-umbrella:before { content: "\f0e9"; } .fa-paste:before, .fa-clipboard:before { content: "\f0ea"; } .fa-lightbulb-o:before { content: "\f0eb"; } .fa-exchange:before { content: "\f0ec"; } .fa-cloud-download:before { content: "\f0ed"; } .fa-cloud-upload:before { content: "\f0ee"; } .fa-user-md:before { content: "\f0f0"; } .fa-stethoscope:before { content: "\f0f1"; } .fa-suitcase:before { content: "\f0f2"; } .fa-bell-o:before { content: "\f0a2"; } .fa-coffee:before { content: "\f0f4"; } .fa-cutlery:before { content: "\f0f5"; } .fa-file-text-o:before { content: "\f0f6"; } .fa-building-o:before { content: "\f0f7"; } .fa-hospital-o:before { content: "\f0f8"; } .fa-ambulance:before { content: "\f0f9"; } .fa-medkit:before { content: "\f0fa"; } .fa-fighter-jet:before { content: "\f0fb"; } .fa-beer:before { content: "\f0fc"; } .fa-h-square:before { content: "\f0fd"; } .fa-plus-square:before { content: "\f0fe"; } .fa-angle-double-left:before { content: "\f100"; } .fa-angle-double-right:before { content: "\f101"; } .fa-angle-double-up:before { content: "\f102"; } .fa-angle-double-down:before { content: "\f103"; } .fa-angle-left:before { content: "\f104"; } .fa-angle-right:before { content: "\f105"; } .fa-angle-up:before { content: "\f106"; } .fa-angle-down:before { content: "\f107"; } .fa-desktop:before { content: "\f108"; } .fa-laptop:before { content: "\f109"; } .fa-tablet:before { content: "\f10a"; } .fa-mobile-phone:before, .fa-mobile:before { content: "\f10b"; } .fa-circle-o:before { content: "\f10c"; } .fa-quote-left:before { content: "\f10d"; } .fa-quote-right:before { content: "\f10e"; } .fa-spinner:before { content: "\f110"; } .fa-circle:before { content: "\f111"; } .fa-mail-reply:before, .fa-reply:before { content: "\f112"; } .fa-github-alt:before { content: "\f113"; } .fa-folder-o:before { content: "\f114"; } .fa-folder-open-o:before { content: "\f115"; } .fa-smile-o:before { content: "\f118"; } .fa-frown-o:before { content: "\f119"; } .fa-meh-o:before { content: "\f11a"; } .fa-gamepad:before { content: "\f11b"; } .fa-keyboard-o:before { content: "\f11c"; } .fa-flag-o:before { content: "\f11d"; } .fa-flag-checkered:before { content: "\f11e"; } .fa-terminal:before { content: "\f120"; } .fa-code:before { content: "\f121"; } .fa-mail-reply-all:before, .fa-reply-all:before { content: "\f122"; } .fa-star-half-empty:before, .fa-star-half-full:before, .fa-star-half-o:before { content: "\f123"; } .fa-location-arrow:before { content: "\f124"; } .fa-crop:before { content: "\f125"; } .fa-code-fork:before { content: "\f126"; } .fa-unlink:before, .fa-chain-broken:before { content: "\f127"; } .fa-question:before { content: "\f128"; } .fa-info:before { content: "\f129"; } .fa-exclamation:before { content: "\f12a"; } .fa-superscript:before { content: "\f12b"; } .fa-subscript:before { content: "\f12c"; } .fa-eraser:before { content: "\f12d"; } .fa-puzzle-piece:before { content: "\f12e"; } .fa-microphone:before { content: "\f130"; } .fa-microphone-slash:before { content: "\f131"; } .fa-shield:before { content: "\f132"; } .fa-calendar-o:before { content: "\f133"; } .fa-fire-extinguisher:before { content: "\f134"; } .fa-rocket:before { content: "\f135"; } .fa-maxcdn:before { content: "\f136"; } .fa-chevron-circle-left:before { content: "\f137"; } .fa-chevron-circle-right:before { content: "\f138"; } .fa-chevron-circle-up:before { content: "\f139"; } .fa-chevron-circle-down:before { content: "\f13a"; } .fa-html5:before { content: "\f13b"; } .fa-css3:before { content: "\f13c"; } .fa-anchor:before { content: "\f13d"; } .fa-unlock-alt:before { content: "\f13e"; } .fa-bullseye:before { content: "\f140"; } .fa-ellipsis-h:before { content: "\f141"; } .fa-ellipsis-v:before { content: "\f142"; } .fa-rss-square:before { content: "\f143"; } .fa-play-circle:before { content: "\f144"; } .fa-ticket:before { content: "\f145"; } .fa-minus-square:before { content: "\f146"; } .fa-minus-square-o:before { content: "\f147"; } .fa-level-up:before { content: "\f148"; } .fa-level-down:before { content: "\f149"; } .fa-check-square:before { content: "\f14a"; } .fa-pencil-square:before { content: "\f14b"; } .fa-external-link-square:before { content: "\f14c"; } .fa-share-square:before { content: "\f14d"; } .fa-compass:before { content: "\f14e"; } .fa-toggle-down:before, .fa-caret-square-o-down:before { content: "\f150"; } .fa-toggle-up:before, .fa-caret-square-o-up:before { content: "\f151"; } .fa-toggle-right:before, .fa-caret-square-o-right:before { content: "\f152"; } .fa-euro:before, .fa-eur:before { content: "\f153"; } .fa-gbp:before { content: "\f154"; } .fa-dollar:before, .fa-usd:before { content: "\f155"; } .fa-rupee:before, .fa-inr:before { content: "\f156"; } .fa-cny:before, .fa-rmb:before, .fa-yen:before, .fa-jpy:before { content: "\f157"; } .fa-ruble:before, .fa-rouble:before, .fa-rub:before { content: "\f158"; } .fa-won:before, .fa-krw:before { content: "\f159"; } .fa-bitcoin:before, .fa-btc:before { content: "\f15a"; } .fa-file:before { content: "\f15b"; } .fa-file-text:before { content: "\f15c"; } .fa-sort-alpha-asc:before { content: "\f15d"; } .fa-sort-alpha-desc:before { content: "\f15e"; } .fa-sort-amount-asc:before { content: "\f160"; } .fa-sort-amount-desc:before { content: "\f161"; } .fa-sort-numeric-asc:before { content: "\f162"; } .fa-sort-numeric-desc:before { content: "\f163"; } .fa-thumbs-up:before { content: "\f164"; } .fa-thumbs-down:before { content: "\f165"; } .fa-youtube-square:before { content: "\f166"; } .fa-youtube:before { content: "\f167"; } .fa-xing:before { content: "\f168"; } .fa-xing-square:before { content: "\f169"; } .fa-youtube-play:before { content: "\f16a"; } .fa-dropbox:before { content: "\f16b"; } .fa-stack-overflow:before { content: "\f16c"; } .fa-instagram:before { content: "\f16d"; } .fa-flickr:before { content: "\f16e"; } .fa-adn:before { content: "\f170"; } .fa-bitbucket:before { content: "\f171"; } .fa-bitbucket-square:before { content: "\f172"; } .fa-tumblr:before { content: "\f173"; } .fa-tumblr-square:before { content: "\f174"; } .fa-long-arrow-down:before { content: "\f175"; } .fa-long-arrow-up:before { content: "\f176"; } .fa-long-arrow-left:before { content: "\f177"; } .fa-long-arrow-right:before { content: "\f178"; } .fa-apple:before { content: "\f179"; } .fa-windows:before { content: "\f17a"; } .fa-android:before { content: "\f17b"; } .fa-linux:before { content: "\f17c"; } .fa-dribbble:before { content: "\f17d"; } .fa-skype:before { content: "\f17e"; } .fa-foursquare:before { content: "\f180"; } .fa-trello:before { content: "\f181"; } .fa-female:before { content: "\f182"; } .fa-male:before { content: "\f183"; } .fa-gittip:before, .fa-gratipay:before { content: "\f184"; } .fa-sun-o:before { content: "\f185"; } .fa-moon-o:before { content: "\f186"; } .fa-archive:before { content: "\f187"; } .fa-bug:before { content: "\f188"; } .fa-vk:before { content: "\f189"; } .fa-weibo:before { content: "\f18a"; } .fa-renren:before { content: "\f18b"; } .fa-pagelines:before { content: "\f18c"; } .fa-stack-exchange:before { content: "\f18d"; } .fa-arrow-circle-o-right:before { content: "\f18e"; } .fa-arrow-circle-o-left:before { content: "\f190"; } .fa-toggle-left:before, .fa-caret-square-o-left:before { content: "\f191"; } .fa-dot-circle-o:before { content: "\f192"; } .fa-wheelchair:before { content: "\f193"; } .fa-vimeo-square:before { content: "\f194"; } .fa-turkish-lira:before, .fa-try:before { content: "\f195"; } .fa-plus-square-o:before { content: "\f196"; } .fa-space-shuttle:before { content: "\f197"; } .fa-slack:before { content: "\f198"; } .fa-envelope-square:before { content: "\f199"; } .fa-wordpress:before { content: "\f19a"; } .fa-openid:before { content: "\f19b"; } .fa-institution:before, .fa-bank:before, .fa-university:before { content: "\f19c"; } .fa-mortar-board:before, .fa-graduation-cap:before { content: "\f19d"; } .fa-yahoo:before { content: "\f19e"; } .fa-google:before { content: "\f1a0"; } .fa-reddit:before { content: "\f1a1"; } .fa-reddit-square:before { content: "\f1a2"; } .fa-stumbleupon-circle:before { content: "\f1a3"; } .fa-stumbleupon:before { content: "\f1a4"; } .fa-delicious:before { content: "\f1a5"; } .fa-digg:before { content: "\f1a6"; } .fa-pied-piper-pp:before { content: "\f1a7"; } .fa-pied-piper-alt:before { content: "\f1a8"; } .fa-drupal:before { content: "\f1a9"; } .fa-joomla:before { content: "\f1aa"; } .fa-language:before { content: "\f1ab"; } .fa-fax:before { content: "\f1ac"; } .fa-building:before { content: "\f1ad"; } .fa-child:before { content: "\f1ae"; } .fa-paw:before { content: "\f1b0"; } .fa-spoon:before { content: "\f1b1"; } .fa-cube:before { content: "\f1b2"; } .fa-cubes:before { content: "\f1b3"; } .fa-behance:before { content: "\f1b4"; } .fa-behance-square:before { content: "\f1b5"; } .fa-steam:before { content: "\f1b6"; } .fa-steam-square:before { content: "\f1b7"; } .fa-recycle:before { content: "\f1b8"; } .fa-automobile:before, .fa-car:before { content: "\f1b9"; } .fa-cab:before, .fa-taxi:before { content: "\f1ba"; } .fa-tree:before { content: "\f1bb"; } .fa-spotify:before { content: "\f1bc"; } .fa-deviantart:before { content: "\f1bd"; } .fa-soundcloud:before { content: "\f1be"; } .fa-database:before { content: "\f1c0"; } .fa-file-pdf-o:before { content: "\f1c1"; } .fa-file-word-o:before { content: "\f1c2"; } .fa-file-excel-o:before { content: "\f1c3"; } .fa-file-powerpoint-o:before { content: "\f1c4"; } .fa-file-photo-o:before, .fa-file-picture-o:before, .fa-file-image-o:before { content: "\f1c5"; } .fa-file-zip-o:before, .fa-file-archive-o:before { content: "\f1c6"; } .fa-file-sound-o:before, .fa-file-audio-o:before { content: "\f1c7"; } .fa-file-movie-o:before, .fa-file-video-o:before { content: "\f1c8"; } .fa-file-code-o:before { content: "\f1c9"; } .fa-vine:before { content: "\f1ca"; } .fa-codepen:before { content: "\f1cb"; } .fa-jsfiddle:before { content: "\f1cc"; } .fa-life-bouy:before, .fa-life-buoy:before, .fa-life-saver:before, .fa-support:before, .fa-life-ring:before { content: "\f1cd"; } .fa-circle-o-notch:before { content: "\f1ce"; } .fa-ra:before, .fa-resistance:before, .fa-rebel:before { content: "\f1d0"; } .fa-ge:before, .fa-empire:before { content: "\f1d1"; } .fa-git-square:before { content: "\f1d2"; } .fa-git:before { content: "\f1d3"; } .fa-y-combinator-square:before, .fa-yc-square:before, .fa-hacker-news:before { content: "\f1d4"; } .fa-tencent-weibo:before { content: "\f1d5"; } .fa-qq:before { content: "\f1d6"; } .fa-wechat:before, .fa-weixin:before { content: "\f1d7"; } .fa-send:before, .fa-paper-plane:before { content: "\f1d8"; } .fa-send-o:before, .fa-paper-plane-o:before { content: "\f1d9"; } .fa-history:before { content: "\f1da"; } .fa-circle-thin:before { content: "\f1db"; } .fa-header:before { content: "\f1dc"; } .fa-paragraph:before { content: "\f1dd"; } .fa-sliders:before { content: "\f1de"; } .fa-share-alt:before { content: "\f1e0"; } .fa-share-alt-square:before { content: "\f1e1"; } .fa-bomb:before { content: "\f1e2"; } .fa-soccer-ball-o:before, .fa-futbol-o:before { content: "\f1e3"; } .fa-tty:before { content: "\f1e4"; } .fa-binoculars:before { content: "\f1e5"; } .fa-plug:before { content: "\f1e6"; } .fa-slideshare:before { content: "\f1e7"; } .fa-twitch:before { content: "\f1e8"; } .fa-yelp:before { content: "\f1e9"; } .fa-newspaper-o:before { content: "\f1ea"; } .fa-wifi:before { content: "\f1eb"; } .fa-calculator:before { content: "\f1ec"; } .fa-paypal:before { content: "\f1ed"; } .fa-google-wallet:before { content: "\f1ee"; } .fa-cc-visa:before { content: "\f1f0"; } .fa-cc-mastercard:before { content: "\f1f1"; } .fa-cc-discover:before { content: "\f1f2"; } .fa-cc-amex:before { content: "\f1f3"; } .fa-cc-paypal:before { content: "\f1f4"; } .fa-cc-stripe:before { content: "\f1f5"; } .fa-bell-slash:before { content: "\f1f6"; } .fa-bell-slash-o:before { content: "\f1f7"; } .fa-trash:before { content: "\f1f8"; } .fa-copyright:before { content: "\f1f9"; } .fa-at:before { content: "\f1fa"; } .fa-eyedropper:before { content: "\f1fb"; } .fa-paint-brush:before { content: "\f1fc"; } .fa-birthday-cake:before { content: "\f1fd"; } .fa-area-chart:before { content: "\f1fe"; } .fa-pie-chart:before { content: "\f200"; } .fa-line-chart:before { content: "\f201"; } .fa-lastfm:before { content: "\f202"; } .fa-lastfm-square:before { content: "\f203"; } .fa-toggle-off:before { content: "\f204"; } .fa-toggle-on:before { content: "\f205"; } .fa-bicycle:before { content: "\f206"; } .fa-bus:before { content: "\f207"; } .fa-ioxhost:before { content: "\f208"; } .fa-angellist:before { content: "\f209"; } .fa-cc:before { content: "\f20a"; } .fa-shekel:before, .fa-sheqel:before, .fa-ils:before { content: "\f20b"; } .fa-meanpath:before { content: "\f20c"; } .fa-buysellads:before { content: "\f20d"; } .fa-connectdevelop:before { content: "\f20e"; } .fa-dashcube:before { content: "\f210"; } .fa-forumbee:before { content: "\f211"; } .fa-leanpub:before { content: "\f212"; } .fa-sellsy:before { content: "\f213"; } .fa-shirtsinbulk:before { content: "\f214"; } .fa-simplybuilt:before { content: "\f215"; } .fa-skyatlas:before { content: "\f216"; } .fa-cart-plus:before { content: "\f217"; } .fa-cart-arrow-down:before { content: "\f218"; } .fa-diamond:before { content: "\f219"; } .fa-ship:before { content: "\f21a"; } .fa-user-secret:before { content: "\f21b"; } .fa-motorcycle:before { content: "\f21c"; } .fa-street-view:before { content: "\f21d"; } .fa-heartbeat:before { content: "\f21e"; } .fa-venus:before { content: "\f221"; } .fa-mars:before { content: "\f222"; } .fa-mercury:before { content: "\f223"; } .fa-intersex:before, .fa-transgender:before { content: "\f224"; } .fa-transgender-alt:before { content: "\f225"; } .fa-venus-double:before { content: "\f226"; } .fa-mars-double:before { content: "\f227"; } .fa-venus-mars:before { content: "\f228"; } .fa-mars-stroke:before { content: "\f229"; } .fa-mars-stroke-v:before { content: "\f22a"; } .fa-mars-stroke-h:before { content: "\f22b"; } .fa-neuter:before { content: "\f22c"; } .fa-genderless:before { content: "\f22d"; } .fa-facebook-official:before { content: "\f230"; } .fa-pinterest-p:before { content: "\f231"; } .fa-whatsapp:before { content: "\f232"; } .fa-server:before { content: "\f233"; } .fa-user-plus:before { content: "\f234"; } .fa-user-times:before { content: "\f235"; } .fa-hotel:before, .fa-bed:before { content: "\f236"; } .fa-viacoin:before { content: "\f237"; } .fa-train:before { content: "\f238"; } .fa-subway:before { content: "\f239"; } .fa-medium:before { content: "\f23a"; } .fa-yc:before, .fa-y-combinator:before { content: "\f23b"; } .fa-optin-monster:before { content: "\f23c"; } .fa-opencart:before { content: "\f23d"; } .fa-expeditedssl:before { content: "\f23e"; } .fa-battery-4:before, .fa-battery:before, .fa-battery-full:before { content: "\f240"; } .fa-battery-3:before, .fa-battery-three-quarters:before { content: "\f241"; } .fa-battery-2:before, .fa-battery-half:before { content: "\f242"; } .fa-battery-1:before, .fa-battery-quarter:before { content: "\f243"; } .fa-battery-0:before, .fa-battery-empty:before { content: "\f244"; } .fa-mouse-pointer:before { content: "\f245"; } .fa-i-cursor:before { content: "\f246"; } .fa-object-group:before { content: "\f247"; } .fa-object-ungroup:before { content: "\f248"; } .fa-sticky-note:before { content: "\f249"; } .fa-sticky-note-o:before { content: "\f24a"; } .fa-cc-jcb:before { content: "\f24b"; } .fa-cc-diners-club:before { content: "\f24c"; } .fa-clone:before { content: "\f24d"; } .fa-balance-scale:before { content: "\f24e"; } .fa-hourglass-o:before { content: "\f250"; } .fa-hourglass-1:before, .fa-hourglass-start:before { content: "\f251"; } .fa-hourglass-2:before, .fa-hourglass-half:before { content: "\f252"; } .fa-hourglass-3:before, .fa-hourglass-end:before { content: "\f253"; } .fa-hourglass:before { content: "\f254"; } .fa-hand-grab-o:before, .fa-hand-rock-o:before { content: "\f255"; } .fa-hand-stop-o:before, .fa-hand-paper-o:before { content: "\f256"; } .fa-hand-scissors-o:before { content: "\f257"; } .fa-hand-lizard-o:before { content: "\f258"; } .fa-hand-spock-o:before { content: "\f259"; } .fa-hand-pointer-o:before { content: "\f25a"; } .fa-hand-peace-o:before { content: "\f25b"; } .fa-trademark:before { content: "\f25c"; } .fa-registered:before { content: "\f25d"; } .fa-creative-commons:before { content: "\f25e"; } .fa-gg:before { content: "\f260"; } .fa-gg-circle:before { content: "\f261"; } .fa-tripadvisor:before { content: "\f262"; } .fa-odnoklassniki:before { content: "\f263"; } .fa-odnoklassniki-square:before { content: "\f264"; } .fa-get-pocket:before { content: "\f265"; } .fa-wikipedia-w:before { content: "\f266"; } .fa-safari:before { content: "\f267"; } .fa-chrome:before { content: "\f268"; } .fa-firefox:before { content: "\f269"; } .fa-opera:before { content: "\f26a"; } .fa-internet-explorer:before { content: "\f26b"; } .fa-tv:before, .fa-television:before { content: "\f26c"; } .fa-contao:before { content: "\f26d"; } .fa-500px:before { content: "\f26e"; } .fa-amazon:before { content: "\f270"; } .fa-calendar-plus-o:before { content: "\f271"; } .fa-calendar-minus-o:before { content: "\f272"; } .fa-calendar-times-o:before { content: "\f273"; } .fa-calendar-check-o:before { content: "\f274"; } .fa-industry:before { content: "\f275"; } .fa-map-pin:before { content: "\f276"; } .fa-map-signs:before { content: "\f277"; } .fa-map-o:before { content: "\f278"; } .fa-map:before { content: "\f279"; } .fa-commenting:before { content: "\f27a"; } .fa-commenting-o:before { content: "\f27b"; } .fa-houzz:before { content: "\f27c"; } .fa-vimeo:before { content: "\f27d"; } .fa-black-tie:before { content: "\f27e"; } .fa-fonticons:before { content: "\f280"; } .fa-reddit-alien:before { content: "\f281"; } .fa-edge:before { content: "\f282"; } .fa-credit-card-alt:before { content: "\f283"; } .fa-codiepie:before { content: "\f284"; } .fa-modx:before { content: "\f285"; } .fa-fort-awesome:before { content: "\f286"; } .fa-usb:before { content: "\f287"; } .fa-product-hunt:before { content: "\f288"; } .fa-mixcloud:before { content: "\f289"; } .fa-scribd:before { content: "\f28a"; } .fa-pause-circle:before { content: "\f28b"; } .fa-pause-circle-o:before { content: "\f28c"; } .fa-stop-circle:before { content: "\f28d"; } .fa-stop-circle-o:before { content: "\f28e"; } .fa-shopping-bag:before { content: "\f290"; } .fa-shopping-basket:before { content: "\f291"; } .fa-hashtag:before { content: "\f292"; } .fa-bluetooth:before { content: "\f293"; } .fa-bluetooth-b:before { content: "\f294"; } .fa-percent:before { content: "\f295"; } .fa-gitlab:before { content: "\f296"; } .fa-wpbeginner:before { content: "\f297"; } .fa-wpforms:before { content: "\f298"; } .fa-envira:before { content: "\f299"; } .fa-universal-access:before { content: "\f29a"; } .fa-wheelchair-alt:before { content: "\f29b"; } .fa-question-circle-o:before { content: "\f29c"; } .fa-blind:before { content: "\f29d"; } .fa-audio-description:before { content: "\f29e"; } .fa-volume-control-phone:before { content: "\f2a0"; } .fa-braille:before { content: "\f2a1"; } .fa-assistive-listening-systems:before { content: "\f2a2"; } .fa-asl-interpreting:before, .fa-american-sign-language-interpreting:before { content: "\f2a3"; } .fa-deafness:before, .fa-hard-of-hearing:before, .fa-deaf:before { content: "\f2a4"; } .fa-glide:before { content: "\f2a5"; } .fa-glide-g:before { content: "\f2a6"; } .fa-signing:before, .fa-sign-language:before { content: "\f2a7"; } .fa-low-vision:before { content: "\f2a8"; } .fa-viadeo:before { content: "\f2a9"; } .fa-viadeo-square:before { content: "\f2aa"; } .fa-snapchat:before { content: "\f2ab"; } .fa-snapchat-ghost:before { content: "\f2ac"; } .fa-snapchat-square:before { content: "\f2ad"; } .fa-pied-piper:before { content: "\f2ae"; } .fa-first-order:before { content: "\f2b0"; } .fa-yoast:before { content: "\f2b1"; } .fa-themeisle:before { content: "\f2b2"; } .fa-google-plus-circle:before, .fa-google-plus-official:before { content: "\f2b3"; } .fa-fa:before, .fa-font-awesome:before { content: "\f2b4"; } .fa-handshake-o:before { content: "\f2b5"; } .fa-envelope-open:before { content: "\f2b6"; } .fa-envelope-open-o:before { content: "\f2b7"; } .fa-linode:before { content: "\f2b8"; } .fa-address-book:before { content: "\f2b9"; } .fa-address-book-o:before { content: "\f2ba"; } .fa-vcard:before, .fa-address-card:before { content: "\f2bb"; } .fa-vcard-o:before, .fa-address-card-o:before { content: "\f2bc"; } .fa-user-circle:before { content: "\f2bd"; } .fa-user-circle-o:before { content: "\f2be"; } .fa-user-o:before { content: "\f2c0"; } .fa-id-badge:before { content: "\f2c1"; } .fa-drivers-license:before, .fa-id-card:before { content: "\f2c2"; } .fa-drivers-license-o:before, .fa-id-card-o:before { content: "\f2c3"; } .fa-quora:before { content: "\f2c4"; } .fa-free-code-camp:before { content: "\f2c5"; } .fa-telegram:before { content: "\f2c6"; } .fa-thermometer-4:before, .fa-thermometer:before, .fa-thermometer-full:before { content: "\f2c7"; } .fa-thermometer-3:before, .fa-thermometer-three-quarters:before { content: "\f2c8"; } .fa-thermometer-2:before, .fa-thermometer-half:before { content: "\f2c9"; } .fa-thermometer-1:before, .fa-thermometer-quarter:before { content: "\f2ca"; } .fa-thermometer-0:before, .fa-thermometer-empty:before { content: "\f2cb"; } .fa-shower:before { content: "\f2cc"; } .fa-bathtub:before, .fa-s15:before, .fa-bath:before { content: "\f2cd"; } .fa-podcast:before { content: "\f2ce"; } .fa-window-maximize:before { content: "\f2d0"; } .fa-window-minimize:before { content: "\f2d1"; } .fa-window-restore:before { content: "\f2d2"; } .fa-times-rectangle:before, .fa-window-close:before { content: "\f2d3"; } .fa-times-rectangle-o:before, .fa-window-close-o:before { content: "\f2d4"; } .fa-bandcamp:before { content: "\f2d5"; } .fa-grav:before { content: "\f2d6"; } .fa-etsy:before { content: "\f2d7"; } .fa-imdb:before { content: "\f2d8"; } .fa-ravelry:before { content: "\f2d9"; } .fa-eercast:before { content: "\f2da"; } .fa-microchip:before { content: "\f2db"; } .fa-snowflake-o:before { content: "\f2dc"; } .fa-superpowers:before { content: "\f2dd"; } .fa-wpexplorer:before { content: "\f2de"; } .fa-meetup:before { content: "\f2e0"; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); border: 0; } .sr-only-focusable:active, .sr-only-focusable:focus { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; } ================================================ FILE: ui/app/review/elm.json ================================================ { "type": "application", "source-directories": [ "src" ], "elm-version": "0.19.1", "dependencies": { "direct": { "elm/core": "1.0.5", "elm/json": "1.1.3", "elm/project-metadata-utils": "1.0.1", "jfmengels/elm-review": "2.4.1", "jfmengels/elm-review-simplify": "1.0.1", "jfmengels/elm-review-unused": "1.1.9", "stil4m/elm-syntax": "7.2.2" }, "indirect": { "elm/html": "1.0.0", "elm/parser": "1.1.0", "elm/random": "1.0.0", "elm/time": "1.0.0", "elm/virtual-dom": "1.0.2", "elm-community/list-extra": "8.3.0", "elm-explorations/test": "1.2.2", "rtfeldman/elm-hex": "1.0.0", "stil4m/structured-writer": "1.0.3" } }, "test-dependencies": { "direct": { "elm-explorations/test": "1.2.2" }, "indirect": {} } } ================================================ FILE: ui/app/review/src/ReviewConfig.elm ================================================ module ReviewConfig exposing (config) {-| Do not rename the ReviewConfig module or the config function, because `elm-review` will look for these. To add packages that contain rules, add them to this review project using `elm install author/packagename` when inside the directory containing this file. -} import NoUnused.CustomTypeConstructorArgs import NoUnused.CustomTypeConstructors import NoUnused.Dependencies import NoUnused.Exports import NoUnused.Modules import NoUnused.Parameters import NoUnused.Patterns import NoUnused.Variables import Simplify import Review.Rule exposing (Rule) config : List Rule config = List.map (Review.Rule.ignoreErrorsForDirectories [ "src/Data/" ]) [ NoUnused.CustomTypeConstructors.rule [] , NoUnused.CustomTypeConstructorArgs.rule , NoUnused.Dependencies.rule , NoUnused.Exports.rule , NoUnused.Modules.rule , NoUnused.Parameters.rule , NoUnused.Patterns.rule , NoUnused.Variables.rule , Simplify.rule ] ================================================ FILE: ui/app/script.js ================================================ !function(n){"use strict";function r(n,r,t){return t.a=n,t.f=r,t}function l(t){return r(2,t,function(r){return function(n){return t(r,n)}})}function d(e){return r(3,e,function(t){return function(r){return function(n){return e(t,r,n)}}})}function t(u){return r(4,u,function(e){return function(t){return function(r){return function(n){return u(e,t,r,n)}}}})}function c(a){return r(5,a,function(u){return function(e){return function(t){return function(r){return function(n){return a(u,e,t,r,n)}}}}})}function e(c){return r(6,c,function(a){return function(u){return function(e){return function(t){return function(r){return function(n){return c(a,u,e,t,r,n)}}}}}})}function u(o){return r(7,o,function(c){return function(a){return function(u){return function(e){return function(t){return function(r){return function(n){return o(c,a,u,e,t,r,n)}}}}}}})}function a(i){return r(8,i,function(o){return function(c){return function(a){return function(u){return function(e){return function(t){return function(r){return function(n){return i(o,c,a,u,e,t,r,n)}}}}}}}})}function o(f){return r(9,f,function(i){return function(o){return function(c){return function(a){return function(u){return function(e){return function(t){return function(r){return function(n){return f(i,o,c,a,u,e,t,r,n)}}}}}}}}})}function w(n,r,t){return 2===n.a?n.f(r,t):n(r)(t)}function y(n,r,t,e){return 3===n.a?n.f(r,t,e):n(r)(t)(e)}function x(n,r,t,e,u){return 4===n.a?n.f(r,t,e,u):n(r)(t)(e)(u)}function v(n,r,t,e,u,a){return 5===n.a?n.f(r,t,e,u,a):n(r)(t)(e)(u)(a)}function f(n,r,t,e,u,a,c){return 6===n.a?n.f(r,t,e,u,a,c):n(r)(t)(e)(u)(a)(c)}function b(n,r,t,e,u,a,c,o){return 7===n.a?n.f(r,t,e,u,a,c,o):n(r)(t)(e)(u)(a)(c)(o)}function s(n,r,t,e,u,a,c,o,i){return 8===n.a?n.f(r,t,e,u,a,c,o,i):n(r)(t)(e)(u)(a)(c)(o)(i)}var i=d(function(n,r,t){for(var e=Array(n),u=0;u"}function p(n){throw Error("https://github.com/elm/core/blob/1.0.0/hints/"+n+".md")}function g(n,r){for(var t,e=[],u=k(n,r,0,e);u&&(t=e.pop());u=k(t.a,t.b,0,e));return u}function k(n,r,t,e){if(100=56320&&57343>=a?t[--e]+u:u,r)}return r}),W=l(function(n,r){return r.split(n)}),Y=l(function(n,r){return r.join(n)}),nn=d(function(n,r,t){return t.slice(n,r)});l(function(n,r){for(var t=r.length;t--;){var e=r[t],u=r.charCodeAt(t);if(n(e=u>=56320&&57343>=u?r[--t]+e:e))return!0}return!1});var rn=l(function(n,r){for(var t=r.length;t--;){var e=r[t],u=r.charCodeAt(t);if(!n(e=u>=56320&&57343>=u?r[--t]+e:e))return!1}return!0}),tn=l(function(n,r){return!!~r.indexOf(n)}),en=l(function(n,r){return 0==r.indexOf(n)}),un=l(function(n,r){return n.length<=r.length&&r.lastIndexOf(n)==r.length-n.length}),an=l(function(n,r){var t=n.length;if(t<1)return H;for(var e=0,u=[];-1<(e=r.indexOf(n,e));)u.push(e),e+=t;return R(u)});var cn=l(function(n,r){return{$:10,d:n,b:r}});l(function(n,r){return{$:11,e:n,b:r}});function on(n,r){return{$:13,f:n,g:r}}var fn=l(function(n,r){return{$:14,b:r,h:n}});var bn=l(function(n,r){return on(n,[r])}),sn=d(function(n,r,t){return on(n,[r,t])}),ln=(t(function(n,r,t,e){return on(n,[r,t,e])}),c(function(n,r,t,e,u){return on(n,[r,t,e,u])}),e(function(n,r,t,e,u,a){return on(n,[r,t,e,u,a])}),u(function(n,r,t,e,u,a,c){return on(n,[r,t,e,u,a,c])}),a(function(n,r,t,e,u,a,c,o){return on(n,[r,t,e,u,a,c,o])}),o(function(n,r,t,e,u,a,c,o,i){return on(n,[r,t,e,u,a,c,o,i])}),l(function(n,r){try{return vn(n,JSON.parse(r))}catch(n){return mt(w(ht,"This is not valid JSON! "+n.message,r))}})),dn=l(vn);function vn(n,r){switch(n.$){case 3:return"boolean"==typeof r?wt(r):hn("a BOOL",r);case 2:return"number"!=typeof r?hn("an INT",r):(r<=-2147483647||2147483647<=r||(0|r)!==r)&&(!isFinite(r)||r%1)?hn("an INT",r):wt(r);case 4:return"number"==typeof r?wt(r):hn("a FLOAT",r);case 6:return"string"==typeof r?wt(r):r instanceof String?wt(r+""):hn("a STRING",r);case 9:return null===r?wt(n.c):hn("null",r);case 5:return wt(r);case 7:return Array.isArray(r)?$n(n.b,r,R):hn("a LIST",r);case 8:return Array.isArray(r)?$n(n.b,r,mn):hn("an ARRAY",r);case 10:var t=n.d;if("object"!=typeof r||null===r||!(t in r))return hn("an OBJECT with a field named `"+t+"`",r);var e=vn(n.b,r[t]);return ie(e)?e:mt(w(pt,t,e.a));case 11:t=n.e;if(!Array.isArray(r))return hn("an ARRAY",r);if(r.length<=t)return hn("a LONGER array. Need index "+t+" but only see "+r.length+" entries",r);e=vn(n.b,r[t]);return ie(e)?e:mt(w(gt,t,e.a));case 12:if("object"!=typeof r||null===r||Array.isArray(r))return hn("an OBJECT",r);var u,a=H;for(u in r)if(r.hasOwnProperty(u)){e=vn(n.b,r[u]);if(!ie(e))return mt(w(pt,u,e.a));a=I(_(u,e.a),a)}return wt(It(a));case 13:for(var c=n.f,o=n.g,i=0;ic)return u}var l=t.$;if(4===l){for(var d=t.k;4===d.$;)d=d.k;return n(r,d,e,u,a+1,c,r.elm_event_node_ref)}var v=t.e;var $=r.childNodes;for(var m=0;mc))return u;a=p}return u}(n,r,t,0,0,r.b,e)}function Zr(n,r,t,e){return 0===t.length?n:(Er(n,r,t,e),jr(n,t))}function jr(n,r){for(var t=0;te||e>57)if(65>e||e>70){if(e<97||102=u)return n;for(var r=arguments.length-3,t=Array(r);0>n}),l(function(n,r){return r>>>n});l(function(t,e){return Tn(function(n){var r=setInterval(function(){Cn(e)},t);return function(){clearInterval(r)}})});function Pr(n){return w(jt,"\n ",w(Ct,"\n",n))}function Wr(n){return y(_t,l(function(n,r){return r+1}),0,n)}function Yr(n){return 97<=(n=Ht(n))&&n<=122}function nt(n){return(n=Ht(n))<=90&&65<=n}function rt(n){return(n=Ht(n))<=57&&48<=n}function tt(n){return Yr(n)||nt(n)||rt(n)}function et(n){return n}function ut(n){return n.a}function at(n){return n}function ct(n){return""===n}var ot=O,it=m,ft=(d(function(t,n,r){var e=r.c,r=r.d,u=l(function(n,r){return y(it,n.$?t:u,r,n.a)});return y(it,u,y(it,t,n,r),e)}),d(function(n,r,t){for(;;){if(-2===t.$)return r;var e=t.d,u=n,a=y(n,t.b,t.c,y(ft,n,r,t.e));n=u,r=a,t=e}})),bt=function(n){return y(ft,d(function(n,r,t){return w(ot,_(n,r),t)}),H,n)},st=function(n){return n=n,y(ft,d(function(n,r,t){return w(ot,n,t)}),H,n)},lt=1,dt=2,vt=0,$t=l(function(n,r){return n}),mt=function(n){return{$:1,a:n}},ht=l(function(n,r){return{$:3,a:n,b:r}}),pt=l(function(n,r){return{$:0,a:n,b:r}}),gt=l(function(n,r){return{$:1,a:n,b:r}}),wt=function(n){return{$:0,a:n}},yt=function(n){return{$:2,a:n}},xt=G,kt=function(n){return{$:0,a:n}},At={$:1},St=rn,Tt=N,Et=wn,Zt=function(n){return n+""},jt=l(function(n,r){return w(Y,n,V(r))}),Ct=l(function(n,r){return R(w(W,n,r))}),_t=d(function(n,r,t){for(;;){if(!t.b)return r;var e=t.b,u=n,a=w(n,t.a,r);n=u,r=a,t=e}}),Jt=z,Lt=d(function(n,r,t){for(;;){if(1<=T(n,r))return t;var e=n,u=r-1,a=w(ot,r,t);n=e,r=u,t=a}}),Nt=l(function(n,r){return y(Lt,n,r,H)}),Xt=l(function(n,r){return y(Jt,n,w(Nt,0,Wr(r)-1),r)}),Ht=function(n){var r=n.charCodeAt(0);return r<55296||56319>1,X(r,r),1&n?X(t,r):t):t}),ns=l(function(n,r){return y(Yb,n,r,"")}),rs=d(function(n,r,t){return X(w(ns,n-me(t),Su(r)),t)}),ts=l(function(n,r){return y(rs,n,"0",Zt(r))}),es=l(function(n,r){return w(wi,60,w(Mb,na(r),1e3))}),us=l(function(n,r){return ra(w(Ub,n,r)).b3}),Br=l(function(n,r){return{$:0,a:n,b:r}}),as=w(Br,0,H),cs=ta,os=w(zf,gf,cs),is=l(function(n,r){return y(_t,(t=n,l(function(n,r){return r.push(t(n)),r})),[],r);var t}),fs=l(function(n,r){return{$:1,a:n,b:r}}),bs=d(function(n,r,t){return v(Of,"POST",H,n,r,t)}),ss=l(function(n,r){n=w(jt,"/",R([n,"silences"])),r=Wu(R([_("matchers",w(is,Yu,(r=r).bk)),_("startsAt",os(r.bQ)),_("endsAt",os(r.aX)),_("createdBy",gf(r.aV)),_("comment",gf(r.aT)),_("annotations",w(wf,Fb,w(Mi,w(Gb,at,gf),r.aK))),_("id",w(wf,Fb,w(Mi,gf,r.bd)))])),r=w(fs,"application/json",w(Et,0,r));return Ff(y(bs,n,r,zb))}),ls=l(function(n,r){return{aT:n.aT,aV:n.aV,v:r,s:n.s,aX:n.aX,bd:n.bd,bQ:n.bQ,J:!0}}),ds=l(function(n,r){return fi(na(r)+ai(n))}),vs=function(n){return n.trim()},$s=R([_("w",6048e5),_("d",864e5),_("h",36e5),_("m",6e4),_("s",1e3)]),ms=l(function(n,r){return w(wi,7,function(n){n=w(wi,7,n);return n||7}(r)+7-function(n){switch(n){case 0:return 1;case 1:return 2;case 2:return 3;case 3:return 4;case 4:return 5;case 5:return 6;default:return 7}}(n))}),hs=l(function(n,r){var t=aa(n)?1:0;switch(r){case 0:return 0;case 1:return 31;case 2:return 59+t;case 3:return 90+t;case 4:return 120+t;case 5:return 151+t;case 6:return 181+t;case 7:return 212+t;case 8:return 243+t;case 9:return 273+t;case 10:return 304+t;default:return 334+t}}),ps=l(function(n,r){return Pt(n/r)}),gs=l(function(n,r){return ca(n)+w(hs,n,r)+1}),ws=l(function(n,r){switch(r){case 0:return 31;case 1:return aa(n)?29:28;case 2:return 31;case 3:return 30;case 4:return 31;case 5:return 30;case 6:case 7:return 31;case 8:return 30;case 9:return 31;case 10:return 30;default:return 31}}),ys=d(function(n,r,t){for(;;){var e=w(ws,n,r),u=oa(r);if(12<=u||T(t,e)<=0)return{aW:t,ap:r,b3:n};n=n,r=ia(u+1),t=t-e}}),xs=l(function(n,r){return _(w(ps,n,r),w(wi,r,n))}),ks=w(_o,ba,function(n){return n.ap}),As=w(_o,ks,function(n){return(oa(n)+2)/3|0}),Ss=l(function(n,r){var t,e=r;switch(n){case 0:return t=fa(r),ca(t)+1;case 1:return w(gs,fa(r),(t=As(r),ia(3*t-2)));case 2:return w(gs,fa(r),ks(r));case 3:case 4:return e-w(ms,0,r);case 5:return e-w(ms,1,r);case 6:return e-w(ms,2,r);case 7:return e-w(ms,3,r);case 8:return e-w(ms,4,r);case 9:return e-w(ms,5,r);case 10:return e-w(ms,6,r);default:return r}}),Ts=d(function(n,r,t){return T(t,n)<0?n:0 Cmd (ApiData (List Receiver)) fetchReceivers apiUrl = Utils.Api.send (Utils.Api.get (apiUrl ++ "/receivers") (Json.Decode.list Data.Receiver.decoder) ) fetchAlertGroups : String -> Filter -> Cmd (ApiData (List AlertGroup)) fetchAlertGroups apiUrl filter = let url = String.join "/" [ apiUrl, "alerts", "groups" ++ generateAPIQueryString filter ] in Utils.Api.send (Utils.Api.get url (Json.Decode.list Data.AlertGroup.decoder)) fetchAlerts : String -> Filter -> Cmd (ApiData (List GettableAlert)) fetchAlerts apiUrl filter = let url = String.join "/" [ apiUrl, "alerts" ++ generateAPIQueryString filter ] in Utils.Api.send (Utils.Api.get url (Json.Decode.list Data.GettableAlert.decoder)) ================================================ FILE: ui/app/src/Data/Alert.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.Alert exposing (Alert(..), decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias Alert = { labels : Dict String String , generatorURL : Maybe String } decoder : Decoder Alert decoder = Decode.succeed Alert |> required "labels" (Decode.dict Decode.string) |> optional "generatorURL" (Decode.nullable Decode.string) Nothing encoder : Alert -> Encode.Value encoder model = Encode.object [ ( "labels", Encode.dict identity Encode.string model.labels ) , ( "generatorURL", Maybe.withDefault Encode.null (Maybe.map Encode.string model.generatorURL) ) ] ================================================ FILE: ui/app/src/Data/AlertGroup.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.AlertGroup exposing (AlertGroup, decoder, encoder) import Data.GettableAlert as GettableAlert exposing (GettableAlert) import Data.Receiver as Receiver exposing (Receiver) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias AlertGroup = { labels : Dict String String , receiver : Receiver , alerts : List GettableAlert } decoder : Decoder AlertGroup decoder = Decode.succeed AlertGroup |> required "labels" (Decode.dict Decode.string) |> required "receiver" Receiver.decoder |> required "alerts" (Decode.list GettableAlert.decoder) encoder : AlertGroup -> Encode.Value encoder model = Encode.object [ ( "labels", Encode.dict identity Encode.string model.labels ) , ( "receiver", Receiver.encoder model.receiver ) , ( "alerts", Encode.list GettableAlert.encoder model.alerts ) ] ================================================ FILE: ui/app/src/Data/AlertStatus.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.AlertStatus exposing (AlertStatus, State(..), decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias AlertStatus = { state : State , silencedBy : List String , inhibitedBy : List String , mutedBy : List String } type State = Unprocessed | Active | Suppressed decoder : Decoder AlertStatus decoder = Decode.succeed AlertStatus |> required "state" stateDecoder |> required "silencedBy" (Decode.list Decode.string) |> required "inhibitedBy" (Decode.list Decode.string) |> required "mutedBy" (Decode.list Decode.string) encoder : AlertStatus -> Encode.Value encoder model = Encode.object [ ( "state", stateEncoder model.state ) , ( "silencedBy", Encode.list Encode.string model.silencedBy ) , ( "inhibitedBy", Encode.list Encode.string model.inhibitedBy ) , ( "mutedBy", Encode.list Encode.string model.mutedBy ) ] stateDecoder : Decoder State stateDecoder = Decode.string |> Decode.andThen (\str -> case str of "unprocessed" -> Decode.succeed Unprocessed "active" -> Decode.succeed Active "suppressed" -> Decode.succeed Suppressed other -> Decode.fail <| "Unknown type: " ++ other ) stateEncoder : State -> Encode.Value stateEncoder model = case model of Unprocessed -> Encode.string "unprocessed" Active -> Encode.string "active" Suppressed -> Encode.string "suppressed" ================================================ FILE: ui/app/src/Data/AlertmanagerConfig.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.AlertmanagerConfig exposing (AlertmanagerConfig, decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias AlertmanagerConfig = { original : String } decoder : Decoder AlertmanagerConfig decoder = Decode.succeed AlertmanagerConfig |> required "original" Decode.string encoder : AlertmanagerConfig -> Encode.Value encoder model = Encode.object [ ( "original", Encode.string model.original ) ] ================================================ FILE: ui/app/src/Data/AlertmanagerStatus.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.AlertmanagerStatus exposing (AlertmanagerStatus, decoder, encoder) import Data.AlertmanagerConfig as AlertmanagerConfig exposing (AlertmanagerConfig) import Data.ClusterStatus as ClusterStatus exposing (ClusterStatus) import Data.VersionInfo as VersionInfo exposing (VersionInfo) import DateTime exposing (DateTime) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias AlertmanagerStatus = { cluster : ClusterStatus , versionInfo : VersionInfo , config : AlertmanagerConfig , uptime : DateTime } decoder : Decoder AlertmanagerStatus decoder = Decode.succeed AlertmanagerStatus |> required "cluster" ClusterStatus.decoder |> required "versionInfo" VersionInfo.decoder |> required "config" AlertmanagerConfig.decoder |> required "uptime" DateTime.decoder encoder : AlertmanagerStatus -> Encode.Value encoder model = Encode.object [ ( "cluster", ClusterStatus.encoder model.cluster ) , ( "versionInfo", VersionInfo.encoder model.versionInfo ) , ( "config", AlertmanagerConfig.encoder model.config ) , ( "uptime", DateTime.encoder model.uptime ) ] ================================================ FILE: ui/app/src/Data/ClusterStatus.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.ClusterStatus exposing (ClusterStatus, Status(..), decoder, encoder) import Data.PeerStatus as PeerStatus exposing (PeerStatus) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias ClusterStatus = { name : Maybe String , status : Status , peers : Maybe (List PeerStatus) } type Status = Ready | Settling | Disabled decoder : Decoder ClusterStatus decoder = Decode.succeed ClusterStatus |> optional "name" (Decode.nullable Decode.string) Nothing |> required "status" statusDecoder |> optional "peers" (Decode.nullable (Decode.list PeerStatus.decoder)) Nothing encoder : ClusterStatus -> Encode.Value encoder model = Encode.object [ ( "name", Maybe.withDefault Encode.null (Maybe.map Encode.string model.name) ) , ( "status", statusEncoder model.status ) , ( "peers", Maybe.withDefault Encode.null (Maybe.map (Encode.list PeerStatus.encoder) model.peers) ) ] statusDecoder : Decoder Status statusDecoder = Decode.string |> Decode.andThen (\str -> case str of "ready" -> Decode.succeed Ready "settling" -> Decode.succeed Settling "disabled" -> Decode.succeed Disabled other -> Decode.fail <| "Unknown type: " ++ other ) statusEncoder : Status -> Encode.Value statusEncoder model = case model of Ready -> Encode.string "ready" Settling -> Encode.string "settling" Disabled -> Encode.string "disabled" ================================================ FILE: ui/app/src/Data/GettableAlert.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.GettableAlert exposing (GettableAlert, decoder, encoder) import Data.AlertStatus as AlertStatus exposing (AlertStatus) import Data.Receiver as Receiver exposing (Receiver) import DateTime exposing (DateTime) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias GettableAlert = { labels : Dict String String , generatorURL : Maybe String , annotations : Dict String String , receivers : List Receiver , fingerprint : String , startsAt : DateTime , updatedAt : DateTime , endsAt : DateTime , status : AlertStatus } decoder : Decoder GettableAlert decoder = Decode.succeed GettableAlert |> required "labels" (Decode.dict Decode.string) |> optional "generatorURL" (Decode.nullable Decode.string) Nothing |> required "annotations" (Decode.dict Decode.string) |> required "receivers" (Decode.list Receiver.decoder) |> required "fingerprint" Decode.string |> required "startsAt" DateTime.decoder |> required "updatedAt" DateTime.decoder |> required "endsAt" DateTime.decoder |> required "status" AlertStatus.decoder encoder : GettableAlert -> Encode.Value encoder model = Encode.object [ ( "labels", Encode.dict identity Encode.string model.labels ) , ( "generatorURL", Maybe.withDefault Encode.null (Maybe.map Encode.string model.generatorURL) ) , ( "annotations", Encode.dict identity Encode.string model.annotations ) , ( "receivers", Encode.list Receiver.encoder model.receivers ) , ( "fingerprint", Encode.string model.fingerprint ) , ( "startsAt", DateTime.encoder model.startsAt ) , ( "updatedAt", DateTime.encoder model.updatedAt ) , ( "endsAt", DateTime.encoder model.endsAt ) , ( "status", AlertStatus.encoder model.status ) ] ================================================ FILE: ui/app/src/Data/GettableSilence.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.GettableSilence exposing (GettableSilence, decoder, encoder) import Data.Matcher as Matcher exposing (Matcher) import Data.SilenceStatus as SilenceStatus exposing (SilenceStatus) import DateTime exposing (DateTime) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias GettableSilence = { matchers : List Matcher , startsAt : DateTime , endsAt : DateTime , createdBy : String , comment : String , annotations : Maybe (Dict String String) , id : String , status : SilenceStatus , updatedAt : DateTime } decoder : Decoder GettableSilence decoder = Decode.succeed GettableSilence |> required "matchers" (Decode.list Matcher.decoder) |> required "startsAt" DateTime.decoder |> required "endsAt" DateTime.decoder |> required "createdBy" Decode.string |> required "comment" Decode.string |> optional "annotations" (Decode.nullable (Decode.dict Decode.string)) Nothing |> required "id" Decode.string |> required "status" SilenceStatus.decoder |> required "updatedAt" DateTime.decoder encoder : GettableSilence -> Encode.Value encoder model = Encode.object [ ( "matchers", Encode.list Matcher.encoder model.matchers ) , ( "startsAt", DateTime.encoder model.startsAt ) , ( "endsAt", DateTime.encoder model.endsAt ) , ( "createdBy", Encode.string model.createdBy ) , ( "comment", Encode.string model.comment ) , ( "annotations", Maybe.withDefault Encode.null (Maybe.map (Encode.dict identity Encode.string) model.annotations) ) , ( "id", Encode.string model.id ) , ( "status", SilenceStatus.encoder model.status ) , ( "updatedAt", DateTime.encoder model.updatedAt ) ] ================================================ FILE: ui/app/src/Data/InlineResponse200.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.InlineResponse200 exposing (InlineResponse200, decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias InlineResponse200 = { silenceID : Maybe String } decoder : Decoder InlineResponse200 decoder = Decode.succeed InlineResponse200 |> optional "silenceID" (Decode.nullable Decode.string) Nothing encoder : InlineResponse200 -> Encode.Value encoder model = Encode.object [ ( "silenceID", Maybe.withDefault Encode.null (Maybe.map Encode.string model.silenceID) ) ] ================================================ FILE: ui/app/src/Data/Matcher.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.Matcher exposing (Matcher, decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias Matcher = { name : String , value : String , isRegex : Bool , isEqual : Maybe Bool } decoder : Decoder Matcher decoder = Decode.succeed Matcher |> required "name" Decode.string |> required "value" Decode.string |> required "isRegex" Decode.bool |> optional "isEqual" (Decode.nullable Decode.bool) (Just True) encoder : Matcher -> Encode.Value encoder model = Encode.object [ ( "name", Encode.string model.name ) , ( "value", Encode.string model.value ) , ( "isRegex", Encode.bool model.isRegex ) , ( "isEqual", Maybe.withDefault Encode.null (Maybe.map Encode.bool model.isEqual) ) ] ================================================ FILE: ui/app/src/Data/PeerStatus.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.PeerStatus exposing (PeerStatus, decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias PeerStatus = { name : String , address : String } decoder : Decoder PeerStatus decoder = Decode.succeed PeerStatus |> required "name" Decode.string |> required "address" Decode.string encoder : PeerStatus -> Encode.Value encoder model = Encode.object [ ( "name", Encode.string model.name ) , ( "address", Encode.string model.address ) ] ================================================ FILE: ui/app/src/Data/PostableAlert.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.PostableAlert exposing (PostableAlert, decoder, encoder) import DateTime exposing (DateTime) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias PostableAlert = { labels : Dict String String , generatorURL : Maybe String , startsAt : Maybe DateTime , endsAt : Maybe DateTime , annotations : Maybe (Dict String String) } decoder : Decoder PostableAlert decoder = Decode.succeed PostableAlert |> required "labels" (Decode.dict Decode.string) |> optional "generatorURL" (Decode.nullable Decode.string) Nothing |> optional "startsAt" (Decode.nullable DateTime.decoder) Nothing |> optional "endsAt" (Decode.nullable DateTime.decoder) Nothing |> optional "annotations" (Decode.nullable (Decode.dict Decode.string)) Nothing encoder : PostableAlert -> Encode.Value encoder model = Encode.object [ ( "labels", Encode.dict identity Encode.string model.labels ) , ( "generatorURL", Maybe.withDefault Encode.null (Maybe.map Encode.string model.generatorURL) ) , ( "startsAt", Maybe.withDefault Encode.null (Maybe.map DateTime.encoder model.startsAt) ) , ( "endsAt", Maybe.withDefault Encode.null (Maybe.map DateTime.encoder model.endsAt) ) , ( "annotations", Maybe.withDefault Encode.null (Maybe.map (Encode.dict identity Encode.string) model.annotations) ) ] ================================================ FILE: ui/app/src/Data/PostableSilence.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.PostableSilence exposing (PostableSilence, decoder, encoder) import Data.Matcher as Matcher exposing (Matcher) import DateTime exposing (DateTime) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias PostableSilence = { matchers : List Matcher , startsAt : DateTime , endsAt : DateTime , createdBy : String , comment : String , annotations : Maybe (Dict String String) , id : Maybe String } decoder : Decoder PostableSilence decoder = Decode.succeed PostableSilence |> required "matchers" (Decode.list Matcher.decoder) |> required "startsAt" DateTime.decoder |> required "endsAt" DateTime.decoder |> required "createdBy" Decode.string |> required "comment" Decode.string |> optional "annotations" (Decode.nullable (Decode.dict Decode.string)) Nothing |> optional "id" (Decode.nullable Decode.string) Nothing encoder : PostableSilence -> Encode.Value encoder model = Encode.object [ ( "matchers", Encode.list Matcher.encoder model.matchers ) , ( "startsAt", DateTime.encoder model.startsAt ) , ( "endsAt", DateTime.encoder model.endsAt ) , ( "createdBy", Encode.string model.createdBy ) , ( "comment", Encode.string model.comment ) , ( "annotations", Maybe.withDefault Encode.null (Maybe.map (Encode.dict identity Encode.string) model.annotations) ) , ( "id", Maybe.withDefault Encode.null (Maybe.map Encode.string model.id) ) ] ================================================ FILE: ui/app/src/Data/Receiver.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.Receiver exposing (Receiver, decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias Receiver = { name : String } decoder : Decoder Receiver decoder = Decode.succeed Receiver |> required "name" Decode.string encoder : Receiver -> Encode.Value encoder model = Encode.object [ ( "name", Encode.string model.name ) ] ================================================ FILE: ui/app/src/Data/Silence.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.Silence exposing (Silence(..), decoder, encoder) import Data.Matcher as Matcher exposing (Matcher) import DateTime exposing (DateTime) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias Silence = { matchers : List Matcher , startsAt : DateTime , endsAt : DateTime , createdBy : String , comment : String , annotations : Maybe (Dict String String) } decoder : Decoder Silence decoder = Decode.succeed Silence |> required "matchers" (Decode.list Matcher.decoder) |> required "startsAt" DateTime.decoder |> required "endsAt" DateTime.decoder |> required "createdBy" Decode.string |> required "comment" Decode.string |> optional "annotations" (Decode.nullable (Decode.dict Decode.string)) Nothing encoder : Silence -> Encode.Value encoder model = Encode.object [ ( "matchers", Encode.list Matcher.encoder model.matchers ) , ( "startsAt", DateTime.encoder model.startsAt ) , ( "endsAt", DateTime.encoder model.endsAt ) , ( "createdBy", Encode.string model.createdBy ) , ( "comment", Encode.string model.comment ) , ( "annotations", Maybe.withDefault Encode.null (Maybe.map (Encode.dict identity Encode.string) model.annotations) ) ] ================================================ FILE: ui/app/src/Data/SilenceStatus.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.SilenceStatus exposing (SilenceStatus, State(..), decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias SilenceStatus = { state : State } type State = Expired | Active | Pending decoder : Decoder SilenceStatus decoder = Decode.succeed SilenceStatus |> required "state" stateDecoder encoder : SilenceStatus -> Encode.Value encoder model = Encode.object [ ( "state", stateEncoder model.state ) ] stateDecoder : Decoder State stateDecoder = Decode.string |> Decode.andThen (\str -> case str of "expired" -> Decode.succeed Expired "active" -> Decode.succeed Active "pending" -> Decode.succeed Pending other -> Decode.fail <| "Unknown type: " ++ other ) stateEncoder : State -> Encode.Value stateEncoder model = case model of Expired -> Encode.string "expired" Active -> Encode.string "active" Pending -> Encode.string "pending" ================================================ FILE: ui/app/src/Data/VersionInfo.elm ================================================ {- Alertmanager API API of the Prometheus Alertmanager (https://github.com/prometheus/alertmanager) OpenAPI spec version: 0.0.1 NOTE: This file is auto generated by the openapi-generator. https://github.com/openapitools/openapi-generator.git Do not edit this file manually. -} module Data.VersionInfo exposing (VersionInfo, decoder, encoder) import Dict exposing (Dict) import Json.Decode as Decode exposing (Decoder) import Json.Decode.Pipeline exposing (optional, required) import Json.Encode as Encode type alias VersionInfo = { version : String , revision : String , branch : String , buildUser : String , buildDate : String , goVersion : String } decoder : Decoder VersionInfo decoder = Decode.succeed VersionInfo |> required "version" Decode.string |> required "revision" Decode.string |> required "branch" Decode.string |> required "buildUser" Decode.string |> required "buildDate" Decode.string |> required "goVersion" Decode.string encoder : VersionInfo -> Encode.Value encoder model = Encode.object [ ( "version", Encode.string model.version ) , ( "revision", Encode.string model.revision ) , ( "branch", Encode.string model.branch ) , ( "buildUser", Encode.string model.buildUser ) , ( "buildDate", Encode.string model.buildDate ) , ( "goVersion", Encode.string model.goVersion ) ] ================================================ FILE: ui/app/src/DateTime.elm ================================================ module DateTime exposing (DateTime, decoder, encoder, toString) import Iso8601 import Json.Decode as Decode exposing (Decoder) import Json.Encode as Encode import Result import Time type alias DateTime = Time.Posix decoder : Decoder DateTime decoder = Decode.string |> Decode.andThen decodeIsoString encoder : DateTime -> Encode.Value encoder = Encode.string << toString decodeIsoString : String -> Decoder DateTime decodeIsoString str = case Iso8601.toTime str of Result.Ok posix -> Decode.succeed posix Result.Err _ -> Decode.fail <| "Invalid date: " ++ str toString : DateTime -> String toString = Iso8601.fromTime ================================================ FILE: ui/app/src/Main.elm ================================================ module Main exposing (main) import Browser exposing (UrlRequest(..)) import Browser.Navigation exposing (Key) import Json.Decode as Json import Parsing import Types exposing (Model, Msg(..), Route(..)) import Updates exposing (update) import Url exposing (Url) import Utils.Api as Api import Utils.DateTimePicker.Utils exposing (FirstDayOfWeek(..)) import Utils.Filter exposing (nullFilter) import Utils.Types exposing (ApiData(..)) import Views import Views.AlertList.Types exposing (initAlertList) import Views.SilenceForm.Types exposing (initSilenceForm) import Views.SilenceList.Types exposing (initSilenceList) import Views.SilenceView.Types exposing (initSilenceView) import Views.Status.Types exposing (initStatusModel) main : Program Json.Value Model Msg main = Browser.application { init = init , update = update , view = \model -> { title = "Alertmanager" , body = [ Views.view model ] } , subscriptions = always Sub.none , onUrlRequest = \request -> case request of Internal url -> NavigateToInternalUrl (Url.toString url) External url -> NavigateToExternalUrl url , onUrlChange = urlUpdate } init : Json.Value -> Url -> Key -> ( Model, Cmd Msg ) init flags url key = let route = Parsing.urlParser url filter = case route of AlertsRoute filter_ -> filter_ SilenceListRoute filter_ -> filter_ _ -> nullFilter prod = flags |> Json.decodeValue (Json.field "production" Json.bool) |> Result.withDefault False defaultCreator = flags |> Json.decodeValue (Json.field "defaultCreator" Json.string) |> Result.withDefault "" groupExpandAll = flags |> Json.decodeValue (Json.field "groupExpandAll" Json.bool) |> Result.withDefault False apiUrl = if prod then Api.makeApiUrl url.path else Api.makeApiUrl "http://localhost:9093/" libUrl = if prod then url.path else "/" firstDayOfWeek = flags |> Json.decodeValue (Json.field "firstDayOfWeek" Json.string) |> Result.withDefault "Sunday" |> (\d -> case d of "Sunday" -> Sunday "Saturday" -> Saturday _ -> Monday ) in update (urlUpdate url) (Model (initSilenceList key) (initSilenceView key) (initSilenceForm key firstDayOfWeek) (initAlertList key groupExpandAll) route filter initStatusModel url.path apiUrl libUrl Loading Loading Loading defaultCreator groupExpandAll key { firstDayOfWeek = firstDayOfWeek } ) urlUpdate : Url -> Msg urlUpdate url = let route = Parsing.urlParser url in case route of SilenceListRoute maybeFilter -> NavigateToSilenceList maybeFilter SilenceViewRoute silenceId -> NavigateToSilenceView silenceId SilenceFormEditRoute silenceId -> NavigateToSilenceFormEdit silenceId SilenceFormNewRoute params -> NavigateToSilenceFormNew params AlertsRoute filter -> NavigateToAlerts filter StatusRoute -> NavigateToStatus SettingsRoute -> NavigateToSettings TopLevelRoute -> RedirectAlerts NotFoundRoute -> NavigateToNotFound ================================================ FILE: ui/app/src/Parsing.elm ================================================ module Parsing exposing (urlParser) import Regex import Types exposing (Route(..)) import Url exposing (Url) import Url.Parser exposing (Parser, map, oneOf, parse, top) import Views.AlertList.Parsing exposing (alertsParser) import Views.Settings.Parsing exposing (settingsViewParser) import Views.SilenceForm.Parsing exposing (silenceFormEditParser, silenceFormNewParser) import Views.SilenceList.Parsing exposing (silenceListParser) import Views.SilenceView.Parsing exposing (silenceViewParser) import Views.Status.Parsing exposing (statusParser) urlParser : Url -> Route urlParser url = let -- Parse a query string occurring after the hash if it exists, and use -- it for routing. hashAndQuery = url.fragment |> Maybe.map (Regex.splitAtMost 1 (Regex.fromString "\\?" |> Maybe.withDefault Regex.never)) |> Maybe.withDefault [] ( path, query ) = case hashAndQuery of [] -> ( "/", Nothing ) h :: [] -> ( h, Nothing ) h :: rest -> ( h, Just (String.concat rest) ) in case parse routeParser { url | query = query, fragment = Nothing, path = path } of Just route -> route Nothing -> NotFoundRoute routeParser : Parser (Route -> a) a routeParser = oneOf [ map SilenceListRoute silenceListParser , map StatusRoute statusParser , map SettingsRoute settingsViewParser , map SilenceFormNewRoute silenceFormNewParser , map SilenceViewRoute silenceViewParser , map SilenceFormEditRoute silenceFormEditParser , map AlertsRoute alertsParser , map TopLevelRoute top ] ================================================ FILE: ui/app/src/Silences/Api.elm ================================================ module Silences.Api exposing (create, destroy, getSilence, getSilences) import Data.GettableSilence exposing (GettableSilence) import Data.PostableSilence exposing (PostableSilence) import Http import Json.Decode import Silences.Decoders import Utils.Api import Utils.Filter exposing (Filter, generateAPIQueryString) import Utils.Types exposing (ApiData(..)) getSilences : String -> Filter -> (ApiData (List GettableSilence) -> msg) -> Cmd msg getSilences apiUrl filter msg = let url = String.join "/" [ apiUrl, "silences" ++ generateAPIQueryString filter ] in Utils.Api.send (Utils.Api.get url (Json.Decode.list Data.GettableSilence.decoder)) |> Cmd.map msg getSilence : String -> String -> (ApiData GettableSilence -> msg) -> Cmd msg getSilence apiUrl uuid msg = let url = String.join "/" [ apiUrl, "silence", uuid ] in Utils.Api.send (Utils.Api.get url Data.GettableSilence.decoder) |> Cmd.map msg create : String -> PostableSilence -> Cmd (ApiData String) create apiUrl silence = let url = String.join "/" [ apiUrl, "silences" ] body = Http.jsonBody <| Data.PostableSilence.encoder silence in -- TODO: This should return the silence, not just the ID, so that we can -- redirect to the silence show page. Utils.Api.send (Utils.Api.post url body Silences.Decoders.create) destroy : String -> GettableSilence -> (ApiData String -> msg) -> Cmd msg destroy apiUrl silence msg = -- The incorrect route using "silences" receives a 405. The route seems to -- be matching on /silences and ignoring the :sid, should be getting a 404. let url = String.join "/" [ apiUrl, "silence", silence.id ] responseDecoder = -- Silences.Encoders.silence silence Silences.Decoders.destroy in Utils.Api.send (Utils.Api.delete url responseDecoder) |> Cmd.map msg ================================================ FILE: ui/app/src/Silences/Decoders.elm ================================================ module Silences.Decoders exposing (create, destroy) import Json.Decode as Json import Utils.Types exposing (ApiData(..)) create : Json.Decoder String create = Json.at [ "silenceID" ] Json.string destroy : Json.Decoder String destroy = Json.at [ "status" ] Json.string ================================================ FILE: ui/app/src/Silences/Types.elm ================================================ module Silences.Types exposing ( nullSilence , stateToString ) import Data.Matcher exposing (Matcher) import Data.PostableSilence exposing (PostableSilence) import Data.SilenceStatus exposing (State(..)) import Time nullSilence : PostableSilence nullSilence = { id = Nothing , createdBy = "" , comment = "" , startsAt = Time.millisToPosix 0 , endsAt = Time.millisToPosix 0 , matchers = nullMatchers , annotations = Nothing } nullMatchers : List Matcher nullMatchers = [ nullMatcher ] nullMatcher : Matcher nullMatcher = Matcher "" "" False (Just True) stateToString : State -> String stateToString state = case state of Active -> "active" Pending -> "pending" Expired -> "expired" ================================================ FILE: ui/app/src/Status/Api.elm ================================================ module Status.Api exposing (clusterStatusToString, getStatus) import Data.AlertmanagerStatus exposing (AlertmanagerStatus) import Data.ClusterStatus exposing (Status(..)) import Utils.Api exposing (get, send) import Utils.Types exposing (ApiData) getStatus : String -> (ApiData AlertmanagerStatus -> msg) -> Cmd msg getStatus apiUrl msg = let url = String.join "/" [ apiUrl, "status" ] request = get url Data.AlertmanagerStatus.decoder in Cmd.map msg <| send request clusterStatusToString : Status -> String clusterStatusToString status = case status of Ready -> "ready" Settling -> "settling" Disabled -> "disabled" ================================================ FILE: ui/app/src/Status/Types.elm ================================================ module Status.Types exposing (ClusterPeer, ClusterStatus, VersionInfo) type alias StatusResponse = { config : String , uptime : String , versionInfo : VersionInfo , clusterStatus : Maybe ClusterStatus } type alias VersionInfo = { branch : String , buildDate : String , buildUser : String , goVersion : String , revision : String , version : String } type alias ClusterStatus = { name : String , status : String , peers : List ClusterPeer } type alias ClusterPeer = { name : String , address : String } ================================================ FILE: ui/app/src/Types.elm ================================================ module Types exposing (Model, Msg(..), Route(..)) import Browser.Navigation exposing (Key) import Utils.Filter exposing (Filter, SilenceFormGetParams) import Utils.Types exposing (ApiData) import Views.AlertList.Types as AlertList exposing (AlertListMsg) import Views.Settings.Types as SettingsView exposing (SettingsMsg) import Views.SilenceForm.Types as SilenceForm exposing (SilenceFormMsg) import Views.SilenceList.Types as SilenceList exposing (SilenceListMsg) import Views.SilenceView.Types as SilenceView exposing (SilenceViewMsg) import Views.Status.Types exposing (StatusModel, StatusMsg) type alias Model = { silenceList : SilenceList.Model , silenceView : SilenceView.Model , silenceForm : SilenceForm.Model , alertList : AlertList.Model , route : Route , filter : Filter , status : StatusModel , basePath : String , apiUrl : String , libUrl : String , bootstrapCSS : ApiData String , fontAwesomeCSS : ApiData String , elmDatepickerCSS : ApiData String , defaultCreator : String , expandAll : Bool , key : Key , settings : SettingsView.Model } type Msg = MsgForAlertList AlertListMsg | MsgForSilenceView SilenceViewMsg | MsgForSilenceForm SilenceFormMsg | MsgForSilenceList SilenceListMsg | MsgForStatus StatusMsg | MsgForSettings SettingsMsg | NavigateToAlerts Filter | NavigateToNotFound | NavigateToSilenceView String | NavigateToSilenceFormEdit String | NavigateToSilenceFormNew SilenceFormGetParams | NavigateToSilenceList Filter | NavigateToStatus | NavigateToSettings | NavigateToInternalUrl String | NavigateToExternalUrl String | RedirectAlerts | BootstrapCSSLoaded (ApiData String) | FontAwesomeCSSLoaded (ApiData String) | ElmDatepickerCSSLoaded (ApiData String) | SetDefaultCreator String | SetGroupExpandAll Bool type Route = AlertsRoute Filter | NotFoundRoute | SilenceFormEditRoute String | SilenceFormNewRoute SilenceFormGetParams | SilenceListRoute Filter | SilenceViewRoute String | StatusRoute | TopLevelRoute | SettingsRoute ================================================ FILE: ui/app/src/Updates.elm ================================================ module Updates exposing (update) import Browser.Navigation as Navigation import Task import Types exposing (Model, Msg(..), Route(..)) import Views.AlertList.Types exposing (AlertListMsg(..)) import Views.AlertList.Updates import Views.Settings.Updates import Views.SilenceForm.Types exposing (SilenceFormMsg(..)) import Views.SilenceForm.Updates import Views.SilenceList.Types exposing (SilenceListMsg(..)) import Views.SilenceList.Updates import Views.SilenceView.Types as SilenceViewTypes import Views.SilenceView.Updates import Views.Status.Types exposing (StatusMsg(..)) import Views.Status.Updates update : Msg -> Model -> ( Model, Cmd Msg ) update msg ({ basePath, apiUrl } as model) = case msg of NavigateToAlerts filter -> let ( alertList, cmd ) = Views.AlertList.Updates.update FetchAlerts model.alertList filter apiUrl basePath in ( { model | alertList = alertList, route = AlertsRoute filter, filter = filter }, cmd ) NavigateToSilenceList filter -> let ( silenceList, cmd ) = Views.SilenceList.Updates.update FetchSilences model.silenceList filter basePath apiUrl in ( { model | silenceList = silenceList, route = SilenceListRoute filter, filter = filter } , Cmd.map MsgForSilenceList cmd ) NavigateToStatus -> ( { model | route = StatusRoute }, Task.perform identity (Task.succeed <| MsgForStatus <| InitStatusView apiUrl) ) NavigateToSilenceView silenceId -> let ( silenceView, cmd ) = Views.SilenceView.Updates.update (SilenceViewTypes.InitSilenceView silenceId) model.silenceView apiUrl in ( { model | route = SilenceViewRoute silenceId, silenceView = silenceView } , Cmd.map MsgForSilenceView cmd ) NavigateToSilenceFormNew params -> ( { model | route = SilenceFormNewRoute params } , Task.perform (NewSilenceFromMatchersAndComment model.defaultCreator >> MsgForSilenceForm) (Task.succeed params) ) NavigateToSilenceFormEdit uuid -> ( { model | route = SilenceFormEditRoute uuid }, Task.perform identity (Task.succeed <| (FetchSilence uuid |> MsgForSilenceForm)) ) NavigateToNotFound -> ( { model | route = NotFoundRoute }, Cmd.none ) NavigateToInternalUrl url -> ( model, Navigation.pushUrl model.key url ) NavigateToExternalUrl url -> ( model, Navigation.load url ) RedirectAlerts -> ( model, Navigation.pushUrl model.key (basePath ++ "#/alerts") ) NavigateToSettings -> ( { model | route = SettingsRoute }, Cmd.none ) MsgForStatus subMsg -> Views.Status.Updates.update subMsg model MsgForAlertList subMsg -> let ( alertList, cmd ) = Views.AlertList.Updates.update subMsg model.alertList model.filter apiUrl basePath in ( { model | alertList = alertList }, cmd ) MsgForSilenceList subMsg -> let ( silenceList, cmd ) = Views.SilenceList.Updates.update subMsg model.silenceList model.filter basePath apiUrl in ( { model | silenceList = silenceList }, Cmd.map MsgForSilenceList cmd ) MsgForSettings subMsg -> let ( settingsView, cmd ) = Views.Settings.Updates.update subMsg model.settings in ( { model | settings = settingsView }, cmd ) MsgForSilenceView subMsg -> let ( silenceView, cmd ) = Views.SilenceView.Updates.update subMsg model.silenceView apiUrl in ( { model | silenceView = silenceView }, Cmd.map MsgForSilenceView cmd ) MsgForSilenceForm subMsg -> let ( silenceForm, cmd ) = Views.SilenceForm.Updates.update subMsg model.silenceForm basePath apiUrl in ( { model | silenceForm = silenceForm }, cmd ) BootstrapCSSLoaded css -> ( { model | bootstrapCSS = css }, Cmd.none ) FontAwesomeCSSLoaded css -> ( { model | fontAwesomeCSS = css }, Cmd.none ) ElmDatepickerCSSLoaded css -> ( { model | elmDatepickerCSS = css }, Cmd.none ) SetDefaultCreator name -> ( { model | defaultCreator = name }, Cmd.none ) SetGroupExpandAll expanded -> ( { model | expandAll = expanded }, Cmd.none ) ================================================ FILE: ui/app/src/Utils/Api.elm ================================================ module Utils.Api exposing (delete, get, makeApiUrl, map, post, send) import Http exposing (Error(..)) import Json.Decode as Json exposing (field) import Utils.Types exposing (ApiData(..)) map : (a -> b) -> ApiData a -> ApiData b map fn response = case response of Success value -> Success (fn value) Initial -> Initial Loading -> Loading Failure a -> Failure a parseError : String -> Maybe String parseError = Json.decodeString (field "error" Json.string) >> Result.toMaybe errorToString : Http.Error -> String errorToString err = case err of Timeout -> "Timeout exceeded" NetworkError -> "Network error" BadStatus resp -> parseError resp.body |> Maybe.withDefault (String.fromInt resp.status.code ++ " " ++ resp.status.message) BadPayload err_ _ -> -- OK status, unexpected payload "Unexpected response from api: " ++ err_ BadUrl url -> "Malformed url: " ++ url fromResult : Result Http.Error a -> ApiData a fromResult result = case result of Err e -> Failure (errorToString e) Ok x -> Success x send : Http.Request a -> Cmd (ApiData a) send = Http.send fromResult get : String -> Json.Decoder a -> Http.Request a get url decoder = request "GET" [] url Http.emptyBody decoder post : String -> Http.Body -> Json.Decoder a -> Http.Request a post url body decoder = request "POST" [] url body decoder delete : String -> Json.Decoder a -> Http.Request a delete url decoder = request "DELETE" [] url Http.emptyBody decoder request : String -> List Http.Header -> String -> Http.Body -> Json.Decoder a -> Http.Request a request method headers url body decoder = Http.request { method = method , headers = headers , url = url , body = body , expect = Http.expectJson decoder , timeout = Nothing , withCredentials = False } makeApiUrl : String -> String makeApiUrl externalUrl = let url = if String.endsWith "/" externalUrl then String.dropRight 1 externalUrl else externalUrl in url ++ "/api/v2" ================================================ FILE: ui/app/src/Utils/Date.elm ================================================ module Utils.Date exposing ( addDuration , dateTimeFormat , durationFormat , parseDuration , timeDifference , timeFromString , timeToString ) import Iso8601 import Parser exposing ((|.), (|=), Parser) import Time exposing (Posix) import Tuple parseDuration : String -> Result String Float parseDuration = Parser.run durationParser >> Result.mapError (always "Wrong duration format") durationParser : Parser Float durationParser = Parser.succeed identity |= Parser.loop 0 durationHelp |. Parser.spaces |. Parser.end durationHelp : Float -> Parser (Parser.Step Float Float) durationHelp duration = Parser.oneOf [ Parser.succeed (\d -> Parser.Loop (d + duration)) |= term |. Parser.spaces , Parser.succeed (Parser.Done duration) ] units : List ( String, number ) units = [ ( "w", 604800000 ) , ( "d", 86400000 ) , ( "h", 3600000 ) , ( "m", 60000 ) , ( "s", 1000 ) ] timeToString : Posix -> String timeToString = Iso8601.fromTime term : Parser Float term = Parser.succeed (*) |= Parser.float |= (units |> List.map (\( unit, ms ) -> Parser.succeed ms |. Parser.symbol unit) |> Parser.oneOf ) addDuration : Float -> Posix -> Posix addDuration duration time = Time.millisToPosix <| (Time.posixToMillis time + round duration) timeDifference : Posix -> Posix -> Float timeDifference startsAt endsAt = toFloat <| (Time.posixToMillis endsAt - Time.posixToMillis startsAt) durationFormat : Float -> Maybe String durationFormat duration = if duration >= 0 then List.foldl (\( unit, ms ) ( result, curr ) -> ( if curr // ms == 0 then result else result ++ String.fromInt (curr // ms) ++ unit ++ " " , modBy ms curr ) ) ( "", round duration ) units |> Tuple.first |> String.trim |> Just else Nothing dateTimeFormat : Posix -> String dateTimeFormat = Iso8601.fromTime timeFromString : String -> Result String Posix timeFromString string = if string == "" then Err "Should not be empty" else Iso8601.toTime string |> Result.mapError (always "Wrong ISO8601 format") ================================================ FILE: ui/app/src/Utils/DateTimePicker/Types.elm ================================================ module Utils.DateTimePicker.Types exposing ( DateTimePicker , InputHourOrMinute(..) , Msg(..) , StartOrEnd(..) , initDateTimePicker , initFromStartAndEndTime ) import Time exposing (Posix) import Utils.DateTimePicker.Utils exposing (FirstDayOfWeek, floorMinute) type alias DateTimePicker = { month : Maybe Posix , mouseOverDay : Maybe Posix , startDate : Maybe Posix , endDate : Maybe Posix , startTime : Maybe Posix , endTime : Maybe Posix , firstDayOfWeek : FirstDayOfWeek } type Msg = NextMonth | PrevMonth | MouseOverDay Posix | OnClickDay | ClearMouseOverDay | SetInputTime StartOrEnd InputHourOrMinute Int | IncrementTime StartOrEnd InputHourOrMinute Int type StartOrEnd = Start | End type InputHourOrMinute = InputHour | InputMinute initDateTimePicker : FirstDayOfWeek -> DateTimePicker initDateTimePicker firstDayOfWeek = { month = Nothing , mouseOverDay = Nothing , startDate = Nothing , endDate = Nothing , startTime = Nothing , endTime = Nothing , firstDayOfWeek = firstDayOfWeek } initFromStartAndEndTime : Maybe Posix -> Maybe Posix -> FirstDayOfWeek -> DateTimePicker initFromStartAndEndTime start end firstDayOfWeek = let startTime = Maybe.map (\s -> floorMinute s) start endTime = Maybe.map (\e -> floorMinute e) end in { month = start , mouseOverDay = Nothing , startDate = start , endDate = end , startTime = startTime , endTime = endTime , firstDayOfWeek = firstDayOfWeek } ================================================ FILE: ui/app/src/Utils/DateTimePicker/Updates.elm ================================================ module Utils.DateTimePicker.Updates exposing (update) import Time exposing (Posix) import Utils.DateTimePicker.Types exposing ( DateTimePicker , InputHourOrMinute(..) , Msg(..) , StartOrEnd(..) ) import Utils.DateTimePicker.Utils exposing ( addHour , addMinute , firstDayOfNextMonth , firstDayOfPrevMonth , floorDate , trimTime , updateHour , updateMinute ) update : Msg -> DateTimePicker -> DateTimePicker update msg dateTimePicker = let justMonth = dateTimePicker.month |> Maybe.withDefault (Time.millisToPosix 0) setTime_ : StartOrEnd -> InputHourOrMinute -> (InputHourOrMinute -> Posix -> Posix) -> ( Maybe Posix, Maybe Posix ) setTime_ soe ihom updateTime = let set_ : Maybe Posix -> Maybe Posix set_ a = Maybe.map (\b -> updateTime ihom b) a in case soe of Start -> ( set_ dateTimePicker.startTime, dateTimePicker.endTime ) End -> ( dateTimePicker.startTime, set_ dateTimePicker.endTime ) in case msg of NextMonth -> { dateTimePicker | month = Just (firstDayOfNextMonth justMonth) } PrevMonth -> { dateTimePicker | month = Just (firstDayOfPrevMonth justMonth) } MouseOverDay time -> { dateTimePicker | mouseOverDay = Just time } ClearMouseOverDay -> { dateTimePicker | mouseOverDay = Nothing } OnClickDay -> let addDateTime_ : Posix -> Maybe Posix -> Posix addDateTime_ date maybeTime = case maybeTime of Just time -> floorDate date |> Time.posixToMillis |> (\d -> trimTime time |> Time.posixToMillis |> (\t -> d + t) ) |> Time.millisToPosix Nothing -> floorDate date updateTime_ : Maybe Posix -> Maybe Posix -> Maybe Posix updateTime_ maybeDate maybeTime = case maybeDate of Just date -> Just <| addDateTime_ date maybeTime Nothing -> maybeTime ( startDate, endDate ) = case dateTimePicker.mouseOverDay of Just m -> case ( dateTimePicker.startDate, dateTimePicker.endDate ) of ( Nothing, Nothing ) -> ( Just m , Nothing ) ( Just start, Nothing ) -> case compare (floorDate m |> Time.posixToMillis) (floorDate start |> Time.posixToMillis) of LT -> ( Just m , Just start ) _ -> ( Just start , Just m ) ( Nothing, Just end ) -> ( Just m , Just end ) ( Just _, Just _ ) -> ( Just m , Nothing ) _ -> ( dateTimePicker.startDate , dateTimePicker.endDate ) in { dateTimePicker | startDate = startDate , endDate = endDate , startTime = updateTime_ startDate dateTimePicker.startTime , endTime = updateTime_ endDate dateTimePicker.endTime } SetInputTime startOrEnd inputHourOrMinute num -> let limit_ : Int -> Int -> Int limit_ limit n = if n < 0 then 0 else modBy limit n updateHourOrMinute_ : InputHourOrMinute -> Posix -> Posix updateHourOrMinute_ ihom s = case ihom of InputHour -> updateHour (limit_ 24 num) s InputMinute -> updateMinute (limit_ 60 num) s ( startTime, endTime ) = setTime_ startOrEnd inputHourOrMinute updateHourOrMinute_ in { dateTimePicker | startTime = startTime, endTime = endTime } IncrementTime startOrEnd inputHourOrMinute num -> let updateHourOrMinute_ : InputHourOrMinute -> Posix -> Posix updateHourOrMinute_ ihom s = let compare_ : Posix -> Posix compare_ a = if (floorDate s |> Time.posixToMillis) == (floorDate a |> Time.posixToMillis) then a else s in case ihom of InputHour -> addHour num s |> compare_ InputMinute -> addMinute num s |> compare_ ( startTime, endTime ) = setTime_ startOrEnd inputHourOrMinute updateHourOrMinute_ in { dateTimePicker | startTime = startTime, endTime = endTime } ================================================ FILE: ui/app/src/Utils/DateTimePicker/Utils.elm ================================================ module Utils.DateTimePicker.Utils exposing ( FirstDayOfWeek(..) , addHour , addMinute , firstDayOfNextMonth , firstDayOfPrevMonth , floorDate , floorMinute , floorMonth , listDaysOfMonth , monthToString , splitWeek , targetValueIntParse , trimTime , updateHour , updateMinute ) import Html.Events exposing (targetValue) import Json.Decode as Decode import Time exposing (Month(..), Posix, Weekday(..), utc) import Time.Extra as Time exposing (Interval(..)) type FirstDayOfWeek = Monday | Sunday | Saturday listDaysOfMonth : Posix -> FirstDayOfWeek -> List Posix listDaysOfMonth time firstDayOfWeek = let firstOfMonth = Time.floor Time.Month utc time firstOfNextMonth = firstDayOfNextMonth time padFront = weekToInt (Time.toWeekday utc firstOfMonth) |> (\wd -> case firstDayOfWeek of Sunday -> if wd == 7 then 0 else wd Monday -> if wd == 1 then 0 else wd - 1 Saturday -> if wd == 6 then 0 else if wd == 7 then 1 else wd + 1 ) |> (\w -> Time.add Time.Day -w utc firstOfMonth) |> (\d -> Time.range Time.Day 1 utc d firstOfMonth) padBack = weekToInt (Time.toWeekday utc firstOfNextMonth) |> (\wd -> case firstDayOfWeek of Sunday -> wd Monday -> if wd == 1 then 7 else wd - 1 Saturday -> if wd == 6 then 7 else if wd == 7 then 1 else wd + 1 ) |> (\w -> Time.add Time.Day (7 - w) utc firstOfNextMonth) |> Time.range Time.Day 1 utc firstOfNextMonth in Time.range Time.Day 1 utc firstOfMonth firstOfNextMonth |> (\m -> padFront ++ m ++ padBack) firstDayOfNextMonth : Posix -> Posix firstDayOfNextMonth time = Time.floor Time.Month utc time |> Time.add Time.Day 1 utc |> Time.ceiling Time.Month utc firstDayOfPrevMonth : Posix -> Posix firstDayOfPrevMonth time = Time.floor Time.Month utc time |> Time.add Time.Day -1 utc |> Time.floor Time.Month utc splitWeek : List Posix -> List (List Posix) -> List (List Posix) splitWeek days weeks = if List.length days < 7 then weeks else List.append weeks [ List.take 7 days ] |> splitWeek (List.drop 7 days) floorDate : Posix -> Posix floorDate time = Time.floor Time.Day utc time floorMonth : Posix -> Posix floorMonth time = Time.floor Time.Month utc time floorMinute : Posix -> Posix floorMinute time = Time.floor Time.Minute utc time trimTime : Posix -> Posix trimTime time = Time.floor Time.Day utc time |> Time.posixToMillis |> (\d -> Time.posixToMillis time - d ) |> Time.millisToPosix updateHour : Int -> Posix -> Posix updateHour n time = let diff = n - Time.toHour utc time in Time.add Hour diff utc time updateMinute : Int -> Posix -> Posix updateMinute n time = let diff = n - Time.toMinute utc time in Time.add Minute diff utc time addHour : Int -> Posix -> Posix addHour n time = Time.add Hour n utc time addMinute : Int -> Posix -> Posix addMinute n time = Time.add Minute n utc time weekToInt : Weekday -> Int weekToInt weekday = case weekday of Mon -> 1 Tue -> 2 Wed -> 3 Thu -> 4 Fri -> 5 Sat -> 6 Sun -> 7 monthToString : Month -> String monthToString month = case month of Jan -> "January" Feb -> "February" Mar -> "March" Apr -> "April" May -> "May" Jun -> "June" Jul -> "July" Aug -> "August" Sep -> "September" Oct -> "October" Nov -> "November" Dec -> "December" targetValueIntParse : Decode.Decoder Int targetValueIntParse = customDecoder targetValue (String.toInt >> maybeStringToResult) maybeStringToResult : Maybe a -> Result String a maybeStringToResult = Result.fromMaybe "could not convert string" customDecoder : Decode.Decoder a -> (a -> Result String b) -> Decode.Decoder b customDecoder d f = let resultDecoder x = case x of Ok a -> Decode.succeed a Err e -> Decode.fail e in Decode.map f d |> Decode.andThen resultDecoder ================================================ FILE: ui/app/src/Utils/DateTimePicker/Views.elm ================================================ module Utils.DateTimePicker.Views exposing (viewDateTimePicker) import Html exposing (Html, br, button, div, i, input, p, strong, text) import Html.Attributes exposing (class, maxlength, value) import Html.Events exposing (on, onClick, onMouseOut, onMouseOver) import Iso8601 import Json.Decode as Decode import Time exposing (Posix, utc) import Utils.DateTimePicker.Types exposing (DateTimePicker, InputHourOrMinute(..), Msg(..), StartOrEnd(..)) import Utils.DateTimePicker.Utils exposing ( FirstDayOfWeek(..) , floorDate , floorMonth , listDaysOfMonth , monthToString , splitWeek , targetValueIntParse ) viewDateTimePicker : DateTimePicker -> Html Msg viewDateTimePicker dateTimePicker = div [ class "w-100 container" ] [ viewCalendar dateTimePicker , div [ class "pt-4 row justify-content-center" ] [ viewTimePicker dateTimePicker Start , viewTimePicker dateTimePicker End ] ] viewCalendar : DateTimePicker -> Html Msg viewCalendar dateTimePicker = let justViewTime = dateTimePicker.month |> Maybe.withDefault (Time.millisToPosix 0) in div [ class "calendar_ month" ] [ viewMonthHeader justViewTime , viewMonth dateTimePicker justViewTime ] viewMonthHeader : Posix -> Html Msg viewMonthHeader justViewTime = div [ class "row month-header" ] [ div [ class "prev-month d-flex-center" , onClick PrevMonth ] [ p [ class "arrow" ] [ i [ class "fa fa-angle-left fa-3x cursor-pointer" ] [] ] ] , div [ class "month-text d-flex-center" ] [ text (Time.toYear utc justViewTime |> String.fromInt) , br [] [] , text (Time.toMonth utc justViewTime |> monthToString) ] , div [ class "next-month d-flex-center" , onClick NextMonth ] [ p [ class "arrow" ] [ i [ class "fa fa-angle-right fa-3x cursor-pointer" ] [] ] ] ] viewMonth : DateTimePicker -> Posix -> Html Msg viewMonth dateTimePicker justViewTime = let days = listDaysOfMonth justViewTime dateTimePicker.firstDayOfWeek weeks = splitWeek days [] in div [ class "row justify-content-center" ] [ div [ class "weekheader" ] (case dateTimePicker.firstDayOfWeek of Sunday -> List.map viewWeekHeader [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ] Monday -> List.map viewWeekHeader [ "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" ] Saturday -> List.map viewWeekHeader [ "Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri" ] ) , div [ class "date-container" , onMouseOut ClearMouseOverDay ] (List.map (viewWeek dateTimePicker justViewTime) weeks) ] viewWeekHeader : String -> Html Msg viewWeekHeader weekday = div [ class "date text-muted" ] [ text weekday ] viewWeek : DateTimePicker -> Posix -> List Posix -> Html Msg viewWeek dateTimePicker justViewTime days = div [] [ div [] (List.map (viewDay dateTimePicker justViewTime) days) ] viewDay : DateTimePicker -> Posix -> Posix -> Html Msg viewDay dateTimePicker justViewTime day = let compareDate_ : Posix -> Posix -> Order compareDate_ a b = compare (floorDate a |> Time.posixToMillis) (floorDate b |> Time.posixToMillis) setClass_ : Maybe Posix -> String -> String setClass_ d s = case d of Just m -> case compareDate_ m day of EQ -> s _ -> "" Nothing -> "" thisMonthClass = if floorMonth justViewTime == floorMonth day then " thismonth" else "" mouseoverClass = setClass_ dateTimePicker.mouseOverDay " mouseover" startClass = setClass_ dateTimePicker.startDate " start" endClass = setClass_ dateTimePicker.endDate " end" ( startClassBack, endClassBack ) = Maybe.map2 (\_ _ -> ( startClass, endClass )) dateTimePicker.startDate dateTimePicker.endDate |> Maybe.withDefault ( "", "" ) betweenClass = case ( dateTimePicker.startDate, dateTimePicker.endDate ) of ( Just start, Just end ) -> case ( compareDate_ start day, compareDate_ end day ) of ( LT, GT ) -> " between" _ -> "" _ -> "" in div [ class ("date back" ++ startClassBack ++ endClassBack ++ betweenClass) ] [ div [ class ("date front" ++ mouseoverClass ++ startClass ++ endClass ++ thisMonthClass) , onMouseOver <| MouseOverDay day , onClick OnClickDay ] [ text (Time.toDay utc day |> String.fromInt) ] ] viewTimePicker : DateTimePicker -> StartOrEnd -> Html Msg viewTimePicker dateTimePicker startOrEnd = div [ class "row timepicker" ] [ strong [ class "subject" ] [ text (case startOrEnd of Start -> "Start" End -> "End" ) ] , div [ class "hour" ] [ button [ class "up-button d-flex-center" , onClick <| IncrementTime startOrEnd InputHour 1 ] [ i [ class "fa fa-angle-up" ] [] ] , input [ on "blur" <| Decode.map (SetInputTime startOrEnd InputHour) targetValueIntParse , value (case startOrEnd of Start -> case dateTimePicker.startTime of Just t -> Time.toHour utc t |> String.fromInt Nothing -> "0" End -> case dateTimePicker.endTime of Just t -> Time.toHour utc t |> String.fromInt Nothing -> "0" ) , maxlength 2 , class "view d-flex-center" ] [] , button [ class "down-button d-flex-center" , onClick <| IncrementTime startOrEnd InputHour -1 ] [ i [ class "fa fa-angle-down" ] [] ] ] , div [ class "colon d-flex-center" ] [ text ":" ] , div [ class "minute" ] [ button [ class "up-button d-flex-center" , onClick <| IncrementTime startOrEnd InputMinute 1 ] [ i [ class "fa fa-angle-up" ] [] ] , input [ on "blur" <| Decode.map (SetInputTime startOrEnd InputMinute) targetValueIntParse , value (case startOrEnd of Start -> case dateTimePicker.startTime of Just t -> Time.toMinute utc t |> String.fromInt Nothing -> "0" End -> case dateTimePicker.endTime of Just t -> Time.toMinute utc t |> String.fromInt Nothing -> "0" ) , maxlength 2 , class "view" ] [] , button [ class "down-button d-flex-center" , onClick <| IncrementTime startOrEnd InputMinute -1 ] [ i [ class "fa fa-angle-down" ] [] ] ] , div [ class "timeview d-flex-center" ] [ text (let toString_ : Maybe Posix -> Maybe Posix -> String toString_ maybeTime maybeDate = Maybe.map (\t -> case maybeDate of Just _ -> Iso8601.fromTime t |> String.dropRight 8 Nothing -> "" ) maybeTime |> Maybe.withDefault "" selectedTime = case startOrEnd of Start -> toString_ dateTimePicker.startTime dateTimePicker.startDate End -> toString_ dateTimePicker.endTime dateTimePicker.endDate in selectedTime ) ] ] ================================================ FILE: ui/app/src/Utils/Filter.elm ================================================ module Utils.Filter exposing ( Filter , MatchOperator(..) , Matcher , SilenceFormGetParams , convertFilterMatcher , emptySilenceFormGetParams , fromApiMatcher , generateAPIQueryString , nullFilter , parseFilter , parseGroup , parseMatcher , silencePreviewFilter , stringifyFilter , stringifyGroup , stringifyMatcher , toApiMatcher , toUrl , withMatchers ) import Char import Data.Matcher import Json.Decode as Decode import Json.Encode as Encode import Parser exposing ((|.), (|=), Parser, Trailing(..)) import Set import Url exposing (percentEncode) type alias Filter = { text : Maybe String , group : Maybe String , customGrouping : Bool , receiver : Maybe String , showSilenced : Maybe Bool , showInhibited : Maybe Bool , showMuted : Maybe Bool , showActive : Maybe Bool } nullFilter : Filter nullFilter = { text = Nothing , group = Nothing , customGrouping = False , receiver = Nothing , showSilenced = Nothing , showInhibited = Nothing , showMuted = Nothing , showActive = Nothing } generateQueryParam : String -> Maybe String -> Maybe String generateQueryParam name = Maybe.map (percentEncode >> (++) (name ++ "=")) toUrl : String -> Filter -> String toUrl baseUrl { receiver, customGrouping, showSilenced, showInhibited, showMuted, showActive, text, group } = let parts = [ ( "silenced", Maybe.withDefault False showSilenced |> boolToString |> Just ) , ( "inhibited", Maybe.withDefault False showInhibited |> boolToString |> Just ) , ( "muted", Maybe.withDefault False showMuted |> boolToString |> Just ) , ( "active", Maybe.withDefault True showActive |> boolToString |> Just ) , ( "filter", emptyToNothing text ) , ( "receiver", emptyToNothing receiver ) , ( "group", group ) , ( "customGrouping", boolToMaybeString customGrouping ) ] |> List.filterMap (\( a, b ) -> generateQueryParam a b) in if List.length parts > 0 then baseUrl ++ (parts |> String.join "&" |> (++) "?" ) else baseUrl generateAPIQueryString : Filter -> String generateAPIQueryString { receiver, showSilenced, showInhibited, showMuted, showActive, text, group } = let filter_ = case parseFilter (Maybe.withDefault "" text) of Just matchers_ -> List.map (stringifyMatcher >> Just >> Tuple.pair "filter") matchers_ Nothing -> [] parts = filter_ ++ [ ( "silenced", Maybe.withDefault False showSilenced |> boolToString |> Just ) , ( "inhibited", Maybe.withDefault False showInhibited |> boolToString |> Just ) , ( "muted", Maybe.withDefault False showMuted |> boolToString |> Just ) , ( "active", Maybe.withDefault True showActive |> boolToString |> Just ) , ( "receiver", emptyToNothing receiver ) , ( "group", group ) ] |> List.filterMap (\( a, b ) -> generateQueryParam a b) in if List.length parts > 0 then parts |> String.join "&" |> (++) "?" else "" boolToMaybeString : Bool -> Maybe String boolToMaybeString b = if b then Just "true" else Nothing boolToString : Bool -> String boolToString b = if b then "true" else "false" emptyToNothing : Maybe String -> Maybe String emptyToNothing str = case str of Just "" -> Nothing _ -> str type alias Matcher = { key : String , op : MatchOperator , value : String } toApiMatcher : Matcher -> Data.Matcher.Matcher toApiMatcher { key, op, value } = let ( isRegex, isEqual ) = case op of Eq -> ( False, True ) NotEq -> ( False, False ) RegexMatch -> ( True, True ) NotRegexMatch -> ( True, False ) in { name = key , isRegex = isRegex , isEqual = Just isEqual , value = value } fromApiMatcher : Data.Matcher.Matcher -> Matcher fromApiMatcher { name, value, isRegex, isEqual } = let isEqualValue = case isEqual of Nothing -> True Just justIsEqual -> justIsEqual op = if not isRegex && isEqualValue then Eq else if not isRegex && not isEqualValue then NotEq else if isRegex && isEqualValue then RegexMatch else NotRegexMatch in { key = name , value = value , op = op } type MatchOperator = Eq | NotEq | RegexMatch | NotRegexMatch matchers : List ( String, MatchOperator ) matchers = [ ( "=~", RegexMatch ) , ( "!~", NotRegexMatch ) , ( "=", Eq ) , ( "!=", NotEq ) ] parseFilter : String -> Maybe (List Matcher) parseFilter = Parser.run filter >> Result.toMaybe parseMatcher : String -> Maybe Matcher parseMatcher = Parser.run matcher >> Result.toMaybe stringifyGroup : List String -> Maybe String stringifyGroup list = if List.isEmpty list then Just "" else if list == [ "alertname" ] then Nothing else Just (String.join "," list) parseGroup : Maybe String -> List String parseGroup maybeGroup = case maybeGroup of Nothing -> [ "alertname" ] Just something -> String.split "," something |> List.filter (String.length >> (<) 0) stringifyFilter : List Matcher -> String stringifyFilter matchers_ = case matchers_ of [] -> "" list -> (list |> List.map stringifyMatcher |> String.join ", " |> (++) "{" ) ++ "}" stringifyMatcher : Matcher -> String stringifyMatcher { key, op, value } = key ++ (matchers |> List.filter (Tuple.second >> (==) op) |> List.head |> Maybe.map Tuple.first |> Maybe.withDefault "" ) ++ Encode.encode 0 (Encode.string value) convertFilterMatcher : Matcher -> Data.Matcher.Matcher convertFilterMatcher { key, op, value } = { name = key , value = value , isRegex = (op == RegexMatch) || (op == NotRegexMatch) , isEqual = Just ((op == Eq) || (op == RegexMatch)) } filter : Parser (List Matcher) filter = Parser.succeed identity |= Parser.sequence { start = "{" , separator = "," , end = "}" , spaces = Parser.spaces , item = item , trailing = Forbidden } |. Parser.end matcher : Parser Matcher matcher = Parser.succeed identity |. Parser.spaces |= item |. Parser.spaces |. Parser.end item : Parser Matcher item = Parser.succeed Matcher |= Parser.variable { start = isVarChar , inner = isVarChar , reserved = Set.empty } |= (matchers |> List.map (\( keyword, matcher_ ) -> Parser.succeed matcher_ |. Parser.keyword keyword ) |> Parser.oneOf ) |= string '"' string : Char -> Parser String string separator = Parser.succeed () |. Parser.token (String.fromChar separator) |. Parser.loop separator stringHelp |> Parser.getChompedString |> Parser.andThen (\str -> case Decode.decodeString Decode.string str of Ok value -> Parser.succeed value Err _ -> Parser.problem "Invalid string" ) stringHelp : Char -> Parser (Parser.Step Char ()) stringHelp separator = Parser.oneOf [ Parser.succeed (Parser.Done ()) |. Parser.token (String.fromChar separator) , Parser.succeed (Parser.Loop separator) |. Parser.chompIf (\char -> char == '\\') |. Parser.chompIf (\_ -> True) , Parser.succeed (Parser.Loop separator) |. Parser.chompIf (\char -> char /= '\\' && char /= separator) ] isVarChar : Char -> Bool isVarChar char = Char.isLower char || Char.isUpper char || (char == '_') || Char.isDigit char withMatchers : List Matcher -> Filter -> Filter withMatchers matchers_ filter_ = { filter_ | text = Just (stringifyFilter matchers_) } silencePreviewFilter : List Data.Matcher.Matcher -> Filter silencePreviewFilter apiMatchers = { nullFilter | text = List.map fromApiMatcher apiMatchers |> stringifyFilter |> Just , showSilenced = Just True , showInhibited = Just True , showMuted = Just True , showActive = Just True } type alias SilenceFormGetParams = { matchers : List Matcher , comment : String } emptySilenceFormGetParams : SilenceFormGetParams emptySilenceFormGetParams = { matchers = [] , comment = "" } ================================================ FILE: ui/app/src/Utils/FormValidation.elm ================================================ module Utils.FormValidation exposing ( ValidatedField , ValidationState(..) , initialField , stringNotEmpty , updateValue , validate ) type ValidationState = Initial | Valid | Invalid String fromResult : Result String a -> ValidationState fromResult result = case result of Ok _ -> Valid Err str -> Invalid str type alias ValidatedField = { value : String , validationState : ValidationState } initialField : String -> ValidatedField initialField value = { value = value , validationState = Initial } updateValue : String -> ValidatedField -> ValidatedField updateValue value field = { field | value = value, validationState = Initial } validate : (String -> Result String a) -> ValidatedField -> ValidatedField validate validator field = { field | validationState = fromResult (validator field.value) } stringNotEmpty : String -> Result String String stringNotEmpty string = if String.isEmpty (String.trim string) then Err "Should not be empty" else Ok string ================================================ FILE: ui/app/src/Utils/Keyboard.elm ================================================ module Utils.Keyboard exposing (keys, onKeyDown, onKeyUp) import Html exposing (Attribute) import Html.Events exposing (keyCode, on) import Json.Decode as Json keys : { backspace : Int , enter : Int , up : Int , down : Int } keys = { backspace = 8 , enter = 13 , up = 38 , down = 40 } onKeyDown : (Int -> msg) -> Attribute msg onKeyDown tagger = on "keydown" (Json.map tagger keyCode) onKeyUp : (Int -> msg) -> Attribute msg onKeyUp tagger = on "keyup" (Json.map tagger keyCode) ================================================ FILE: ui/app/src/Utils/List.elm ================================================ module Utils.List exposing (groupBy, lastElem, mstring, nextElem, zip) import Data.Matcher exposing (Matcher) import Dict exposing (Dict) import Json.Encode as Encode nextElem : a -> List a -> Maybe a nextElem el list = case list of curr :: rest -> if curr == el then List.head rest else nextElem el rest [] -> Nothing lastElem : List a -> Maybe a lastElem = List.foldl (Just >> always) Nothing mstring : Matcher -> String mstring m = let isEqual = case m.isEqual of Nothing -> True Just value -> value sep = if not m.isRegex && isEqual then "=" else if not m.isRegex && not isEqual then "!=" else if m.isRegex && isEqual then "=~" else "!~" in String.join sep [ m.name, Encode.encode 0 (Encode.string m.value) ] {-| Takes a key-fn and a list. Creates a `Dict` which maps the key to a list of matching elements. mary = {id=1, name="Mary"} jack = {id=2, name="Jack"} jill = {id=1, name="Jill"} groupBy .id [mary, jack, jill] == Dict.fromList [(1, [mary, jill]), (2, [jack])] Copied from -} groupBy : (a -> comparable) -> List a -> Dict comparable (List a) groupBy keyfn list = List.foldr (\x acc -> Dict.update (keyfn x) (Maybe.map ((::) x) >> Maybe.withDefault [ x ] >> Just) acc ) Dict.empty list zip : List a -> List b -> List ( a, b ) zip a b = List.map2 (\a1 b1 -> ( a1, b1 )) a b ================================================ FILE: ui/app/src/Utils/Match.elm ================================================ module Utils.Match exposing (consecutiveChars, jaroWinkler) import Char import Utils.List exposing (zip) {-| Adapted from https://blog.art-of-coding.eu/comparing-strings-with-metrics-in-haskell/ -} jaro : String -> String -> Float jaro s1 s2 = if s1 == s2 then 1.0 else let l1 = String.length s1 l2 = String.length s2 z2 = zip (List.range 1 l2) (String.toList s2) |> List.map (Tuple.mapSecond Char.toCode) searchLength = -- A character must be within searchLength spaces of the -- character we are matching against in order to be considered -- a match. -- (//) is integer division, which removes the need to floor -- the result. (max l1 l2 // 2) - 1 m = zip (List.range 1 l1) (String.toList s1) |> List.map (Tuple.mapSecond Char.toCode) |> List.concatMap (charMatch searchLength z2) ml = List.length m t = m |> List.map (transposition z2 >> toFloat >> (*) 0.5) |> List.sum ml1 = toFloat ml / toFloat l1 ml2 = toFloat ml / toFloat l2 mtm = (toFloat ml - t) / toFloat ml in if ml == 0 then 0 else (1 / 3) * (ml1 + ml2 + mtm) winkler : String -> String -> Float -> Float winkler s1 s2 jaro_ = if s1 == "" || s2 == "" then 0.0 else if s1 == s2 then 1.0 else let l = consecutiveChars s1 s2 |> String.length |> toFloat p = 0.25 in jaro_ + ((l * p) * (1.0 - jaro_)) jaroWinkler : String -> String -> Float jaroWinkler s1 s2 = if s1 == "" || s2 == "" then 0.0 else if s1 == s2 then 1.0 else jaro s1 s2 |> winkler s1 s2 consecutiveChars : String -> String -> String consecutiveChars s1 s2 = if s1 == "" || s2 == "" then "" else if s1 == s2 then s1 else cp (String.toList s1) (String.toList s2) [] |> String.fromList cp : List Char -> List Char -> List Char -> List Char cp l1 l2 acc = case ( l1, l2 ) of ( x :: xs, y :: ys ) -> if x == y then cp xs ys (acc ++ [ x ]) else if List.length acc > 0 then -- If we have already found matches, we bail. We only want -- consecutive matches. acc else -- Go through every character in l1 until it matches the first -- character in l2, and then start counting from there. cp l1 ys acc _ -> acc charMatch : Int -> List ( Int, Int ) -> ( Int, Int ) -> List ( Int, Int ) charMatch matchRange list ( p, q ) = list |> List.drop (p - matchRange - 1) |> List.take (p + matchRange) |> List.filter (Tuple.second >> (==) q) transposition : List ( Int, Int ) -> ( Int, Int ) -> Int transposition list ( p, q ) = list |> List.filter (\( x, y ) -> p /= x && q == y ) |> List.length ================================================ FILE: ui/app/src/Utils/String.elm ================================================ module Utils.String exposing (capitalizeFirst, linkify) import Char import String capitalizeFirst : String -> String capitalizeFirst string = case String.uncons string of Nothing -> string Just ( char, rest ) -> String.cons (Char.toUpper char) rest linkify : String -> List (Result String String) linkify string = List.reverse (linkifyHelp (String.words string) []) linkifyHelp : List String -> List (Result String String) -> List (Result String String) linkifyHelp words linkified = case words of [] -> linkified word :: restWords -> if isUrl word then case linkified of (Err lastWord) :: restLinkified -> -- append space to last word linkifyHelp restWords (Ok word :: Err (lastWord ++ " ") :: restLinkified) (Ok _) :: _ -> -- insert space between two links linkifyHelp restWords (Ok word :: Err " " :: linkified) _ -> linkifyHelp restWords (Ok word :: linkified) else case linkified of (Err lastWord) :: restLinkified -> -- concatenate with last word linkifyHelp restWords (Err (lastWord ++ " " ++ word) :: restLinkified) (Ok _) :: _ -> -- insert space after the link linkifyHelp restWords (Err (" " ++ word) :: linkified) _ -> linkifyHelp restWords (Err word :: linkified) isUrl : String -> Bool isUrl = (\b a -> String.startsWith a b) >> (\b a -> List.any a b) [ "http://", "https://" ] ================================================ FILE: ui/app/src/Utils/Types.elm ================================================ module Utils.Types exposing (ApiData(..), Label, Labels, Matcher) type ApiData a = Initial | Loading | Failure String | Success a type alias Matcher = { isRegex : Bool , isEqual : Maybe Bool , name : String , value : String } type alias Matchers = List Matcher type alias Labels = List Label type alias Label = ( String, String ) ================================================ FILE: ui/app/src/Utils/Views.elm ================================================ module Utils.Views exposing ( apiData , checkbox , error , labelButton , linkifyText , loading , tab , validatedField , validatedTextareaField ) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (onBlur, onCheck, onClick, onInput) import Utils.FormValidation exposing (ValidatedField, ValidationState(..)) import Utils.String import Utils.Types as Types tab : tab -> tab -> (tab -> msg) -> List (Html msg) -> Html msg tab tab_ currentTab msg content = li [ class "nav-item" ] [ if tab_ == currentTab then span [ class "nav-link active" ] content else button [ style "background" "transparent" , style "font" "inherit" , style "cursor" "pointer" , style "outline" "none" , class "nav-link" , onClick (msg tab_) ] content ] labelButton : Maybe msg -> String -> Html msg labelButton maybeMsg labelText = case maybeMsg of Nothing -> span [ class "btn btn-sm btn-light border mr-2 mb-2" , style "user-select" "text" , style "-moz-user-select" "text" , style "-webkit-user-select" "text" ] [ text labelText ] Just msg -> button [ class "btn btn-sm btn-light border mr-2 mb-2" , onClick msg ] [ span [ class "text-muted" ] [ text labelText ] ] linkifyText : String -> List (Html msg) linkifyText str = List.map (\result -> case result of Ok link -> a [ href link, target "_blank" ] [ text link ] Err txt -> text txt ) (Utils.String.linkify str) checkbox : String -> Bool -> (Bool -> msg) -> Html msg checkbox name status msg = label [ class "f6 dib mb2 mr2 d-flex align-items-center" ] [ input [ type_ "checkbox", checked status, onCheck msg ] [] , span [ class "pl-2" ] [ text <| " " ++ name ] ] validatedField : (List (Attribute msg) -> List (Html msg) -> Html msg) -> String -> String -> (String -> msg) -> msg -> ValidatedField -> Html msg validatedField htmlField labelText classes inputMsg blurMsg field = case field.validationState of Valid -> div [ class <| "d-flex flex-column form-group has-success " ++ classes ] [ label [] [ strong [] [ text labelText ] ] , htmlField [ value field.value , onInput inputMsg , onBlur blurMsg , class "form-control form-control-success" ] [] ] Initial -> div [ class <| "d-flex flex-column form-group " ++ classes ] [ label [] [ strong [] [ text labelText ] ] , htmlField [ value field.value , onInput inputMsg , onBlur blurMsg , class "form-control" ] [] ] Invalid error_ -> div [ class <| "d-flex flex-column form-group has-danger " ++ classes ] [ label [] [ strong [] [ text labelText ] ] , htmlField [ value field.value , onInput inputMsg , onBlur blurMsg , class "form-control form-control-danger" ] [] , div [ class "form-control-feedback" ] [ text error_ ] ] validatedTextareaField : String -> String -> (String -> msg) -> msg -> ValidatedField -> Html msg validatedTextareaField labelText classes inputMsg blurMsg field = let lineCount = String.lines field.value |> List.length |> clamp 3 15 in case field.validationState of Valid -> div [ class <| "d-flex flex-column form-group has-success " ++ classes ] [ label [] [ strong [] [ text labelText ] ] , textarea [ value field.value , onInput inputMsg , onBlur blurMsg , class "form-control form-control-success" , rows lineCount , disableGrammarly ] [] ] Initial -> div [ class <| "d-flex flex-column form-group " ++ classes ] [ label [] [ strong [] [ text labelText ] ] , textarea [ value field.value , onInput inputMsg , onBlur blurMsg , class "form-control" , rows lineCount , disableGrammarly ] [] ] Invalid error_ -> div [ class <| "d-flex flex-column form-group has-danger " ++ classes ] [ label [] [ strong [] [ text labelText ] ] , textarea [ value field.value , onInput inputMsg , onBlur blurMsg , class "form-control form-control-danger" , rows lineCount , disableGrammarly ] [] , div [ class "form-control-feedback" ] [ text error_ ] ] apiData : (a -> Html msg) -> Types.ApiData a -> Html msg apiData onSuccess data = case data of Types.Success payload -> onSuccess payload Types.Loading -> loading Types.Initial -> loading Types.Failure msg -> error msg loading : Html msg loading = div [] [ span [] [ text "Loading..." ] ] error : String -> Html msg error err = div [ class "alert alert-warning" ] [ text (Utils.String.capitalizeFirst err) ] disableGrammarly : Html.Attribute msg disableGrammarly = attribute "data-gramm_editor" "false" ================================================ FILE: ui/app/src/Views/AlertList/AlertView.elm ================================================ module Views.AlertList.AlertView exposing (addLabelMsg, view) import Data.GettableAlert exposing (GettableAlert) import Dict import Html exposing (..) import Html.Attributes exposing (class, href, style, title, value) import Html.Events exposing (onClick) import Types exposing (Msg(..)) import Url exposing (percentEncode) import Utils.Filter import Views.AlertList.Types exposing (AlertListMsg(..)) import Views.FilterBar.Types as FilterBarTypes import Views.Shared.Alert exposing (annotation, annotationsButton, generatorUrlButton, titleView) import Views.SilenceForm.Parsing exposing (newSilenceFromAlertLabels) view : List ( String, String ) -> Maybe String -> GettableAlert -> Html Msg view labels maybeActiveId alert = let -- remove the grouping labels, and bring the alertname to front ungroupedLabels = alert.labels |> Dict.toList |> List.filter ((\b a -> List.member a b) labels >> not) |> List.partition (Tuple.first >> (==) "alertname") |> (\( a, b ) -> a ++ b) in li [ -- speedup rendering in Chrome, because list-group-item className -- creates a new layer in the rendering engine style "position" "static" , class "align-items-start list-group-item border-0 p-0 mb-4" ] [ div [ class "w-100 mb-2 d-flex align-items-start" ] [ titleView alert , if Dict.size alert.annotations > 0 then annotationsButton maybeActiveId alert |> Html.map (\msg -> MsgForAlertList (SetActive msg)) else text "" , case alert.generatorURL of Just url -> generatorUrlButton url Nothing -> text "" , silenceButton alert , inhibitedIcon alert , mutedIcon alert , linkButton alert ] , if maybeActiveId == Just alert.fingerprint then table [ class "table w-100 mb-1" ] (List.map annotation <| Dict.toList alert.annotations) else text "" , div [] (List.map labelButton ungroupedLabels) ] labelButton : ( String, String ) -> Html Msg labelButton ( key, val ) = div [ class "btn-group mr-2 mb-2" ] [ span [ class "btn btn-sm border-right-0 text-muted" -- have to reset bootstrap button styles to make the text selectable , style "user-select" "initial" -- have to reset bootstrap button styles to make the text selectable , style "-moz-user-select" "initial" -- have to reset bootstrap button styles to make the text selectable , style "-webkit-user-select" "initial" -- have to reset bootstrap button styles to make the text selectable , style "border-color" "#ccc" ] [ text (key ++ "=\"" ++ val ++ "\"") ] , button [ class "btn btn-sm bg-light btn-outline-secondary" , onClick (addLabelMsg ( key, val )) , title "Filter by this label" ] [ text "+" ] ] addLabelMsg : ( String, String ) -> Msg addLabelMsg ( key, value ) = FilterBarTypes.AddFilterMatcher False { key = key , op = Utils.Filter.Eq , value = value } |> MsgForFilterBar |> MsgForAlertList linkButton : GettableAlert -> Html Msg linkButton alert = let link = alert.labels |> Dict.toList |> List.map (\( k, v ) -> Utils.Filter.Matcher k Utils.Filter.Eq v) |> Utils.Filter.stringifyFilter |> percentEncode |> (++) "#/alerts?filter=" in a [ class "btn btn-outline-info border-0" , href link ] [ i [ class "fa fa-link mr-2" ] [] , text "Link" ] silenceButton : GettableAlert -> Html Msg silenceButton alert = case List.head alert.status.silencedBy of Just sId -> a [ class "btn btn-outline-danger border-0" , href ("#/silences/" ++ sId) ] [ i [ class "fa fa-bell-slash mr-2" ] [] , text "Silenced" ] Nothing -> a [ class "btn btn-outline-info border-0" , href (newSilenceFromAlertLabels alert.labels) ] [ i [ class "fa fa-bell-slash-o mr-2" ] [] , text "Silence" ] inhibitedIcon : GettableAlert -> Html Msg inhibitedIcon alert = case List.head alert.status.inhibitedBy of Just _ -> span [ class "btn btn-outline-danger border-0" ] [ i [ class "fa fa-eye-slash mr-2" ] [] , text "Inhibited" ] Nothing -> text "" mutedIcon : GettableAlert -> Html Msg mutedIcon alert = case List.head alert.status.mutedBy of Just _ -> span [ class "btn btn-outline-danger border-0" ] [ i [ class "fa fa-bell-slash mr-2" ] [] , text "Muted" ] Nothing -> text "" ================================================ FILE: ui/app/src/Views/AlertList/Parsing.elm ================================================ module Views.AlertList.Parsing exposing (alertsParser) import Url.Parser exposing ((), Parser, map, s) import Url.Parser.Query as Query import Utils.Filter exposing (Filter, MatchOperator(..)) boolParam : String -> Query.Parser Bool boolParam name = Query.custom name (List.head >> (/=) Nothing) maybeBoolParam : String -> Query.Parser (Maybe Bool) maybeBoolParam name = Query.custom name (List.head >> Maybe.map (String.toLower >> (/=) "false")) alertsParser : Parser (Filter -> a) a alertsParser = s "alerts" Query.string "filter" Query.string "group" boolParam "customGrouping" Query.string "receiver" maybeBoolParam "silenced" maybeBoolParam "inhibited" maybeBoolParam "muted" maybeBoolParam "active" |> map Filter ================================================ FILE: ui/app/src/Views/AlertList/Types.elm ================================================ module Views.AlertList.Types exposing ( AlertListMsg(..) , Model , Tab(..) , initAlertList ) import Browser.Navigation exposing (Key) import Data.AlertGroup exposing (AlertGroup) import Data.GettableAlert exposing (GettableAlert) import Set exposing (Set) import Utils.Types exposing (ApiData(..)) import Views.FilterBar.Types as FilterBar import Views.GroupBar.Types as GroupBar import Views.ReceiverBar.Types as ReceiverBar type AlertListMsg = AlertsFetched (ApiData (List GettableAlert)) | AlertGroupsFetched (ApiData (List AlertGroup)) | FetchAlerts | MsgForReceiverBar ReceiverBar.Msg | MsgForFilterBar FilterBar.Msg | MsgForGroupBar GroupBar.Msg | ToggleSilenced Bool | ToggleInhibited Bool | ToggleMuted Bool | SetActive (Maybe String) | ActiveGroups Int | SetTab Tab | ToggleExpandAll Bool type Tab = FilterTab | GroupTab type alias Model = { alerts : ApiData (List GettableAlert) , alertGroups : ApiData (List AlertGroup) , receiverBar : ReceiverBar.Model , groupBar : GroupBar.Model , filterBar : FilterBar.Model , tab : Tab , activeId : Maybe String , activeGroups : Set Int , key : Key , expandAll : Bool } initAlertList : Key -> Bool -> Model initAlertList key expandAll = { alerts = Initial , alertGroups = Initial , receiverBar = ReceiverBar.initReceiverBar key , groupBar = GroupBar.initGroupBar key , filterBar = FilterBar.initFilterBar [] , tab = FilterTab , activeId = Nothing , activeGroups = Set.empty , key = key , expandAll = expandAll } ================================================ FILE: ui/app/src/Views/AlertList/Updates.elm ================================================ port module Views.AlertList.Updates exposing (update) import Alerts.Api as Api import Browser.Navigation as Navigation import Data.AlertGroup exposing (AlertGroup) import Dict import Set import Task import Types exposing (Msg(..)) import Utils.Filter exposing (Filter) import Utils.List import Utils.Types exposing (ApiData(..)) import Views.AlertList.Types exposing (AlertListMsg(..), Model, Tab(..)) import Views.FilterBar.Updates as FilterBar import Views.GroupBar.Updates as GroupBar import Views.ReceiverBar.Updates as ReceiverBar update : AlertListMsg -> Model -> Filter -> String -> String -> ( Model, Cmd Types.Msg ) update msg ({ groupBar, alerts, filterBar, receiverBar, alertGroups } as model) filter apiUrl basePath = let alertsUrl = basePath ++ "#/alerts" filteredUrl = Utils.Filter.toUrl alertsUrl in case msg of AlertGroupsFetched listOfAlertGroups -> ( { model | alertGroups = listOfAlertGroups } , Cmd.none ) AlertsFetched listOfAlerts -> let ( groups_, groupBar_ ) = case listOfAlerts of Success ungroupedAlerts -> let groups = ungroupedAlerts |> Utils.List.groupBy (.labels >> Dict.toList >> List.filter (\( key, _ ) -> List.member key groupBar.fields)) |> Dict.toList |> List.map (\( labels, alerts_ ) -> AlertGroup (Dict.fromList labels) { name = "unknown" } alerts_ ) newGroupBar = { groupBar | list = List.concatMap (.labels >> Dict.toList) ungroupedAlerts |> List.map Tuple.first |> Set.fromList } in ( Success groups, newGroupBar ) Initial -> ( Initial, groupBar ) Loading -> ( Loading, groupBar ) Failure e -> ( Failure e, groupBar ) in ( { model | alerts = listOfAlerts , alertGroups = groups_ , groupBar = groupBar_ } , Cmd.none ) FetchAlerts -> let newGroupBar = GroupBar.setFields filter groupBar newFilterBar = FilterBar.setMatchers filter filterBar in ( { model | alerts = if filter.customGrouping then Loading else alerts , alertGroups = if filter.customGrouping then alertGroups else Loading , filterBar = newFilterBar , groupBar = newGroupBar , activeId = Nothing , activeGroups = Set.empty } , Cmd.batch [ if filter.customGrouping then Api.fetchAlerts apiUrl filter |> Cmd.map (AlertsFetched >> MsgForAlertList) else Api.fetchAlertGroups apiUrl filter |> Cmd.map (AlertGroupsFetched >> MsgForAlertList) , ReceiverBar.fetchReceivers apiUrl |> Cmd.map (MsgForReceiverBar >> MsgForAlertList) ] ) ToggleSilenced showSilenced -> ( model , Navigation.pushUrl model.key (filteredUrl { filter | showSilenced = Just showSilenced }) ) ToggleInhibited showInhibited -> ( model , Navigation.pushUrl model.key (filteredUrl { filter | showInhibited = Just showInhibited }) ) ToggleMuted showMuted -> ( model , Navigation.pushUrl model.key (filteredUrl { filter | showMuted = Just showMuted }) ) SetTab tab -> ( { model | tab = tab }, Cmd.none ) MsgForFilterBar subMsg -> let ( newFilterBar, shouldFilter, cmd ) = FilterBar.update subMsg filterBar filterBarCmd = Cmd.map (MsgForFilterBar >> MsgForAlertList) cmd newUrl = filteredUrl (Utils.Filter.withMatchers newFilterBar.matchers filter) alertsCmd = if shouldFilter then Cmd.batch [ Navigation.pushUrl model.key newUrl , filterBarCmd ] else filterBarCmd in ( { model | filterBar = newFilterBar, tab = FilterTab }, alertsCmd ) MsgForGroupBar subMsg -> let ( newGroupBar, cmd ) = GroupBar.update alertsUrl filter subMsg groupBar in ( { model | groupBar = newGroupBar }, Cmd.map (MsgForGroupBar >> MsgForAlertList) cmd ) MsgForReceiverBar subMsg -> let ( newReceiverBar, cmd ) = ReceiverBar.update alertsUrl filter subMsg receiverBar in ( { model | receiverBar = newReceiverBar }, Cmd.map (MsgForReceiverBar >> MsgForAlertList) cmd ) SetActive maybeId -> ( { model | activeId = maybeId }, Cmd.none ) ActiveGroups activeGroup -> let activeGroups_ = if Set.member activeGroup model.activeGroups then Set.remove activeGroup model.activeGroups else Set.insert activeGroup model.activeGroups in ( { model | activeGroups = activeGroups_, expandAll = False }, persistGroupExpandAll False ) ToggleExpandAll expanded -> let allGroupLabels = case ( alertGroups, expanded ) of ( Success groups, True ) -> List.range 0 (List.length groups) |> Set.fromList _ -> Set.empty in ( { model | expandAll = expanded , activeGroups = allGroupLabels } , Cmd.batch [ persistGroupExpandAll expanded , Task.succeed expanded |> Task.perform SetGroupExpandAll ] ) port persistGroupExpandAll : Bool -> Cmd msg ================================================ FILE: ui/app/src/Views/AlertList/Views.elm ================================================ module Views.AlertList.Views exposing (view) import Data.AlertGroup exposing (AlertGroup) import Data.GettableAlert exposing (GettableAlert) import Data.Receiver exposing (Receiver) import Dict import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) import Set exposing (Set) import Types exposing (Msg(..)) import Utils.Filter exposing (Filter) import Utils.Types exposing (ApiData(..), Labels) import Utils.Views import Views.AlertList.AlertView as AlertView import Views.AlertList.Types exposing (AlertListMsg(..), Model, Tab(..)) import Views.FilterBar.Views as FilterBar import Views.GroupBar.Views as GroupBar import Views.ReceiverBar.Views as ReceiverBar renderCheckbox : String -> Maybe Bool -> (Bool -> AlertListMsg) -> Html Msg renderCheckbox textLabel maybeChecked toggleMsg = li [ class "nav-item" ] [ div [ class "mt-1 ml-1 custom-control custom-checkbox" ] [ input [ type_ "checkbox" , id textLabel , class "custom-control-input" , checked (Maybe.withDefault False maybeChecked) , onCheck (toggleMsg >> MsgForAlertList) ] [] , label [ class "custom-control-label", for textLabel ] [ text textLabel ] ] ] groupTabName : Bool -> Html msg groupTabName customGrouping = if customGrouping then text "Group (custom)" else text "Group" view : Model -> Filter -> Html Msg view { alertGroups, groupBar, filterBar, receiverBar, tab, activeId, activeGroups, expandAll } filter = div [] [ div [ class "card mb-3" ] [ div [ class "card-header" ] [ ul [ class "nav nav-tabs card-header-tabs" ] [ Utils.Views.tab FilterTab tab (SetTab >> MsgForAlertList) [ text "Filter" ] , Utils.Views.tab GroupTab tab (SetTab >> MsgForAlertList) [ groupTabName filter.customGrouping ] , receiverBar |> ReceiverBar.view filter.receiver |> Html.map (MsgForReceiverBar >> MsgForAlertList) , renderCheckbox "Silenced" filter.showSilenced ToggleSilenced , renderCheckbox "Inhibited" filter.showInhibited ToggleInhibited , renderCheckbox "Muted" filter.showMuted ToggleMuted ] ] , div [ class "card-body" ] [ case tab of FilterTab -> Html.map (MsgForFilterBar >> MsgForAlertList) (FilterBar.view { showSilenceButton = True } filterBar) GroupTab -> Html.map (MsgForGroupBar >> MsgForAlertList) (GroupBar.view groupBar filter.customGrouping) ] ] , div [] [ button [ class "btn btn-outline-secondary border-0 mr-1 mb-3" , onClick (MsgForAlertList (ToggleExpandAll (not expandAll))) ] (if expandAll then [ i [ class "fa fa-minus mr-3" ] [], text "Collapse all groups" ] else [ i [ class "fa fa-plus mr-3" ] [], text "Expand all groups" ] ) ] , Utils.Views.apiData (defaultAlertGroups activeId activeGroups expandAll) alertGroups ] defaultAlertGroups : Maybe String -> Set Int -> Bool -> List AlertGroup -> Html Msg defaultAlertGroups activeId activeGroups expandAll groups = case groups of [] -> Utils.Views.error "No alert groups found" [ { labels, receiver, alerts } ] -> let labels_ = Dict.toList labels in alertGroup activeId (Set.singleton 0) receiver labels_ alerts 0 expandAll _ -> div [ class "pl-5" ] (List.indexedMap (\index group -> alertGroup activeId activeGroups group.receiver (Dict.toList group.labels) group.alerts index expandAll ) groups ) alertGroup : Maybe String -> Set Int -> Receiver -> Labels -> List GettableAlert -> Int -> Bool -> Html Msg alertGroup activeId activeGroups receiver labels alerts groupId expandAll = let groupActive = expandAll || Set.member groupId activeGroups labels_ = case labels of [] -> [ span [ class "btn btn-secondary mr-1 mb-1" ] [ text "Not grouped" ] ] _ -> List.map (\( key, value ) -> div [ class "btn-group mr-1 mb-1" ] [ span [ class "btn text-muted" , style "user-select" "initial" , style "-moz-user-select" "initial" , style "-webkit-user-select" "initial" , style "border-color" "#5bc0de" ] [ text (key ++ "=\"" ++ value ++ "\"") ] , button [ class "btn btn-outline-info" , onClick (AlertView.addLabelMsg ( key, value )) , title "Filter by this label" ] [ text "+" ] ] ) labels expandButton = expandAlertGroup groupActive groupId receiver |> Html.map (\msg -> MsgForAlertList (ActiveGroups msg)) alertCount = List.length alerts alertText = if alertCount == 1 then String.fromInt alertCount ++ " alert" else String.fromInt alertCount ++ " alerts" alertEl = [ span [ class "ml-1 mb-0", style "white-space" "nowrap" ] [ text alertText ] ] in div [] [ div [ class "mb-3" ] (expandButton :: labels_ ++ alertEl) , if groupActive then ul [ class "list-group mb-0" ] (List.map (AlertView.view labels activeId) alerts) else text "" ] expandAlertGroup : Bool -> Int -> Receiver -> Html Int expandAlertGroup expanded groupId receiver = let icon = if expanded then "fa-minus" else "fa-plus" in button [ onClick groupId , class "btn btn-outline-info border-0 mr-1 mb-1" , style "margin-left" "-3rem" ] [ i [ class ("fa " ++ icon) , class "mr-2" ] [] , text receiver.name ] ================================================ FILE: ui/app/src/Views/FilterBar/Types.elm ================================================ module Views.FilterBar.Types exposing (Model, Msg(..), initFilterBar) import Utils.Filter type alias Model = { matchers : List Utils.Filter.Matcher , backspacePressed : Bool , matcherText : String } type Msg = AddFilterMatcher Bool Utils.Filter.Matcher | DeleteFilterMatcher Bool Utils.Filter.Matcher | PressingBackspace Bool | UpdateMatcherText String | Noop {-| A note about the `backspacePressed` attribute: Holding down the backspace removes (one by one) each last character in the input, and the whole time sends multiple keyDown events. This is a guard so that if a user holds down backspace to remove the text in the input, they won't accidentally hold backspace too long and then delete the preceding matcher as well. So, once a user holds backspace to clear an input, they have to then lift up the key and press it again to proceed to deleting the next matcher. -} initFilterBar : List Utils.Filter.Matcher -> Model initFilterBar matchers = { matchers = matchers , backspacePressed = False , matcherText = "" } ================================================ FILE: ui/app/src/Views/FilterBar/Updates.elm ================================================ module Views.FilterBar.Updates exposing (setMatchers, update) import Browser.Dom as Dom import Task import Utils.Filter exposing (Filter, parseFilter) import Views.FilterBar.Types exposing (Model, Msg(..)) {-| Returns a triple where the Bool component notifies whether the matchers have changed. -} update : Msg -> Model -> ( Model, Bool, Cmd Msg ) update msg model = case msg of AddFilterMatcher emptyMatcherText matcher -> ( { model | matchers = if List.member matcher model.matchers then model.matchers else model.matchers ++ [ matcher ] , matcherText = if emptyMatcherText then "" else model.matcherText } , True , Dom.focus "filter-bar-matcher" |> Task.attempt (always Noop) ) DeleteFilterMatcher setMatcherText matcher -> ( { model | matchers = List.filter ((/=) matcher) model.matchers , matcherText = if setMatcherText then Utils.Filter.stringifyMatcher matcher else model.matcherText } , True , Dom.focus "filter-bar-matcher" |> Task.attempt (always Noop) ) UpdateMatcherText value -> ( { model | matcherText = value }, False, Cmd.none ) PressingBackspace isPressed -> ( { model | backspacePressed = isPressed }, False, Cmd.none ) Noop -> ( model, False, Cmd.none ) setMatchers : Filter -> Model -> Model setMatchers filter model = { model | matchers = filter.text |> Maybe.andThen parseFilter |> Maybe.withDefault [] } ================================================ FILE: ui/app/src/Views/FilterBar/Views.elm ================================================ module Views.FilterBar.Views exposing (view) import Html exposing (Html, a, button, div, i, input, small, text) import Html.Attributes exposing (class, disabled, href, id, spellcheck, style, value) import Html.Events exposing (onClick, onInput) import Utils.Filter exposing (Matcher, convertFilterMatcher) import Utils.Keyboard exposing (onKeyDown, onKeyUp) import Utils.List import Views.FilterBar.Types exposing (Model, Msg(..)) import Views.SilenceForm.Parsing exposing (newSilenceFromMatchers) keys : { backspace : Int , enter : Int } keys = { backspace = 8 , enter = 13 } viewMatcher : Matcher -> Html Msg viewMatcher matcher = div [ class "col col-auto" ] [ div [ class "btn-group mr-2 mb-2" ] [ button [ class "btn btn-outline-info" , onClick (DeleteFilterMatcher True matcher) ] [ text <| Utils.Filter.stringifyMatcher matcher ] , button [ class "btn btn-outline-danger" , onClick (DeleteFilterMatcher False matcher) ] [ text "×" ] ] ] viewMatchers : List Matcher -> List (Html Msg) viewMatchers matchers = matchers |> List.map viewMatcher view : { showSilenceButton : Bool } -> Model -> Html Msg view { showSilenceButton } { matchers, matcherText, backspacePressed } = let maybeMatcher = Utils.Filter.parseMatcher matcherText maybeLastMatcher = Utils.List.lastElem matchers className = if matcherText == "" then "" else case maybeMatcher of Just _ -> "has-success" Nothing -> "has-danger" keyDown key = if key == keys.enter then maybeMatcher |> Maybe.map (AddFilterMatcher True) |> Maybe.withDefault Noop else if key == keys.backspace then if matcherText == "" then case ( backspacePressed, maybeLastMatcher ) of ( False, Just lastMatcher ) -> DeleteFilterMatcher True lastMatcher _ -> Noop else PressingBackspace True else Noop keyUp key = if key == keys.backspace then PressingBackspace False else Noop onClickAttr = maybeMatcher |> Maybe.map (AddFilterMatcher True) |> Maybe.withDefault Noop |> onClick isDisabled = maybeMatcher == Nothing dataMatchers = matchers |> List.map convertFilterMatcher in div [ class "row no-gutters align-items-start" ] (viewMatchers matchers ++ [ div [ class ("col " ++ className) , style "min-width" (if showSilenceButton then "300px" else "200px" ) ] [ div [ class "row no-gutters align-content-stretch" ] [ div [ class "col input-group" ] [ input [ id "filter-bar-matcher" , class "form-control" -- Setting spellcheck=false on an input element will disable smartquotes in iOS. , spellcheck False , value matcherText , onKeyDown keyDown , onKeyUp keyUp , onInput UpdateMatcherText ] [] , div [ class "input-group-append" ] [ button [ class "btn btn-primary", disabled isDisabled, onClickAttr ] [ text "+" ] ] ] , if showSilenceButton then div [ class "col col-auto ml-2" ] [ div [ class "input-group" ] [ a [ class "btn btn-outline-info" , href (newSilenceFromMatchers dataMatchers) ] [ i [ class "fa fa-bell-slash-o mr-2" ] [] , text "Silence" ] ] ] else text "" ] , small [ class "form-text text-muted" ] [ text "Custom matcher, e.g." , button [ class "btn btn-link btn-sm align-baseline" , onClick (UpdateMatcherText exampleMatcher) ] [ text exampleMatcher ] ] ] ] ) exampleMatcher : String exampleMatcher = "env=\"production\"" ================================================ FILE: ui/app/src/Views/GroupBar/Types.elm ================================================ module Views.GroupBar.Types exposing (Model, Msg(..), initGroupBar) import Browser.Navigation exposing (Key) import Set exposing (Set) type alias Model = { list : Set String , fieldText : String , fields : List String , matches : List String , backspacePressed : Bool , focused : Bool , resultsHovered : Bool , maybeSelectedMatch : Maybe String , key : Key } type Msg = AddField Bool String | DeleteField Bool String | Select (Maybe String) | PressingBackspace Bool | Focus Bool | ResultsHovered Bool | UpdateFieldText String | CustomGrouping Bool | Noop initGroupBar : Key -> Model initGroupBar key = { list = Set.empty , fieldText = "" , fields = [] , matches = [] , focused = False , resultsHovered = False , backspacePressed = False , maybeSelectedMatch = Nothing , key = key } ================================================ FILE: ui/app/src/Views/GroupBar/Updates.elm ================================================ module Views.GroupBar.Updates exposing (setFields, update) import Browser.Dom as Dom import Browser.Navigation as Navigation import Set import Task import Utils.Filter exposing (Filter, parseGroup, stringifyGroup) import Utils.Match exposing (jaroWinkler) import Views.GroupBar.Types exposing (Model, Msg(..)) update : String -> Filter -> Msg -> Model -> ( Model, Cmd Msg ) update url filter msg model = case msg of CustomGrouping customGrouping -> ( model , Cmd.batch [ Navigation.pushUrl model.key (Utils.Filter.toUrl url { filter | customGrouping = customGrouping }) , Dom.focus "group-by-field" |> Task.attempt (always Noop) ] ) AddField emptyFieldText text -> immediatelyFilter url filter { model | fields = model.fields ++ [ text ] , matches = [] , fieldText = if emptyFieldText then "" else model.fieldText } DeleteField setFieldText text -> immediatelyFilter url filter { model | fields = List.filter ((/=) text) model.fields , matches = [] , fieldText = if setFieldText then text else model.fieldText } Select maybeSelectedMatch -> ( { model | maybeSelectedMatch = maybeSelectedMatch }, Cmd.none ) Focus focused -> ( { model | focused = focused , maybeSelectedMatch = Nothing } , Cmd.none ) ResultsHovered resultsHovered -> ( { model | resultsHovered = resultsHovered } , Cmd.none ) PressingBackspace pressed -> ( { model | backspacePressed = pressed }, Cmd.none ) UpdateFieldText text -> updateAutoComplete { model | fieldText = text } Noop -> ( model, Cmd.none ) immediatelyFilter : String -> Filter -> Model -> ( Model, Cmd Msg ) immediatelyFilter url filter model = let newFilter = { filter | group = stringifyGroup model.fields } in ( model , Cmd.batch [ Navigation.pushUrl model.key (Utils.Filter.toUrl url newFilter) , Dom.focus "group-by-field" |> Task.attempt (always Noop) ] ) setFields : Filter -> Model -> Model setFields filter model = { model | fields = parseGroup filter.group } updateAutoComplete : Model -> ( Model, Cmd Msg ) updateAutoComplete model = ( { model | matches = if String.isEmpty model.fieldText then [] else if String.contains " " model.fieldText then model.matches else -- TODO: How many matches do we want to show? -- NOTE: List.reverse is used because our scale is (0.0, 1.0), -- but we want the higher values to be in the front of the -- list. Set.toList model.list |> List.filter ((\a -> List.member a model.fields) >> not) |> List.sortBy (jaroWinkler model.fieldText) |> List.reverse |> List.take 10 , maybeSelectedMatch = Nothing } , Cmd.none ) ================================================ FILE: ui/app/src/Views/GroupBar/Views.elm ================================================ module Views.GroupBar.Views exposing (view) import Html exposing (Html, a, button, div, input, small, text) import Html.Attributes exposing (class, disabled, id, style, value) import Html.Events exposing (onBlur, onClick, onFocus, onInput, onMouseEnter, onMouseLeave) import Set import Utils.Keyboard exposing (keys, onKeyDown, onKeyUp) import Utils.List import Utils.Views import Views.GroupBar.Types exposing (Model, Msg(..)) view : Model -> Bool -> Html Msg view ({ list, fieldText, fields } as model) customGrouping = let isDisabled = not (Set.member fieldText list) || List.member fieldText fields className = if String.isEmpty fieldText then "" else if isDisabled then "has-danger" else "has-success" checkbox = div [ class "mb-3" ] [ Utils.Views.checkbox "Enable custom grouping" customGrouping CustomGrouping ] in if customGrouping then div [] [ checkbox , div [ class "row no-gutters align-items-start" ] (List.map viewField fields ++ [ div [ class ("col " ++ className) , style "min-width" "200px" ] [ textInputField isDisabled model , exampleField fields , autoCompleteResults model ] ] ) ] else checkbox exampleField : List String -> Html Msg exampleField fields = if List.member "alertname" fields then small [ class "form-text text-muted" ] [ text "Label key for grouping alerts" ] else small [ class "form-text text-muted" ] [ text "Label key for grouping alerts, e.g." , button [ class "btn btn-link btn-sm align-baseline" , onClick (UpdateFieldText "alertname") ] [ text "alertname" ] ] textInputField : Bool -> Model -> Html Msg textInputField isDisabled { fieldText, matches, maybeSelectedMatch, fields, backspacePressed } = let onClickMsg = if isDisabled then Noop else AddField True fieldText nextMatch = maybeSelectedMatch |> Maybe.map ((\b a -> Utils.List.nextElem a b) <| matches) |> Maybe.withDefault (List.head matches) prevMatch = maybeSelectedMatch |> Maybe.map ((\b a -> Utils.List.nextElem a b) <| List.reverse matches) |> Maybe.withDefault (Utils.List.lastElem matches) keyDown key = if key == keys.down then Select nextMatch else if key == keys.up then Select prevMatch else if key == keys.enter then if not isDisabled then AddField True fieldText else maybeSelectedMatch |> Maybe.map (AddField True) |> Maybe.withDefault Noop else if key == keys.backspace then if fieldText == "" then case ( Utils.List.lastElem fields, backspacePressed ) of ( Just lastField, False ) -> DeleteField True lastField _ -> Noop else PressingBackspace True else Noop keyUp key = if key == keys.backspace then PressingBackspace False else Noop in div [ class "input-group" ] [ input [ id "group-by-field" , class "form-control" , value fieldText , onKeyDown keyDown , onKeyUp keyUp , onInput UpdateFieldText , onFocus (Focus True) , onBlur (Focus False) ] [] , div [ class "input-group-append" ] [ button [ class "btn btn-primary", disabled isDisabled, onClick onClickMsg ] [ text "+" ] ] ] autoCompleteResults : Model -> Html Msg autoCompleteResults { maybeSelectedMatch, focused, resultsHovered, matches } = let autoCompleteClass = if (focused || resultsHovered) && not (List.isEmpty matches) then "show" else "" in div [ class ("autocomplete-menu " ++ autoCompleteClass) , onMouseEnter (ResultsHovered True) , onMouseLeave (ResultsHovered False) ] [ matches |> List.map (matchedField maybeSelectedMatch) |> div [ class "dropdown-menu" ] ] matchedField : Maybe String -> String -> Html Msg matchedField maybeSelectedMatch field = let className = if maybeSelectedMatch == Just field then "active" else "" in button [ class ("dropdown-item " ++ className) , onClick (AddField True field) ] [ text field ] viewField : String -> Html Msg viewField field = div [ class "col col-auto" ] [ div [ class "btn-group mr-2 mb-2" ] [ button [ class "btn btn-outline-info" , onClick (DeleteField True field) ] [ text field ] , button [ class "btn btn-outline-danger" , onClick (DeleteField False field) ] [ text "×" ] ] ] ================================================ FILE: ui/app/src/Views/NavBar/Types.elm ================================================ module Views.NavBar.Types exposing (Tab, alertsTab, noneTab, settingsTab, silencesTab, statusTab, tabs) type alias Tab = { link : String , name : String } alertsTab : Tab alertsTab = { link = "#/alerts", name = "Alerts" } silencesTab : Tab silencesTab = { link = "#/silences", name = "Silences" } statusTab : Tab statusTab = { link = "#/status", name = "Status" } settingsTab : Tab settingsTab = { link = "#/settings", name = "Settings" } helpTab : Tab helpTab = { link = "https://prometheus.io/docs/alerting/alertmanager/", name = "Documentation" } noneTab : Tab noneTab = { link = "", name = "" } tabs : List Tab tabs = [ alertsTab, silencesTab, statusTab, settingsTab, helpTab ] ================================================ FILE: ui/app/src/Views/NavBar/Views.elm ================================================ module Views.NavBar.Views exposing (navBar) import Html exposing (Html, a, div, header, li, nav, text, ul) import Html.Attributes exposing (class, href, style, title) import Types exposing (Route(..)) import Views.NavBar.Types exposing (Tab, alertsTab, noneTab, settingsTab, silencesTab, statusTab, tabs) navBar : Route -> Html msg navBar currentRoute = header [ class "navbar navbar-expand-md navbar-light bg-light mb-5 pt-3 pb-3" , style "border-bottom" "1px solid rgba(0, 0, 0, .125)" ] [ nav [ class "container" ] [ a [ class "navbar-brand", href "#" ] [ text "Alertmanager" ] , ul [ class "navbar-nav" ] (navBarItems currentRoute) , case currentRoute of SilenceFormEditRoute _ -> text "" SilenceFormNewRoute _ -> text "" _ -> div [ class "form-inline ml-auto" ] [ a [ class "btn btn-outline-info" , href "#/silences/new" ] [ text "New Silence" ] ] ] ] navBarItems : Route -> List (Html msg) navBarItems currentRoute = List.map (navBarItem currentRoute) tabs navBarItem : Route -> Tab -> Html msg navBarItem currentRoute tab = li [ class <| "nav-item" ++ isActive currentRoute tab ] [ a [ class "nav-link", href tab.link, title tab.name ] [ text tab.name ] ] isActive : Route -> Tab -> String isActive currentRoute tab = if routeToTab currentRoute == tab then " active" else "" routeToTab : Route -> Tab routeToTab currentRoute = case currentRoute of AlertsRoute _ -> alertsTab NotFoundRoute -> noneTab SilenceFormEditRoute _ -> silencesTab SilenceFormNewRoute _ -> silencesTab SilenceListRoute _ -> silencesTab SilenceViewRoute _ -> silencesTab StatusRoute -> statusTab TopLevelRoute -> noneTab SettingsRoute -> settingsTab ================================================ FILE: ui/app/src/Views/NotFound/Views.elm ================================================ module Views.NotFound.Views exposing (view) import Html exposing (Html, div, h1, text) import Types exposing (Msg) view : Html Msg view = div [] [ h1 [] [ text "not found" ] ] ================================================ FILE: ui/app/src/Views/ReceiverBar/Types.elm ================================================ module Views.ReceiverBar.Types exposing (Model, Msg(..), Receiver, apiReceiverToReceiver, initReceiverBar) import Browser.Navigation exposing (Key) import Data.Receiver import Regex import Utils.Types exposing (ApiData(..)) type Msg = ReceiversFetched (ApiData (List Data.Receiver.Receiver)) | UpdateReceiver String | EditReceivers | FilterByReceiver String | Select (Maybe Receiver) | ResultsHovered Bool | BlurReceiverField | Noop type alias Model = { receivers : List Receiver , matches : List Receiver , fieldText : String , selectedReceiver : Maybe Receiver , showReceivers : Bool , resultsHovered : Bool , key : Key } type alias Receiver = { name : String , regex : String } escapeRegExp : String -> String escapeRegExp text = let reg = Regex.fromString "[-[\\]{}()*+?.,\\\\^$|#\\s]" |> Maybe.withDefault Regex.never in Regex.replace reg (.match >> (++) "\\") text apiReceiverToReceiver : Data.Receiver.Receiver -> Receiver apiReceiverToReceiver r = Receiver r.name (escapeRegExp r.name) initReceiverBar : Key -> Model initReceiverBar key = { receivers = [] , matches = [] , fieldText = "" , selectedReceiver = Nothing , showReceivers = False , resultsHovered = False , key = key } ================================================ FILE: ui/app/src/Views/ReceiverBar/Updates.elm ================================================ module Views.ReceiverBar.Updates exposing (fetchReceivers, update) import Alerts.Api as Api import Browser.Dom as Dom import Browser.Navigation as Navigation import Task import Utils.Filter exposing (Filter) import Utils.Match exposing (jaroWinkler) import Utils.Types exposing (ApiData(..)) import Views.ReceiverBar.Types exposing (Model, Msg(..), apiReceiverToReceiver) update : String -> Filter -> Msg -> Model -> ( Model, Cmd Msg ) update url filter msg model = case msg of ReceiversFetched (Success receivers) -> ( { model | receivers = List.map apiReceiverToReceiver receivers }, Cmd.none ) ReceiversFetched _ -> ( model, Cmd.none ) EditReceivers -> ( { model | showReceivers = True , fieldText = "" , matches = model.receivers |> List.take 10 |> (::) { name = "All", regex = "" } , selectedReceiver = Nothing } , Dom.focus "receiver-field" |> Task.attempt (always Noop) ) ResultsHovered resultsHovered -> ( { model | resultsHovered = resultsHovered }, Cmd.none ) UpdateReceiver receiver -> let matches = model.receivers |> List.sortBy (.name >> jaroWinkler receiver) |> List.reverse |> List.take 10 |> (::) { name = "All", regex = "" } in ( { model | fieldText = receiver , matches = matches } , Cmd.none ) BlurReceiverField -> ( { model | showReceivers = False }, Cmd.none ) Select maybeReceiver -> ( { model | selectedReceiver = maybeReceiver }, Cmd.none ) FilterByReceiver regex -> ( { model | showReceivers = False, resultsHovered = False } , Navigation.pushUrl model.key (Utils.Filter.toUrl url { filter | receiver = if regex == "" then Nothing else Just regex } ) ) Noop -> ( model, Cmd.none ) fetchReceivers : String -> Cmd Msg fetchReceivers = Api.fetchReceivers >> Cmd.map ReceiversFetched ================================================ FILE: ui/app/src/Views/ReceiverBar/Views.elm ================================================ module Views.ReceiverBar.Views exposing (view) import Html exposing (Html, div, input, li, text) import Html.Attributes exposing (class, id, style, tabindex, value) import Html.Events exposing (onBlur, onClick, onInput, onMouseEnter, onMouseLeave) import Utils.Keyboard exposing (keys, onKeyDown) import Utils.List import Views.ReceiverBar.Types exposing (Model, Msg(..), Receiver) view : Maybe String -> Model -> Html Msg view maybeRegex model = if model.showReceivers || model.resultsHovered then viewDropdown model else viewResult maybeRegex model.receivers viewResult : Maybe String -> List Receiver -> Html Msg viewResult maybeRegex receivers = let unescapedReceiver = receivers |> List.filter (.regex >> Just >> (==) maybeRegex) |> List.map (.name >> Just) |> List.head |> Maybe.withDefault maybeRegex in li [ class "nav-item ml-auto" , tabindex 1 , style "position" "relative" , style "outline" "none" ] [ div [ onClick EditReceivers , class "mt-1 mr-4" , style "cursor" "pointer" ] [ text ("Receiver: " ++ Maybe.withDefault "All" unescapedReceiver) ] ] viewDropdown : Model -> Html Msg viewDropdown { matches, fieldText, selectedReceiver } = let nextMatch = selectedReceiver |> Maybe.map ((\b a -> Utils.List.nextElem a b) <| matches) |> Maybe.withDefault (List.head matches) prevMatch = selectedReceiver |> Maybe.map ((\b a -> Utils.List.nextElem a b) <| List.reverse matches) |> Maybe.withDefault (Utils.List.lastElem matches) keyDown key = if key == keys.down then Select nextMatch else if key == keys.up then Select prevMatch else if key == keys.enter then selectedReceiver |> Maybe.map .regex |> Maybe.withDefault fieldText |> FilterByReceiver else Noop in li [ class "nav-item ml-auto mr-4 autocomplete-menu show" , onMouseEnter (ResultsHovered True) , onMouseLeave (ResultsHovered False) , style "position" "relative" , style "outline" "none" ] [ input [ id "receiver-field" , value fieldText , onBlur BlurReceiverField , onInput UpdateReceiver , onKeyDown keyDown , class "mr-4" , style "display" "block" , style "width" "100%" ] [] , matches |> List.map (receiverField selectedReceiver) |> div [ class "dropdown-menu dropdown-menu-right" ] ] receiverField : Maybe Receiver -> Receiver -> Html Msg receiverField selected receiver = let attrs = if selected == Just receiver then [ class "dropdown-item active" ] else [ class "dropdown-item" , style "cursor" "pointer" , onClick (FilterByReceiver receiver.regex) ] in div attrs [ text receiver.name ] ================================================ FILE: ui/app/src/Views/Settings/Parsing.elm ================================================ module Views.Settings.Parsing exposing (settingsViewParser) import Url.Parser exposing (Parser, s) settingsViewParser : Parser a a settingsViewParser = s "settings" ================================================ FILE: ui/app/src/Views/Settings/Types.elm ================================================ module Views.Settings.Types exposing (..) import Utils.DateTimePicker.Utils exposing (FirstDayOfWeek) type alias Model = { firstDayOfWeek : FirstDayOfWeek } type SettingsMsg = UpdateFirstDayOfWeek String ================================================ FILE: ui/app/src/Views/Settings/Updates.elm ================================================ port module Views.Settings.Updates exposing (..) import Task import Types exposing (Msg(..)) import Utils.DateTimePicker.Utils exposing (FirstDayOfWeek(..)) import Views.Settings.Types exposing (..) import Views.SilenceForm.Types update : SettingsMsg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of Views.Settings.Types.UpdateFirstDayOfWeek firstDayOfWeekString -> let firstDayOfWeek = case firstDayOfWeekString of "Monday" -> Monday "Sunday" -> Sunday "Saturday" -> Saturday _ -> Monday firstDayOfWeekString2 = case firstDayOfWeek of Monday -> "Monday" Sunday -> "Sunday" Saturday -> "Saturday" in ( { model | firstDayOfWeek = firstDayOfWeek } , Cmd.batch [ Task.perform identity (Task.succeed (MsgForSilenceForm (Views.SilenceForm.Types.UpdateFirstDayOfWeek firstDayOfWeek ) ) ) , persistFirstDayOfWeek firstDayOfWeekString2 ] ) port persistFirstDayOfWeek : String -> Cmd msg ================================================ FILE: ui/app/src/Views/Settings/Views.elm ================================================ module Views.Settings.Views exposing (view) import Html exposing (..) import Html.Attributes exposing (checked, class, for, id, type_, value) import Html.Events exposing (..) import Utils.DateTimePicker.Utils exposing (FirstDayOfWeek(..)) import Views.Settings.Types exposing (Model, SettingsMsg(..)) view : Model -> Html SettingsMsg view model = div [] [ div [ class "no-gutters" ] [ label [ for "fieldset" ] [ text "First day of the week:" ] , fieldset [ id "fieldset" ] [ radio "Monday" (model.firstDayOfWeek == Monday) UpdateFirstDayOfWeek , radio "Sunday" (model.firstDayOfWeek == Sunday) UpdateFirstDayOfWeek , radio "Saturday" (model.firstDayOfWeek == Saturday) UpdateFirstDayOfWeek ] , small [ class "form-text text-muted" ] [ text "Note: This setting is saved in local storage of your browser" ] ] ] radio : String -> Bool -> (String -> msg) -> Html msg radio radioValue isChecked msg = div [ class "mt-1 ml-1 custom-control custom-radio" ] [ input [ type_ "radio" , id radioValue , class "custom-control-input" , checked isChecked , value radioValue , onInput msg ] [] , label [ class "custom-control-label", for radioValue ] [ text radioValue ] ] ================================================ FILE: ui/app/src/Views/Shared/Alert.elm ================================================ module Views.Shared.Alert exposing (annotation, annotationsButton, generatorUrlButton, titleView) import Data.GettableAlert exposing (GettableAlert) import Html exposing (Html, a, button, i, span, td, text, th, tr) import Html.Attributes exposing (class, href) import Html.Events exposing (onClick) import Utils.Date exposing (dateTimeFormat) import Utils.Views exposing (linkifyText) import Views.Shared.Types exposing (Msg) annotationsButton : Maybe String -> GettableAlert -> Html Msg annotationsButton activeAlertId alert = if activeAlertId == Just alert.fingerprint then button [ onClick Nothing , class "btn btn-outline-info border-0 active" ] [ i [ class "fa fa-minus mr-2" ] [], text "Info" ] else button [ class "btn btn-outline-info border-0" , onClick (Just alert.fingerprint) ] [ i [ class "fa fa-plus mr-2" ] [], text "Info" ] annotation : ( String, String ) -> Html msg annotation ( key, value ) = tr [] [ th [ class "text-nowrap" ] [ text (key ++ ":") ] , td [ class "w-100" ] (linkifyText value) ] titleView : GettableAlert -> Html msg titleView alert = span [ class "align-self-center mr-2" ] [ text (dateTimeFormat alert.startsAt) ] generatorUrlButton : String -> Html msg generatorUrlButton url = if String.startsWith "http://" url || String.startsWith "https://" url then a [ class "btn btn-outline-info border-0", href url ] [ i [ class "fa fa-line-chart mr-2" ] [] , text "Source" ] else text "" ================================================ FILE: ui/app/src/Views/Shared/AlertCompact.elm ================================================ module Views.Shared.AlertCompact exposing (view) import Data.GettableAlert exposing (GettableAlert) import Dict import Html exposing (Html, div, table, text) import Html.Attributes exposing (class, style) import Utils.Views exposing (labelButton) import Views.Shared.Alert exposing (annotation, annotationsButton, generatorUrlButton, titleView) import Views.Shared.Types exposing (Msg) view : Maybe String -> GettableAlert -> Html Msg view activeAlertId alert = let -- remove the grouping labels, and bring the alertname to front ungroupedLabels = alert.labels |> Dict.toList |> List.partition (Tuple.first >> (==) "alertname") |> (\( a, b ) -> a ++ b) |> List.map (\( a, b ) -> String.join "=" [ a, b ]) in div [ -- speedup rendering in Chrome, because list-group-item className -- creates a new layer in the rendering engine style "position" "static" , class "border-0 p-0 mb-4" ] [ div [ class "w-100 mb-2 d-flex" ] [ titleView alert , if Dict.size alert.annotations > 0 then annotationsButton activeAlertId alert else text "" , case alert.generatorURL of Just url -> generatorUrlButton url Nothing -> text "" ] , if activeAlertId == Just alert.fingerprint then table [ class "table w-100 mb-1" ] (List.map annotation <| Dict.toList alert.annotations) else text "" , div [] (List.map (labelButton Nothing) ungroupedLabels) ] ================================================ FILE: ui/app/src/Views/Shared/AlertListCompact.elm ================================================ module Views.Shared.AlertListCompact exposing (view) import Data.GettableAlert exposing (GettableAlert) import Html exposing (Html, div) import Html.Attributes exposing (class) import Views.Shared.AlertCompact import Views.Shared.Types exposing (Msg) view : Maybe String -> List GettableAlert -> Html Msg view activeAlertId alerts = List.map (Views.Shared.AlertCompact.view activeAlertId) alerts |> div [ class "pa0 w-100" ] ================================================ FILE: ui/app/src/Views/Shared/Dialog.elm ================================================ module Views.Shared.Dialog exposing (Config, view) import Html exposing (Html, button, div, h5, text) import Html.Attributes exposing (class, style) import Html.Events exposing (onClick) type alias Config msg = { title : String , body : Html msg , footer : Html msg , onClose : msg } view : Maybe (Config msg) -> Html msg view maybeConfig = case maybeConfig of Nothing -> div [ style "clip" "rect(0,0,0,0)", style "position" "fixed" ] [ div [ class "modal fade" ] [] , div [ class "modal-backdrop fade" ] [] ] Just { onClose, body, footer, title } -> div [] [ div [ class "modal fade show", style "display" "block" ] [ div [ class "modal-dialog modal-dialog-centered" ] [ div [ class "modal-content" ] [ div [ class "modal-header" ] [ h5 [ class "modal-title" ] [ text title ] , button [ class "close" , onClick onClose ] [ text "×" ] ] , div [ class "modal-body" ] [ body ] , div [ class "modal-footer" ] [ footer ] ] ] ] , div [ class "modal-backdrop fade show" ] [] ] ================================================ FILE: ui/app/src/Views/Shared/SilencePreview.elm ================================================ module Views.Shared.SilencePreview exposing (view) import Data.GettableAlert exposing (GettableAlert) import Html exposing (Html, div, p, strong, text) import Html.Attributes exposing (class) import Utils.Types exposing (ApiData(..)) import Utils.Views exposing (loading) import Views.Shared.AlertListCompact import Views.Shared.Types exposing (Msg) view : Maybe String -> ApiData (List GettableAlert) -> Html Msg view activeAlertId alertsResponse = case alertsResponse of Success alerts -> if List.isEmpty alerts then div [ class "w-100" ] [ p [] [ strong [] [ text "No affected alerts" ] ] ] else div [ class "w-100" ] [ p [] [ strong [] [ text ("Affected alerts: " ++ String.fromInt (List.length alerts)) ] ] , Views.Shared.AlertListCompact.view activeAlertId alerts ] Initial -> text "" Loading -> loading Failure e -> div [ class "alert alert-warning" ] [ text e ] ================================================ FILE: ui/app/src/Views/Shared/Types.elm ================================================ module Views.Shared.Types exposing (Msg) type alias Msg = Maybe String ================================================ FILE: ui/app/src/Views/SilenceForm/Parsing.elm ================================================ module Views.SilenceForm.Parsing exposing (newSilenceFromAlertLabels, newSilenceFromMatchers, newSilenceFromMatchersAndComment, silenceFormEditParser, silenceFormNewParser) import Data.Matcher import Dict exposing (Dict) import Url exposing (percentEncode) import Url.Parser exposing ((), (), Parser, s, string) import Url.Parser.Query as Query import Utils.Filter exposing (SilenceFormGetParams, parseFilter) newSilenceFromAlertLabels : Dict String String -> String newSilenceFromAlertLabels labels = labels |> Dict.toList |> List.map (\( k, v ) -> Utils.Filter.Matcher k Utils.Filter.Eq v) |> encodeMatchers parseGetParams : Maybe String -> Maybe String -> SilenceFormGetParams parseGetParams filter comment = { matchers = filter |> Maybe.andThen parseFilter >> Maybe.withDefault [] , comment = comment |> Maybe.withDefault "" } silenceFormNewParser : Parser (SilenceFormGetParams -> a) a silenceFormNewParser = s "silences" s "new" Query.map2 parseGetParams (Query.string "filter") (Query.string "comment") silenceFormEditParser : Parser (String -> a) a silenceFormEditParser = s "silences" string s "edit" newSilenceFromMatchers : List Data.Matcher.Matcher -> String newSilenceFromMatchers matchers = matchers |> List.map (\{ name, value, isRegex, isEqual } -> let isEqualValue = case isEqual of Nothing -> True Just justIsEqual -> justIsEqual op = if not isRegex && isEqualValue then Utils.Filter.Eq else if not isRegex && not isEqualValue then Utils.Filter.NotEq else if isRegex && isEqualValue then Utils.Filter.RegexMatch else Utils.Filter.NotRegexMatch in Utils.Filter.Matcher name op value ) |> encodeMatchers newSilenceFromMatchersAndComment : List Data.Matcher.Matcher -> String -> String newSilenceFromMatchersAndComment matchers comment = newSilenceFromMatchers matchers ++ "&comment=" ++ (comment |> percentEncode) encodeMatchers : List Utils.Filter.Matcher -> String encodeMatchers matchers = matchers |> Utils.Filter.stringifyFilter |> percentEncode |> (++) "#/silences/new?filter=" ================================================ FILE: ui/app/src/Views/SilenceForm/Types.elm ================================================ module Views.SilenceForm.Types exposing ( Model , SilenceForm , SilenceFormFieldMsg(..) , SilenceFormMsg(..) , fromDateTimePicker , fromMatchersAndCommentAndTime , fromSilence , initSilenceForm , parseEndsAt , toSilence , validateForm , validateMatchers ) import Browser.Navigation exposing (Key) import Data.GettableAlert exposing (GettableAlert) import Data.GettableSilence exposing (GettableSilence) import Data.Matcher import Data.PostableSilence exposing (PostableSilence) import DateTime import Silences.Types exposing (nullSilence) import Time exposing (Posix) import Utils.Date exposing (addDuration, durationFormat, parseDuration, timeDifference, timeFromString, timeToString) import Utils.DateTimePicker.Types exposing (DateTimePicker, initDateTimePicker, initFromStartAndEndTime) import Utils.DateTimePicker.Utils exposing (FirstDayOfWeek) import Utils.Filter import Utils.FormValidation exposing ( ValidatedField , ValidationState(..) , initialField , stringNotEmpty , validate ) import Utils.Types exposing (ApiData(..)) import Views.FilterBar.Types as FilterBar type alias Model = { form : SilenceForm , filterBar : FilterBar.Model , filterBarValid : ValidationState , silenceId : ApiData String , alerts : ApiData (List GettableAlert) , activeAlertId : Maybe String , key : Key , firstDayOfWeek : FirstDayOfWeek } type alias SilenceForm = { id : Maybe String , createdBy : ValidatedField , comment : ValidatedField , startsAt : ValidatedField , endsAt : ValidatedField , duration : ValidatedField , dateTimePicker : DateTimePicker , viewDateTimePicker : Bool } type SilenceFormMsg = UpdateField SilenceFormFieldMsg | CreateSilence | PreviewSilence | AlertGroupsPreview (ApiData (List GettableAlert)) | SetActiveAlert (Maybe String) | FetchSilence String | NewSilenceFromMatchersAndComment String Utils.Filter.SilenceFormGetParams | NewSilenceFromMatchersAndCommentAndTime String (List Utils.Filter.Matcher) String Posix | SilenceFetch (ApiData GettableSilence) | SilenceCreate (ApiData String) | UpdateDateTimePicker Utils.DateTimePicker.Types.Msg | MsgForFilterBar FilterBar.Msg | UpdateFirstDayOfWeek FirstDayOfWeek type SilenceFormFieldMsg = UpdateStartsAt String | UpdateEndsAt String | UpdateDuration String | ValidateTime | UpdateCreatedBy String | ValidateCreatedBy | UpdateComment String | ValidateComment | UpdateTimesFromPicker | OpenDateTimePicker | CloseDateTimePicker initSilenceForm : Key -> FirstDayOfWeek -> Model initSilenceForm key firstDayOfWeek = { form = empty firstDayOfWeek , filterBar = FilterBar.initFilterBar [] , filterBarValid = Utils.FormValidation.Initial , silenceId = Utils.Types.Initial , alerts = Utils.Types.Initial , activeAlertId = Nothing , key = key , firstDayOfWeek = firstDayOfWeek } toSilence : FilterBar.Model -> SilenceForm -> Maybe PostableSilence toSilence filterBar { id, comment, createdBy, startsAt, endsAt } = Result.map5 (\nonEmptyMatchers nonEmptyComment nonEmptyCreatedBy parsedStartsAt parsedEndsAt -> { nullSilence | id = id , comment = nonEmptyComment , matchers = nonEmptyMatchers , createdBy = nonEmptyCreatedBy , startsAt = parsedStartsAt , endsAt = parsedEndsAt } ) (validMatchers filterBar) (stringNotEmpty comment.value) (stringNotEmpty createdBy.value) (timeFromString startsAt.value) (parseEndsAt startsAt.value endsAt.value) |> Result.toMaybe validMatchers : FilterBar.Model -> Result String (List Data.Matcher.Matcher) validMatchers { matchers, matcherText } = if matcherText /= "" then Err "Please complete adding the matcher" else case matchers of [] -> Err "Matchers are required" nonEmptyMatchers -> Ok (List.map Utils.Filter.toApiMatcher nonEmptyMatchers) fromSilence : GettableSilence -> FirstDayOfWeek -> SilenceForm fromSilence { id, createdBy, comment, startsAt, endsAt } firstDayOfWeek = let startsPosix = Utils.Date.timeFromString (DateTime.toString startsAt) |> Result.toMaybe endsPosix = Utils.Date.timeFromString (DateTime.toString endsAt) |> Result.toMaybe in { id = Just id , createdBy = initialField createdBy , comment = initialField comment , startsAt = initialField (timeToString startsAt) , endsAt = initialField (timeToString endsAt) , duration = initialField (durationFormat (timeDifference startsAt endsAt) |> Maybe.withDefault "") , dateTimePicker = initFromStartAndEndTime startsPosix endsPosix firstDayOfWeek , viewDateTimePicker = False } validateForm : SilenceForm -> SilenceForm validateForm { id, createdBy, comment, startsAt, endsAt, duration, dateTimePicker } = { id = id , createdBy = validate stringNotEmpty createdBy , comment = validate stringNotEmpty comment , startsAt = validate timeFromString startsAt , endsAt = validate (parseEndsAt startsAt.value) endsAt , duration = validate parseDuration duration , dateTimePicker = dateTimePicker , viewDateTimePicker = False } validateMatchers : FilterBar.Model -> ValidationState validateMatchers filter = case validMatchers filter of Err error -> Utils.FormValidation.Invalid error Ok _ -> Utils.FormValidation.Valid parseEndsAt : String -> String -> Result String Posix parseEndsAt startsAt endsAt = case ( timeFromString startsAt, timeFromString endsAt ) of ( Ok starts, Ok ends ) -> if Time.posixToMillis starts > Time.posixToMillis ends then Err "Can't be in the past" else Ok ends ( _, endsResult ) -> endsResult empty : FirstDayOfWeek -> SilenceForm empty firstDayOfWeek = { id = Nothing , createdBy = initialField "" , comment = initialField "" , startsAt = initialField "" , endsAt = initialField "" , duration = initialField "" , dateTimePicker = initDateTimePicker firstDayOfWeek , viewDateTimePicker = False } defaultDuration : Float defaultDuration = -- 2 hours 2 * 60 * 60 * 1000 fromMatchersAndCommentAndTime : String -> String -> Posix -> FirstDayOfWeek -> SilenceForm fromMatchersAndCommentAndTime defaultCreator comment now firstDayOfWeek = { id = Nothing , startsAt = initialField (timeToString now) , endsAt = initialField (timeToString (addDuration defaultDuration now)) , duration = initialField (durationFormat defaultDuration |> Maybe.withDefault "") , createdBy = initialField defaultCreator , comment = initialField comment , dateTimePicker = initFromStartAndEndTime (Just now) (Just (addDuration defaultDuration now)) firstDayOfWeek , viewDateTimePicker = False } fromDateTimePicker : SilenceForm -> DateTimePicker -> SilenceForm fromDateTimePicker { id, createdBy, comment, startsAt, endsAt, duration } newPicker = { id = id , createdBy = createdBy , comment = comment , startsAt = startsAt , endsAt = endsAt , duration = duration , dateTimePicker = newPicker , viewDateTimePicker = True } ================================================ FILE: ui/app/src/Views/SilenceForm/Updates.elm ================================================ port module Views.SilenceForm.Updates exposing (update) import Alerts.Api import Browser.Navigation as Navigation import Silences.Api import Task import Time import Types exposing (Msg(..)) import Utils.Date exposing (timeFromString) import Utils.DateTimePicker.Types exposing (initFromStartAndEndTime) import Utils.DateTimePicker.Updates as DateTimePickerUpdates import Utils.Filter exposing (silencePreviewFilter) import Utils.FormValidation exposing (initialField, stringNotEmpty, updateValue, validate) import Utils.Types exposing (ApiData(..)) import Views.FilterBar.Types as FilterBar import Views.FilterBar.Updates as FilterBar import Views.SilenceForm.Types exposing ( Model , SilenceForm , SilenceFormFieldMsg(..) , SilenceFormMsg(..) , fromDateTimePicker , fromMatchersAndCommentAndTime , fromSilence , parseEndsAt , toSilence , validateForm , validateMatchers ) updateForm : SilenceFormFieldMsg -> SilenceForm -> SilenceForm updateForm msg form = case msg of UpdateStartsAt time -> let startsAt = Utils.Date.timeFromString time endsAt = Utils.Date.timeFromString form.endsAt.value durationValue = case Result.map2 Utils.Date.timeDifference startsAt endsAt of Ok duration -> case Utils.Date.durationFormat duration of Just value -> value Nothing -> form.duration.value Err _ -> form.duration.value in { form | startsAt = updateValue time form.startsAt , duration = updateValue durationValue form.duration } UpdateEndsAt time -> let endsAt = Utils.Date.timeFromString time startsAt = Utils.Date.timeFromString form.startsAt.value durationValue = case Result.map2 Utils.Date.timeDifference startsAt endsAt of Ok duration -> case Utils.Date.durationFormat duration of Just value -> value Nothing -> form.duration.value Err _ -> form.duration.value in { form | endsAt = updateValue time form.endsAt , duration = updateValue durationValue form.duration } UpdateDuration time -> let duration = Utils.Date.parseDuration time startsAt = Utils.Date.timeFromString form.startsAt.value endsAtValue = case Result.map2 Utils.Date.addDuration duration startsAt of Ok endsAt -> Utils.Date.timeToString endsAt Err _ -> form.endsAt.value in { form | endsAt = updateValue endsAtValue form.endsAt , duration = updateValue time form.duration } ValidateTime -> { form | startsAt = validate Utils.Date.timeFromString form.startsAt , endsAt = validate (parseEndsAt form.startsAt.value) form.endsAt , duration = validate Utils.Date.parseDuration form.duration } UpdateCreatedBy createdBy -> { form | createdBy = updateValue createdBy form.createdBy } ValidateCreatedBy -> { form | createdBy = validate stringNotEmpty form.createdBy } UpdateComment comment -> { form | comment = updateValue comment form.comment } ValidateComment -> { form | comment = validate stringNotEmpty form.comment } UpdateTimesFromPicker -> let ( startsAt, endsAt, duration ) = case ( form.dateTimePicker.startTime, form.dateTimePicker.endTime ) of ( Just start, Just end ) -> ( validate timeFromString (initialField (Utils.Date.timeToString start)) , validate (parseEndsAt (Utils.Date.timeToString start)) (initialField (Utils.Date.timeToString end)) , initialField (Utils.Date.durationFormat (Utils.Date.timeDifference start end) |> Maybe.withDefault "") |> validate Utils.Date.parseDuration ) _ -> ( form.startsAt, form.endsAt, form.duration ) in { form | startsAt = startsAt , endsAt = endsAt , duration = duration , viewDateTimePicker = False } OpenDateTimePicker -> let startsAtTime = case timeFromString form.startsAt.value of Ok time -> Just time _ -> form.dateTimePicker.startTime endsAtTime = timeFromString form.endsAt.value |> Result.toMaybe in { form | viewDateTimePicker = True , dateTimePicker = initFromStartAndEndTime startsAtTime endsAtTime form.dateTimePicker.firstDayOfWeek } CloseDateTimePicker -> { form | viewDateTimePicker = False } update : SilenceFormMsg -> Model -> String -> String -> ( Model, Cmd Msg ) update msg model basePath apiUrl = case msg of CreateSilence -> case toSilence model.filterBar model.form of Just silence -> ( { model | silenceId = Loading } , Cmd.batch [ Silences.Api.create apiUrl silence |> Cmd.map (SilenceCreate >> MsgForSilenceForm) , persistDefaultCreator silence.createdBy , Task.succeed silence.createdBy |> Task.perform SetDefaultCreator ] ) Nothing -> ( { model | silenceId = Failure "Could not submit the form, Silence is not yet valid." , form = validateForm model.form , filterBarValid = validateMatchers model.filterBar } , Cmd.none ) SilenceCreate silenceId -> let cmd = case silenceId of Success id -> Navigation.pushUrl model.key (basePath ++ "#/silences/" ++ id) _ -> Cmd.none in ( { model | silenceId = silenceId }, cmd ) NewSilenceFromMatchersAndComment defaultCreator params -> ( model, Task.perform (NewSilenceFromMatchersAndCommentAndTime defaultCreator params.matchers params.comment >> MsgForSilenceForm) Time.now ) NewSilenceFromMatchersAndCommentAndTime defaultCreator matchers comment time -> ( { form = fromMatchersAndCommentAndTime defaultCreator comment time model.firstDayOfWeek , alerts = Initial , activeAlertId = Nothing , silenceId = Initial , filterBar = FilterBar.initFilterBar matchers , filterBarValid = Utils.FormValidation.Initial , key = model.key , firstDayOfWeek = model.firstDayOfWeek } , Cmd.none ) FetchSilence silenceId -> ( model, Silences.Api.getSilence apiUrl silenceId (SilenceFetch >> MsgForSilenceForm) ) SilenceFetch (Success silence) -> ( { form = fromSilence silence model.firstDayOfWeek , filterBar = FilterBar.initFilterBar (List.map Utils.Filter.fromApiMatcher silence.matchers) , filterBarValid = Utils.FormValidation.Initial , silenceId = model.silenceId , alerts = Initial , activeAlertId = Nothing , key = model.key , firstDayOfWeek = model.firstDayOfWeek } , Task.perform identity (Task.succeed (MsgForSilenceForm PreviewSilence)) ) SilenceFetch _ -> ( model, Cmd.none ) PreviewSilence -> case toSilence model.filterBar model.form of Just silence -> ( { model | alerts = Loading } , Alerts.Api.fetchAlerts apiUrl (silencePreviewFilter silence.matchers) |> Cmd.map (AlertGroupsPreview >> MsgForSilenceForm) ) Nothing -> ( { model | alerts = Failure "Cannot display affected Alerts, Silence is not yet valid." , form = validateForm model.form , filterBarValid = validateMatchers model.filterBar } , Cmd.none ) AlertGroupsPreview alerts -> ( { model | alerts = alerts } , Cmd.none ) SetActiveAlert maybeAlertId -> ( { model | activeAlertId = maybeAlertId } , Cmd.none ) UpdateField fieldMsg -> ( { model | form = updateForm fieldMsg model.form , alerts = Initial , silenceId = Initial } , Cmd.none ) UpdateDateTimePicker subMsg -> let newPicker = DateTimePickerUpdates.update subMsg model.form.dateTimePicker in ( { model | form = fromDateTimePicker model.form newPicker } , Cmd.none ) MsgForFilterBar subMsg -> let ( newFilterBar, _, subCmd ) = FilterBar.update subMsg model.filterBar in ( { model | filterBar = newFilterBar, filterBarValid = Utils.FormValidation.Initial } , Cmd.map (MsgForFilterBar >> MsgForSilenceForm) subCmd ) UpdateFirstDayOfWeek firstDayOfWeek -> ( { model | firstDayOfWeek = firstDayOfWeek } , Cmd.none ) port persistDefaultCreator : String -> Cmd msg ================================================ FILE: ui/app/src/Views/SilenceForm/Views.elm ================================================ module Views.SilenceForm.Views exposing (view) import Data.GettableAlert exposing (GettableAlert) import Html exposing (Html, button, div, h1, i, input, label, strong, text) import Html.Attributes exposing (class, style) import Html.Events exposing (onClick) import Utils.DateTimePicker.Views exposing (viewDateTimePicker) import Utils.Filter exposing (SilenceFormGetParams) import Utils.FormValidation exposing (ValidatedField, ValidationState(..)) import Utils.Types exposing (ApiData) import Utils.Views exposing (loading, validatedField, validatedTextareaField) import Views.FilterBar.Types as FilterBar import Views.FilterBar.Views as FilterBar import Views.Shared.SilencePreview import Views.SilenceForm.Types exposing (Model, SilenceForm, SilenceFormFieldMsg(..), SilenceFormMsg(..)) view : Maybe String -> SilenceFormGetParams -> String -> Model -> Html SilenceFormMsg view maybeId silenceFormGetParams defaultCreator { form, filterBar, filterBarValid, silenceId, alerts, activeAlertId } = let ( title, resetClick ) = case maybeId of Just silenceId_ -> ( "Edit Silence", FetchSilence silenceId_ ) Nothing -> ( "New Silence", NewSilenceFromMatchersAndComment defaultCreator silenceFormGetParams ) in div [] [ h1 [] [ text title ] , timeInput form.startsAt form.endsAt form.duration , matchersInput filterBarValid filterBar , validatedField input "Creator" inputSectionPadding (UpdateCreatedBy >> UpdateField) (ValidateCreatedBy |> UpdateField) form.createdBy , validatedTextareaField "Comment" inputSectionPadding (UpdateComment >> UpdateField) (ValidateComment |> UpdateField) form.comment , div [ class inputSectionPadding ] [ informationBlock activeAlertId silenceId alerts , silenceActionButtons maybeId resetClick ] , dateTimePickerDialog form ] dateTimePickerDialog : SilenceForm -> Html SilenceFormMsg dateTimePickerDialog form = if form.viewDateTimePicker then div [] [ div [ class "modal fade show", style "display" "block" ] [ div [ class "modal-dialog modal-dialog-centered" ] [ div [ class "modal-content" ] [ div [ class "modal-header" ] [ button [ class "close ml-auto" , onClick (CloseDateTimePicker |> UpdateField) ] [ text "x" ] ] , div [ class "modal-body" ] [ viewDateTimePicker form.dateTimePicker |> Html.map UpdateDateTimePicker ] , div [ class "modal-footer" ] [ button [ class "ml-2 btn btn-outline-success mr-auto" , onClick (CloseDateTimePicker |> UpdateField) ] [ text "Cancel" ] , button [ class "ml-2 btn btn-primary" , onClick (UpdateTimesFromPicker |> UpdateField) ] [ text "Set Date/Time" ] ] ] ] ] , div [ class "modal-backdrop fade show" ] [] ] else div [ style "clip" "rect(0,0,0,0)", style "position" "fixed" ] [ div [ class "modal fade" ] [] , div [ class "modal-backdrop fade" ] [] ] inputSectionPadding : String inputSectionPadding = "mt-5" timeInput : ValidatedField -> ValidatedField -> ValidatedField -> Html SilenceFormMsg timeInput startsAt endsAt duration = div [ class <| "row " ++ inputSectionPadding ] [ validatedField input "Start" "col-lg-4 col-6" (UpdateStartsAt >> UpdateField) (ValidateTime |> UpdateField) startsAt , validatedField input "Duration" "col-lg-3 col-6" (UpdateDuration >> UpdateField) (ValidateTime |> UpdateField) duration , validatedField input "End" "col-lg-4 col-6" (UpdateEndsAt >> UpdateField) (ValidateTime |> UpdateField) endsAt , div [ class "form-group col-lg-1 col-6" ] [ label [] [ text "\u{00A0}" ] , button [ class "form-control btn btn-outline-primary cursor-pointer" , onClick (OpenDateTimePicker |> UpdateField) ] [ i [ class "fa fa-calendar" ] [] ] ] ] matchersInput : Utils.FormValidation.ValidationState -> FilterBar.Model -> Html SilenceFormMsg matchersInput filterBarValid filterBar = let errorClass = case filterBarValid of Invalid _ -> " has-danger" _ -> "" in div [ class (inputSectionPadding ++ errorClass) ] [ label [ Html.Attributes.for "filter-bar-matcher" ] [ strong [] [ text "Matchers " ] , text "Alerts affected by this silence" ] , FilterBar.view { showSilenceButton = False } filterBar |> Html.map MsgForFilterBar , case filterBarValid of Invalid error -> div [ class "form-control-feedback" ] [ text error ] _ -> text "" ] informationBlock : Maybe String -> ApiData String -> ApiData (List GettableAlert) -> Html SilenceFormMsg informationBlock activeAlertId silence alerts = case silence of Utils.Types.Success _ -> text "" Utils.Types.Initial -> Views.Shared.SilencePreview.view activeAlertId alerts |> Html.map SetActiveAlert Utils.Types.Failure error -> Utils.Views.error error Utils.Types.Loading -> loading silenceActionButtons : Maybe String -> SilenceFormMsg -> Html SilenceFormMsg silenceActionButtons maybeId resetClick = div [ class ("mb-4 " ++ inputSectionPadding) ] [ previewSilenceBtn , createSilenceBtn maybeId , button [ class "ml-2 btn btn-danger", onClick resetClick ] [ text "Reset" ] ] createSilenceBtn : Maybe String -> Html SilenceFormMsg createSilenceBtn maybeId = let btnTxt = case maybeId of Just _ -> "Update" Nothing -> "Create" in button [ class "ml-2 btn btn-primary" , onClick CreateSilence ] [ text btnTxt ] previewSilenceBtn : Html SilenceFormMsg previewSilenceBtn = button [ class "btn btn-outline-success" , onClick PreviewSilence ] [ text "Preview Alerts" ] ================================================ FILE: ui/app/src/Views/SilenceList/Parsing.elm ================================================ module Views.SilenceList.Parsing exposing (silenceListParser) import Url.Parser exposing ((), Parser, map, s) import Url.Parser.Query as Query import Utils.Filter exposing (Filter) silenceListParser : Parser (Filter -> a) a silenceListParser = map (\t -> Filter t Nothing False Nothing Nothing Nothing Nothing Nothing ) (s "silences" Query.string "filter") ================================================ FILE: ui/app/src/Views/SilenceList/SilenceView.elm ================================================ module Views.SilenceList.SilenceView exposing (editButton, view) import Data.GettableSilence exposing (GettableSilence) import Data.Matcher exposing (Matcher) import Data.SilenceStatus exposing (State(..)) import Html exposing (Html, a, button, div, li, span, text) import Html.Attributes exposing (class, href, style) import Html.Events exposing (onClick) import Time exposing (Posix) import Types exposing (Msg(..)) import Utils.Date import Utils.Filter import Utils.List import Utils.Views import Views.FilterBar.Types as FilterBarTypes import Views.Shared.Dialog as Dialog import Views.SilenceForm.Parsing exposing (newSilenceFromMatchersAndComment) import Views.SilenceList.Types exposing (SilenceListMsg(..)) view : Bool -> GettableSilence -> Html Msg view showConfirmationDialog silence = li [ -- speedup rendering in Chrome, because list-group-item className -- creates a new layer in the rendering engine style "position" "static" , class "align-items-start list-group-item border-0 p-0 mb-4" ] [ div [ class "w-100 mb-2 d-flex align-items-start" ] [ case silence.status.state of Active -> dateView "Ends" silence.endsAt Pending -> dateView "Starts" silence.startsAt Expired -> dateView "Expired" silence.endsAt , detailsButton silence.id , editButton silence , deleteButton silence ] , div [ class "" ] (List.map matcherButton silence.matchers) , Dialog.view (if showConfirmationDialog then Just (confirmSilenceDeleteView silence False) else Nothing ) ] confirmSilenceDeleteView : GettableSilence -> Bool -> Dialog.Config Msg confirmSilenceDeleteView silence refresh = { onClose = MsgForSilenceList Views.SilenceList.Types.FetchSilences , title = "Expire Silence" , body = text "Are you sure you want to expire this silence?" , footer = button [ class "btn btn-primary" , onClick (MsgForSilenceList (Views.SilenceList.Types.DestroySilence silence refresh)) ] [ text "Confirm" ] } dateView : String -> Posix -> Html Msg dateView string time = span [ class "text-muted align-self-center mr-2" ] [ text (string ++ " " ++ Utils.Date.dateTimeFormat time) ] matcherButton : Matcher -> Html Msg matcherButton matcher = let isEqual = case matcher.isEqual of Nothing -> True Just value -> value op = if not matcher.isRegex && isEqual then Utils.Filter.Eq else if not matcher.isRegex && not isEqual then Utils.Filter.NotEq else if matcher.isRegex && isEqual then Utils.Filter.RegexMatch else Utils.Filter.NotRegexMatch msg = FilterBarTypes.AddFilterMatcher False { key = matcher.name , op = op , value = matcher.value } |> MsgForFilterBar |> MsgForSilenceList in Utils.Views.labelButton (Just msg) (Utils.List.mstring matcher) editButton : GettableSilence -> Html Msg editButton silence = let editUrl = String.join "/" [ "#/silences", silence.id, "edit" ] default = a [ class "btn btn-outline-info border-0", href editUrl ] [ text "Edit" ] in case silence.status.state of -- If the silence is expired, do not edit it, but instead create a new -- one with the old matchers Expired -> a [ class "btn btn-outline-info border-0" , href (newSilenceFromMatchersAndComment silence.matchers silence.comment) ] [ text "Recreate" ] _ -> default deleteButton : GettableSilence -> Html Msg deleteButton silence = case silence.status.state of Expired -> text "" Active -> button [ class "btn btn-outline-danger border-0" , onClick (MsgForSilenceList (ConfirmDestroySilence silence)) ] [ text "Expire" ] Pending -> button [ class "btn btn-outline-danger border-0" , onClick (MsgForSilenceList (ConfirmDestroySilence silence)) ] [ text "Delete" ] detailsButton : String -> Html Msg detailsButton id = a [ class "btn btn-outline-info border-0", href ("#/silences/" ++ id) ] [ text "View" ] ================================================ FILE: ui/app/src/Views/SilenceList/Types.elm ================================================ module Views.SilenceList.Types exposing (Model, SilenceListMsg(..), SilenceTab, initSilenceList) import Browser.Navigation exposing (Key) import Data.GettableSilence exposing (GettableSilence) import Data.SilenceStatus exposing (State(..)) import Utils.Types exposing (ApiData(..)) import Views.FilterBar.Types as FilterBar type SilenceListMsg = ConfirmDestroySilence GettableSilence | DestroySilence GettableSilence Bool | SilencesFetch (ApiData (List GettableSilence)) | FetchSilences | MsgForFilterBar FilterBar.Msg | SetTab State type alias SilenceTab = { silences : List GettableSilence , tab : State , count : Int } type alias Model = { silences : ApiData (List SilenceTab) , filterBar : FilterBar.Model , tab : State , showConfirmationDialog : Maybe String , key : Key } initSilenceList : Key -> Model initSilenceList key = { silences = Initial , filterBar = FilterBar.initFilterBar [] , tab = Active , showConfirmationDialog = Nothing , key = key } ================================================ FILE: ui/app/src/Views/SilenceList/Updates.elm ================================================ module Views.SilenceList.Updates exposing (update) import Browser.Navigation as Navigation import Data.GettableSilence exposing (GettableSilence) import Data.SilenceStatus exposing (State(..)) import Silences.Api as Api import Utils.Api as ApiData import Utils.Filter exposing (Filter) import Utils.Types exposing (ApiData(..)) import Views.FilterBar.Updates as FilterBar import Views.SilenceList.Types exposing (Model, SilenceListMsg(..), SilenceTab) update : SilenceListMsg -> Model -> Filter -> String -> String -> ( Model, Cmd SilenceListMsg ) update msg model filter basePath apiUrl = case msg of SilencesFetch fetchedSilences -> ( { model | silences = ApiData.map (\silences -> List.map (groupSilencesByState silences) states) fetchedSilences } , Cmd.none ) FetchSilences -> ( { model | filterBar = FilterBar.setMatchers filter model.filterBar , silences = Loading , showConfirmationDialog = Nothing } , Api.getSilences apiUrl filter SilencesFetch ) ConfirmDestroySilence silence -> ( { model | showConfirmationDialog = Just silence.id } , Cmd.none ) DestroySilence silence refresh -> -- TODO: "Deleted id: ID" growl -- TODO: Check why POST isn't there but is accepted ( { model | silences = Loading, showConfirmationDialog = Nothing } , Cmd.batch [ Api.destroy apiUrl silence (always FetchSilences) , if refresh then Navigation.pushUrl model.key (basePath ++ "#/silences") else Cmd.none ] ) MsgForFilterBar subMsg -> let ( newFilterBar, shouldFilter, cmd ) = FilterBar.update subMsg model.filterBar filterBarCmd = Cmd.map MsgForFilterBar cmd newUrl = Utils.Filter.toUrl (basePath ++ "#/silences") (Utils.Filter.withMatchers newFilterBar.matchers filter) silencesCmd = if shouldFilter then Cmd.batch [ Navigation.pushUrl model.key newUrl , filterBarCmd ] else filterBarCmd in ( { model | filterBar = newFilterBar }, silencesCmd ) SetTab tab -> ( { model | tab = tab }, Cmd.none ) groupSilencesByState : List GettableSilence -> State -> SilenceTab groupSilencesByState silences state = let silencesInTab = filterSilencesByState state silences in { tab = state , silences = silencesInTab , count = List.length silencesInTab } states : List State states = [ Active, Pending, Expired ] filterSilencesByState : State -> List GettableSilence -> List GettableSilence filterSilencesByState state = List.filter (filterSilenceByState state) filterSilenceByState : State -> GettableSilence -> Bool filterSilenceByState state silence = silence.status.state == state ================================================ FILE: ui/app/src/Views/SilenceList/Views.elm ================================================ module Views.SilenceList.Views exposing (view) import Data.SilenceStatus exposing (State(..)) import Html exposing (..) import Html.Attributes exposing (..) import Html.Keyed import Html.Lazy exposing (lazy2, lazy3) import Silences.Types exposing (stateToString) import Types exposing (Msg(..)) import Utils.String as StringUtils import Utils.Types exposing (ApiData(..)) import Utils.Views exposing (error, loading) import Views.FilterBar.Views as FilterBar import Views.SilenceList.SilenceView import Views.SilenceList.Types exposing (Model, SilenceListMsg(..), SilenceTab) view : Model -> Html Msg view { filterBar, tab, silences, showConfirmationDialog } = div [] [ div [ class "mb-4" ] [ label [ class "mb-2", for "filter-bar-matcher" ] [ text "Filter" ] , Html.map (MsgForFilterBar >> MsgForSilenceList) (FilterBar.view { showSilenceButton = False } filterBar) ] , lazy2 tabsView tab silences , lazy3 silencesView showConfirmationDialog tab silences ] tabsView : State -> ApiData (List SilenceTab) -> Html Msg tabsView currentTab tabs = case tabs of Success silencesTabs -> List.map (\{ tab, count } -> tabView currentTab count tab) silencesTabs |> ul [ class "nav nav-tabs mb-4" ] _ -> List.map (tabView currentTab 0) states |> ul [ class "nav nav-tabs mb-4" ] tabView : State -> Int -> State -> Html Msg tabView currentTab count tab = Utils.Views.tab tab currentTab (SetTab >> MsgForSilenceList) <| case count of 0 -> [ text (StringUtils.capitalizeFirst (stateToString tab)) ] n -> [ text (StringUtils.capitalizeFirst (stateToString tab)) , span [ class "badge badge-pillow badge-default align-text-top ml-2" ] [ text (String.fromInt n) ] ] silencesView : Maybe String -> State -> ApiData (List SilenceTab) -> Html Msg silencesView showConfirmationDialog tab silencesTab = case silencesTab of Success tabs -> tabs |> List.filter (.tab >> (==) tab) |> List.head |> Maybe.map .silences |> Maybe.withDefault [] |> (\silences -> if List.isEmpty silences then Utils.Views.error "No silences found" else Html.Keyed.ul [ class "list-group" ] (List.map (\silence -> ( silence.id , Views.SilenceList.SilenceView.view (showConfirmationDialog == Just silence.id) silence ) ) silences ) ) Failure msg -> error msg _ -> loading states : List State states = [ Active, Pending, Expired ] ================================================ FILE: ui/app/src/Views/SilenceView/Parsing.elm ================================================ module Views.SilenceView.Parsing exposing (silenceViewParser) import Url.Parser exposing ((), Parser, s, string) silenceViewParser : Parser (String -> a) a silenceViewParser = s "silences" string ================================================ FILE: ui/app/src/Views/SilenceView/Types.elm ================================================ module Views.SilenceView.Types exposing (Model, SilenceViewMsg(..), initSilenceView) import Browser.Navigation exposing (Key) import Data.GettableAlert exposing (GettableAlert) import Data.GettableSilence exposing (GettableSilence) import Utils.Types exposing (ApiData(..)) type SilenceViewMsg = SilenceFetched (ApiData GettableSilence) | SetActiveAlert (Maybe String) | AlertGroupsPreview (ApiData (List GettableAlert)) | InitSilenceView String | ConfirmDestroySilence | Reload String type alias Model = { silence : ApiData GettableSilence , alerts : ApiData (List GettableAlert) , activeAlertId : Maybe String , showConfirmationDialog : Bool , key : Key } initSilenceView : Key -> Model initSilenceView key = { silence = Initial , alerts = Initial , activeAlertId = Nothing , showConfirmationDialog = False , key = key } ================================================ FILE: ui/app/src/Views/SilenceView/Updates.elm ================================================ module Views.SilenceView.Updates exposing (update) import Alerts.Api import Browser.Navigation as Navigation import Silences.Api exposing (getSilence) import Utils.Filter exposing (silencePreviewFilter) import Utils.Types exposing (ApiData(..)) import Views.SilenceView.Types exposing (Model, SilenceViewMsg(..)) update : SilenceViewMsg -> Model -> String -> ( Model, Cmd SilenceViewMsg ) update msg model apiUrl = case msg of AlertGroupsPreview alerts -> ( { model | alerts = alerts } , Cmd.none ) SetActiveAlert activeAlertId -> ( { model | activeAlertId = activeAlertId } , Cmd.none ) SilenceFetched (Success silence) -> ( { model | silence = Success silence , alerts = Loading } , Alerts.Api.fetchAlerts apiUrl (silencePreviewFilter silence.matchers) |> Cmd.map AlertGroupsPreview ) ConfirmDestroySilence -> ( { model | showConfirmationDialog = True } , Cmd.none ) SilenceFetched silence -> ( { model | silence = silence, alerts = Initial }, Cmd.none ) InitSilenceView silenceId -> ( { model | showConfirmationDialog = False }, getSilence apiUrl silenceId SilenceFetched ) Reload silenceId -> ( { model | showConfirmationDialog = False }, Navigation.pushUrl model.key ("#/silences/" ++ silenceId) ) ================================================ FILE: ui/app/src/Views/SilenceView/Views.elm ================================================ module Views.SilenceView.Views exposing (view) import Data.GettableAlert exposing (GettableAlert) import Data.GettableSilence exposing (GettableSilence) import Data.SilenceStatus import Html exposing (Html, b, button, div, h1, label, span, text) import Html.Attributes exposing (class) import Html.Events exposing (onClick) import Silences.Types exposing (stateToString) import Types exposing (Msg(..)) import Utils.Date exposing (dateTimeFormat) import Utils.List import Utils.Types exposing (ApiData(..)) import Utils.Views exposing (error, loading) import Views.Shared.Dialog as Dialog import Views.Shared.SilencePreview import Views.SilenceList.SilenceView exposing (editButton) import Views.SilenceList.Types exposing (SilenceListMsg(..)) import Views.SilenceView.Types as SilenceViewTypes exposing (Model) view : Model -> Html Msg view { silence, alerts, activeAlertId, showConfirmationDialog } = case silence of Success sil -> if showConfirmationDialog then viewSilence activeAlertId alerts sil True else viewSilence activeAlertId alerts sil False Initial -> loading Loading -> loading Failure msg -> error msg viewSilence : Maybe String -> ApiData (List GettableAlert) -> GettableSilence -> Bool -> Html Msg viewSilence activeAlertId alerts silence showPromptDialog = let affectedAlerts = Views.Shared.SilencePreview.view activeAlertId alerts |> Html.map (\msg -> MsgForSilenceView (SilenceViewTypes.SetActiveAlert msg)) in div [] [ h1 [] [ text "Silence" , span [ class "ml-3" ] [ editButton silence , expireButton silence ] ] , formGroup "ID" <| text silence.id , formGroup "Starts at" <| text <| dateTimeFormat silence.startsAt , formGroup "Ends at" <| text <| dateTimeFormat silence.endsAt , formGroup "Updated at" <| text <| dateTimeFormat silence.updatedAt , formGroup "Created by" <| text <| silence.createdBy , formGroup "Comment" <| text silence.comment , formGroup "State" <| text <| stateToString silence.status.state , formGroup "Matchers" <| div [] <| List.map (Utils.List.mstring >> Utils.Views.labelButton Nothing) silence.matchers , affectedAlerts , Dialog.view (if showPromptDialog then Just (confirmSilenceDeleteView silence True) else Nothing ) ] confirmSilenceDeleteView : GettableSilence -> Bool -> Dialog.Config Msg confirmSilenceDeleteView silence refresh = { onClose = MsgForSilenceView (SilenceViewTypes.Reload <| silence.id) , title = "Expire Silence" , body = text "Are you sure you want to expire this silence?" , footer = button [ class "btn btn-primary" , onClick (MsgForSilenceList (DestroySilence silence refresh)) ] [ text "Confirm" ] } formGroup : String -> Html Msg -> Html Msg formGroup key content = div [ class "form-group row" ] [ label [ class "col-2 col-form-label" ] [ b [] [ text key ] ] , div [ class "col-10 d-flex align-items-center" ] [ content ] ] expireButton : GettableSilence -> Html Msg expireButton silence = case silence.status.state of Data.SilenceStatus.Expired -> text "" Data.SilenceStatus.Active -> button [ class "btn btn-outline-danger border-0" , onClick (MsgForSilenceView SilenceViewTypes.ConfirmDestroySilence) ] [ text "Expire" ] Data.SilenceStatus.Pending -> button [ class "btn btn-outline-danger border-0" , onClick (MsgForSilenceView SilenceViewTypes.ConfirmDestroySilence) ] [ text "Delete" ] ================================================ FILE: ui/app/src/Views/Status/Parsing.elm ================================================ module Views.Status.Parsing exposing (statusParser) import Url.Parser exposing (Parser, s) statusParser : Parser a a statusParser = s "status" ================================================ FILE: ui/app/src/Views/Status/Types.elm ================================================ module Views.Status.Types exposing (StatusModel, StatusMsg(..), initStatusModel) import Data.AlertmanagerStatus exposing (AlertmanagerStatus) import Utils.Types exposing (ApiData(..)) type StatusMsg = NewStatus (ApiData AlertmanagerStatus) -- String carries the api url. | InitStatusView String type alias StatusModel = { statusInfo : ApiData AlertmanagerStatus } initStatusModel : StatusModel initStatusModel = { statusInfo = Initial } ================================================ FILE: ui/app/src/Views/Status/Updates.elm ================================================ module Views.Status.Updates exposing (update) import Status.Api exposing (getStatus) import Types exposing (Model, Msg(..)) import Views.Status.Types exposing (StatusMsg(..)) update : StatusMsg -> Model -> ( Model, Cmd Msg ) update msg model = case msg of NewStatus apiResponse -> ( { model | status = { statusInfo = apiResponse } }, Cmd.none ) InitStatusView apiUrl -> ( model, getStatus apiUrl (NewStatus >> MsgForStatus) ) ================================================ FILE: ui/app/src/Views/Status/Views.elm ================================================ module Views.Status.Views exposing (view) import Data.AlertmanagerStatus exposing (AlertmanagerStatus) import Data.ClusterStatus exposing (ClusterStatus, Status(..)) import Data.PeerStatus exposing (PeerStatus) import Data.VersionInfo exposing (VersionInfo) import Html exposing (..) import Html.Attributes exposing (class, classList, style) import Status.Api exposing (clusterStatusToString) import Status.Types exposing (VersionInfo) import Types import Utils.Date exposing (timeToString) import Utils.Types exposing (ApiData(..)) import Utils.Views import Views.Status.Types exposing (StatusModel) view : StatusModel -> Html Types.Msg view { statusInfo } = Utils.Views.apiData viewStatusInfo statusInfo viewStatusInfo : AlertmanagerStatus -> Html Types.Msg viewStatusInfo status = div [] [ h1 [] [ text "Status" ] , div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "Uptime:" ] , div [ class "col-sm-10" ] [ text <| timeToString status.uptime ] ] , viewClusterStatus status.cluster , viewVersionInformation status.versionInfo , viewConfig status.config.original ] viewConfig : String -> Html Types.Msg viewConfig config = div [] [ h2 [] [ text "Config" ] , pre [ class "p-4", style "background" "#f7f7f9", style "font-family" "monospace" ] [ code [] [ text config ] ] ] viewClusterStatus : ClusterStatus -> Html Types.Msg viewClusterStatus { name, status, peers } = span [] [ h2 [] [ text "Cluster Status" ] , case name of Just n -> div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "Name:" ] , div [ class "col-sm-10" ] [ text n ] ] Nothing -> text "" , div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "Status:" ] , div [ class "col-sm-10" ] [ span [ classList [ ( "badge", True ) , case status of Ready -> ( "badge-success", True ) Settling -> ( "badge-warning", True ) Disabled -> ( "badge-danger", True ) ] ] [ text <| clusterStatusToString status ] ] ] , case peers of Just p -> div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "Peers:" ] , ul [ class "col-sm-10" ] <| List.map viewClusterPeer p ] Nothing -> text "" ] viewClusterPeer : PeerStatus -> Html Types.Msg viewClusterPeer peer = li [] [ div [ class "" ] [ b [ class "" ] [ text "Name: " ] , text peer.name ] , div [ class "" ] [ b [ class "" ] [ text "Address: " ] , text peer.address ] ] viewVersionInformation : VersionInfo -> Html Types.Msg viewVersionInformation versionInfo = span [] [ h2 [] [ text "Version Information" ] , div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "Branch:" ], div [ class "col-sm-10" ] [ text versionInfo.branch ] ] , div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "BuildDate:" ], div [ class "col-sm-10" ] [ text versionInfo.buildDate ] ] , div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "BuildUser:" ], div [ class "col-sm-10" ] [ text versionInfo.buildUser ] ] , div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "GoVersion:" ], div [ class "col-sm-10" ] [ text versionInfo.goVersion ] ] , div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "Revision:" ], div [ class "col-sm-10" ] [ text versionInfo.revision ] ] , div [ class "form-group row" ] [ b [ class "col-sm-2" ] [ text "Version:" ], div [ class "col-sm-10" ] [ text versionInfo.version ] ] ] ================================================ FILE: ui/app/src/Views.elm ================================================ module Views exposing (view) import Html exposing (Html, div, node, text) import Html.Attributes exposing (class, href, rel, style) import Html.Events exposing (on) import Json.Decode exposing (succeed) import Types exposing (Model, Msg(..), Route(..)) import Utils.Filter exposing (emptySilenceFormGetParams) import Utils.Types exposing (ApiData(..)) import Utils.Views import Views.AlertList.Views as AlertList import Views.NavBar.Views exposing (navBar) import Views.NotFound.Views as NotFound import Views.Settings.Views as SettingsView import Views.SilenceForm.Views as SilenceForm import Views.SilenceList.Views as SilenceList import Views.SilenceView.Views as SilenceView import Views.Status.Views as Status view : Model -> Html Msg view model = div [] [ renderCSS model.libUrl , case ( model.bootstrapCSS, model.fontAwesomeCSS, model.elmDatepickerCSS ) of ( Success _, Success _, Success _ ) -> div [] [ navBar model.route , div [ class "container pb-4" ] [ currentView model ] ] ( Failure err, _, _ ) -> failureView model err ( _, Failure err, _ ) -> failureView model err ( _, _, Failure err ) -> failureView model err _ -> text "" ] failureView : Model -> String -> Html Msg failureView model err = div [] [ div [ style "padding" "40px", style "color" "red" ] [ text err ] , navBar model.route , div [ class "container pb-4" ] [ currentView model ] ] renderCSS : String -> Html Msg renderCSS assetsUrl = div [] [ cssNode (assetsUrl ++ "lib/bootstrap-4.6.2-dist/css/bootstrap.min.css") BootstrapCSSLoaded , cssNode (assetsUrl ++ "lib/font-awesome-4.7.0/css/font-awesome.min.css") FontAwesomeCSSLoaded , cssNode (assetsUrl ++ "lib/elm-datepicker/css/elm-datepicker.css") ElmDatepickerCSSLoaded ] cssNode : String -> (ApiData String -> Msg) -> Html Msg cssNode url msg = node "link" [ href url , rel "stylesheet" , on "load" (succeed (msg (Success url))) , on "error" (succeed (msg (Failure ("Failed to load CSS from: " ++ url)))) ] [] currentView : Model -> Html Msg currentView model = case model.route of SettingsRoute -> SettingsView.view model.settings |> Html.map MsgForSettings StatusRoute -> Status.view model.status SilenceViewRoute _ -> SilenceView.view model.silenceView AlertsRoute filter -> AlertList.view model.alertList filter SilenceListRoute _ -> SilenceList.view model.silenceList SilenceFormNewRoute getParams -> SilenceForm.view Nothing getParams model.defaultCreator model.silenceForm |> Html.map MsgForSilenceForm SilenceFormEditRoute silenceId -> SilenceForm.view (Just silenceId) emptySilenceFormGetParams "" model.silenceForm |> Html.map MsgForSilenceForm TopLevelRoute -> Utils.Views.loading NotFoundRoute -> NotFound.view ================================================ FILE: ui/app/tests/Filter.elm ================================================ module Filter exposing (parseMatcher, stringifyFilter, toUrl) import Expect import Fuzz exposing (string, tuple) import Helpers exposing (isNotEmptyTrimmedAlphabetWord) import Test exposing (..) import Utils.Filter exposing (MatchOperator(..), Matcher) parseMatcher : Test parseMatcher = describe "parseMatcher" [ test "should parse empty matcher string" <| \() -> Expect.equal Nothing (Utils.Filter.parseMatcher "") , test "should parse empty matcher value" <| \() -> Expect.equal (Just (Matcher "alertname" Eq "")) (Utils.Filter.parseMatcher "alertname=\"\"") , test "should unescape quoted matcher value" <| \() -> Expect.equal (Just (Matcher "alertname" Eq "foo\"bar")) (Utils.Filter.parseMatcher "alertname=\"foo\\\"bar\"") , test "should unescape backslash matcher value" <| \() -> Expect.equal (Just (Matcher "alertname" Eq "foo\\bar")) (Utils.Filter.parseMatcher "alertname=\"foo\\\\bar\"") , fuzz (tuple ( string, string )) "should parse random matcher string" <| \( key, value ) -> if List.map isNotEmptyTrimmedAlphabetWord [ key, value ] /= [ True, True ] then Expect.equal Nothing (Utils.Filter.parseMatcher <| String.concat [ key, "=", value ]) else Expect.equal (Just (Matcher key Eq value)) (Utils.Filter.parseMatcher <| String.concat [ key, "=", "\"", value, "\"" ]) ] toUrl : Test toUrl = describe "toUrl" [ test "should not render keys with Nothing value except the silenced, inhibited, muted and active parameters, which default to false, false, false, true, respectively." <| \() -> Expect.equal "/alerts?silenced=false&inhibited=false&muted=false&active=true" (Utils.Filter.toUrl "/alerts" { receiver = Nothing, group = Nothing, customGrouping = False, text = Nothing, showSilenced = Nothing, showInhibited = Nothing, showMuted = Nothing, showActive = Nothing }) , test "should not render filter key with empty value" <| \() -> Expect.equal "/alerts?silenced=false&inhibited=false&muted=false&active=true" (Utils.Filter.toUrl "/alerts" { receiver = Nothing, group = Nothing, customGrouping = False, text = Just "", showSilenced = Nothing, showInhibited = Nothing, showMuted = Nothing, showActive = Nothing }) , test "should render filter key with values" <| \() -> Expect.equal "/alerts?silenced=false&inhibited=false&muted=false&active=true&filter=%7Bfoo%3D%22bar%22%2C%20baz%3D~%22quux.*%22%7D" (Utils.Filter.toUrl "/alerts" { receiver = Nothing, group = Nothing, customGrouping = False, text = Just "{foo=\"bar\", baz=~\"quux.*\"}", showSilenced = Nothing, showInhibited = Nothing, showMuted = Nothing, showActive = Nothing }) , test "should render silenced key with bool" <| \() -> Expect.equal "/alerts?silenced=true&inhibited=false&muted=false&active=true" (Utils.Filter.toUrl "/alerts" { receiver = Nothing, group = Nothing, customGrouping = False, text = Nothing, showSilenced = Just True, showInhibited = Nothing, showMuted = Nothing, showActive = Nothing }) , test "should render inhibited key with bool" <| \() -> Expect.equal "/alerts?silenced=false&inhibited=true&muted=false&active=true" (Utils.Filter.toUrl "/alerts" { receiver = Nothing, group = Nothing, customGrouping = False, text = Nothing, showSilenced = Nothing, showInhibited = Just True, showMuted = Nothing, showActive = Nothing }) , test "should render muted key with bool" <| \() -> Expect.equal "/alerts?silenced=false&inhibited=false&muted=true&active=true" (Utils.Filter.toUrl "/alerts" { receiver = Nothing, group = Nothing, customGrouping = False, text = Nothing, showSilenced = Nothing, showInhibited = Nothing, showMuted = Just True, showActive = Nothing }) , test "should render active key with bool" <| \() -> Expect.equal "/alerts?silenced=false&inhibited=false&muted=false&active=false" (Utils.Filter.toUrl "/alerts" { receiver = Nothing, group = Nothing, customGrouping = False, text = Nothing, showSilenced = Nothing, showInhibited = Nothing, showMuted = Nothing, showActive = Just False }) , test "should add customGrouping key" <| \() -> Expect.equal "/alerts?silenced=false&inhibited=false&muted=false&active=true&customGrouping=true" (Utils.Filter.toUrl "/alerts" { receiver = Nothing, group = Nothing, customGrouping = True, text = Nothing, showSilenced = Nothing, showInhibited = Nothing, showMuted = Nothing, showActive = Nothing }) ] stringifyFilter : Test stringifyFilter = describe "stringifyFilter" [ test "empty" <| \() -> Expect.equal "" (Utils.Filter.stringifyFilter []) , test "non-empty" <| \() -> Expect.equal "{foo=\"bar\", baz=~\"quux.*\"}" (Utils.Filter.stringifyFilter [ { key = "foo", op = Eq, value = "bar" } , { key = "baz", op = RegexMatch, value = "quux.*" } ] ) , test "escapes matcher values" <| \() -> Expect.equal "{foo=\"bar\\\"baz\\\\qux\"}" (Utils.Filter.stringifyFilter [ { key = "foo", op = Eq, value = "bar\"baz\\qux" } ] ) ] ================================================ FILE: ui/app/tests/Helpers.elm ================================================ module Helpers exposing (isNotEmptyTrimmedAlphabetWord) import String isNotEmptyTrimmedAlphabetWord : String -> Bool isNotEmptyTrimmedAlphabetWord string = let stringLength = String.length string in stringLength /= 0 && String.length (String.filter isLetter string) == stringLength isLetter : Char -> Bool isLetter char = String.contains (String.fromChar char) lowerCaseAlphabet || String.contains (String.fromChar char) upperCaseAlphabet lowerCaseAlphabet : String lowerCaseAlphabet = "abcdefghijklmnopqrstuvwxyz" upperCaseAlphabet : String upperCaseAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ================================================ FILE: ui/app/tests/Match.elm ================================================ module Match exposing (testConsecutiveChars, testJaroWinkler) import Expect import Test exposing (..) import Utils.Match exposing (consecutiveChars, jaroWinkler) testJaroWinkler : Test testJaroWinkler = describe "jaroWinkler" [ test "should find the right values 1" <| \() -> Expect.greaterThan (jaroWinkler "zi" "zone") (jaroWinkler "zo" "zone") , test "should find the right values 2" <| \() -> Expect.greaterThan (jaroWinkler "hook" "alertname") (jaroWinkler "de" "dev") , test "should find the right values 3" <| \() -> Expect.equal 0.0 (jaroWinkler "l" "zone") , test "should find the right values 4" <| \() -> Expect.equal 1.0 (jaroWinkler "zone" "zone") , test "should find the right values 5" <| \() -> Expect.greaterThan 0.688 (jaroWinkler "atleio3tefdoisahdf" "attributefdoiashfoihfeowfh9w8f9afaw9fahw") ] testConsecutiveChars : Test testConsecutiveChars = describe "consecutiveChars" [ test "should find the consecutiveChars 1" <| \() -> Expect.equal "zo" (consecutiveChars "zo" "bozo") , test "should find the consecutiveChars 2" <| \() -> Expect.equal "zo" (consecutiveChars "zol" "zone") , test "should find the consecutiveChars 3" <| \() -> Expect.equal "oon" (consecutiveChars "oon" "baboone") , test "should find the consecutiveChars 4" <| \() -> Expect.equal "dom" (consecutiveChars "dom" "random") ] ================================================ FILE: ui/app/tests/StringUtils.elm ================================================ module StringUtils exposing (testLinkify) import Expect import Test exposing (..) import Utils.String exposing (linkify) testLinkify : Test testLinkify = describe "linkify" [ test "should linkify a url in the middle" <| \() -> Expect.equal (linkify "word1 http://url word2") [ Err "word1 ", Ok "http://url", Err " word2" ] , test "should linkify a url in the beginning" <| \() -> Expect.equal (linkify "http://url word1 word2") [ Ok "http://url", Err " word1 word2" ] , test "should linkify a url in the end" <| \() -> Expect.equal (linkify "word1 word2 http://url") [ Err "word1 word2 ", Ok "http://url" ] ] ================================================ FILE: ui/mantine-ui/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: ui/mantine-ui/.nvmrc ================================================ v24.3.0 ================================================ FILE: ui/mantine-ui/.prettierrc.mjs ================================================ /** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */ const config = { printWidth: 100, singleQuote: true, trailingComma: "es5", plugins: ["@ianvs/prettier-plugin-sort-imports"], importOrder: [ ".*styles.css$", "", "dayjs", "^react$", "^next$", "^next/.*$", "", "", "^@mantine/(.*)$", "^@mantinex/(.*)$", "^@mantine-tests/(.*)$", "^@docs/(.*)$", "^@/.*$", "^../(?!.*.css$).*$", "^./(?!.*.css$).*$", "\\.css$", ], overrides: [ { files: "*.mdx", options: { printWidth: 70, }, }, ], }; export default config; ================================================ FILE: ui/mantine-ui/.stylelintignore ================================================ dist ================================================ FILE: ui/mantine-ui/.stylelintrc.json ================================================ { "extends": [ "stylelint-config-standard-scss" ], "rules": { "custom-property-pattern": null, "selector-class-pattern": null, "scss/no-duplicate-mixins": null, "declaration-empty-line-before": null, "declaration-block-no-redundant-longhand-properties": null, "alpha-value-notation": null, "custom-property-empty-line-before": null, "property-no-vendor-prefix": null, "color-function-notation": null, "length-zero-no-unit": null, "selector-not-notation": null, "no-descending-specificity": null, "comment-empty-line-before": null, "scss/at-mixin-pattern": null, "scss/at-rule-no-unknown": null, "value-keyword-case": null, "media-feature-range-notation": null, "selector-pseudo-class-no-unknown": [ true, { "ignorePseudoClasses": [ "global" ] } ] } } ================================================ FILE: ui/mantine-ui/eslint.config.js ================================================ import mantine from "eslint-config-mantine"; import tseslint from "typescript-eslint"; export default tseslint.config( ...mantine, { ignores: ["**/*.{mjs,cjs,js,d.ts,d.mts}", "./.storybook/main.ts"] }, { files: ["**/*.story.tsx"], rules: { "no-console": "off" }, } ); ================================================ FILE: ui/mantine-ui/index.html ================================================ Alertmanager
================================================ FILE: ui/mantine-ui/package.json ================================================ { "name": "alertmanager", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite --host", "build": "tsc && vite build", "typecheck": "tsc --noEmit", "lint": "npm run eslint && npm run stylelint", "eslint": "eslint . --cache", "stylelint": "stylelint '**/*.css' --cache", "prettier": "prettier --check \"**/*.{ts,tsx}\"", "prettier:write": "prettier --write \"**/*.{ts,tsx}\"", "vitest": "vitest run", "vitest:watch": "vitest", "test": "npm run typecheck && npm run prettier && npm run lint && npm run vitest && npm run build" }, "dependencies": { "@mantine/code-highlight": "^8.3.16", "@mantine/core": "^8.3.13", "@mantine/hooks": "^8.3.13", "@tanstack/react-query": "^5.90.21", "highlight.js": "^11.11.1", "react": "^19.1.0", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1" }, "devDependencies": { "@eslint/js": "^9.29.0", "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.3.5", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", "eslint": "^9.39.2", "eslint-config-mantine": "^4.0.3", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "jsdom": "^28.1.0", "postcss": "^8.5.8", "postcss-preset-mantine": "1.18.0", "postcss-simple-vars": "^7.0.1", "prettier": "^3.8.1", "prop-types": "^15.8.1", "stylelint": "^17.4.0", "stylelint-config-standard-scss": "^17.0.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1", "vite": "^7.3.1", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.0.18" } } ================================================ FILE: ui/mantine-ui/postcss.config.cjs ================================================ module.exports = { plugins: { 'postcss-preset-mantine': {}, 'postcss-simple-vars': { variables: { 'mantine-breakpoint-xs': '36em', 'mantine-breakpoint-sm': '48em', 'mantine-breakpoint-md': '62em', 'mantine-breakpoint-lg': '75em', 'mantine-breakpoint-xl': '88em', }, }, }, }; ================================================ FILE: ui/mantine-ui/src/App.tsx ================================================ import '@mantine/core/styles.css'; import '@mantine/code-highlight/styles.css'; import { Suspense } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import hljs from 'highlight.js/lib/core'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { CodeHighlightAdapterProvider, createHighlightJsAdapter } from '@mantine/code-highlight'; import { AppShell, Box, MantineProvider, Skeleton } from '@mantine/core'; import ErrorBoundary from './components/ErrorBoundary'; import { Header } from './components/Header'; import { AlertsPage } from './pages/Alerts.page'; import { ConfigPage } from './pages/Config.page'; import { SilencesPage } from './pages/Silences.page'; import { StatusPage } from './pages/Status.page'; import { theme } from './theme'; import './highlightjs.css'; import yamlLang from 'highlight.js/lib/languages/yaml'; hljs.registerLanguage('yaml', yamlLang); const highlightJsAdapter = createHighlightJsAdapter(hljs); const queryClient = new QueryClient(); export default function App() { return (
{Array.from(Array(10), (_, i) => ( ))} } > {/* Main content will be rendered here by the Router */} {/* Redirect the root path to the alerts page */} {/* TODO(@sysadmind): This should take the fact that previous UI used /#/routeName */} } /> } /> } /> } /> } /> ); } ================================================ FILE: ui/mantine-ui/src/components/ErrorBoundary.tsx ================================================ // import { IconAlertTriangle } from "@tabler/icons-react"; import { Component, ErrorInfo, ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import { Alert } from '@mantine/core'; interface Props { children?: ReactNode; title?: string; } interface State { error: Error | null; } class ErrorBoundary extends Component { public state: State = { error: null, }; public static getDerivedStateFromError(error: Error): State { // Update state so the next render will show the fallback UI. return { error }; } public componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error('Uncaught error:', error, errorInfo); } public render() { if (this.state.error !== null) { return ( } maw={500} mx="auto" mt="lg" > Error: {this.state.error.message} ); } return this.props.children; } } const ResettingErrorBoundary = (props: Props) => { const location = useLocation(); return ( {props.children} ); }; export default ResettingErrorBoundary; ================================================ FILE: ui/mantine-ui/src/components/Header.module.css ================================================ .navMain { flex: 1; } .navLink { display: block; line-height: 1; padding: rem(8px) rem(12px); border-radius: var(--mantine-radius-sm); text-decoration: none; color: var(--mantine-color-gray-0); font-size: var(--mantine-font-size-sm); font-weight: 500; background-color: transparent; @mixin hover { background-color: var(--mantine-color-gray-6); color: var(--mantine-color-gray-0); } [data-mantine-color-scheme] &[aria-current='page'] { background-color: var(--mantine-color-blue-filled); color: var(--mantine-color-white); } } ================================================ FILE: ui/mantine-ui/src/components/Header.tsx ================================================ import { Link, NavLink, Route, Routes } from 'react-router-dom'; import { AppShell, Button, Group, Menu, Text } from '@mantine/core'; import { AlertsPage } from '@/pages/Alerts.page'; import { SilencesPage } from '@/pages/Silences.page'; import classes from './Header.module.css'; const navLinkXPadding = 'md'; export const Header = () => { const mainNavPages = [ { title: 'Alerts', path: '/alerts', // icon: , element: , }, { title: 'Silences', path: '/silences', // icon: , element: , }, ]; const navLinks = ( <> {mainNavPages.map((page) => ( ))} } /> } /> {/* Default menu item when no status pages are selected */} } /> Runtime & Build Information Configuration ); return ( {/* */} Alertmanager Alertmanager {navLinks} ); }; ================================================ FILE: ui/mantine-ui/src/components/InfoPageCard.tsx ================================================ // import { IconProps } from "@tabler/icons-react"; import { FC, ReactNode } from 'react'; import { Card, em, Group } from '@mantine/core'; const infoPageCardTitleIconStyle = { width: em(17.5), height: em(17.5) }; const InfoPageCard: FC<{ children: ReactNode; title?: string; icon?: React.ComponentType; }> = ({ children, title, icon: Icon }) => { return ( {title && ( {Icon && } {title} )} {children} ); }; export default InfoPageCard; ================================================ FILE: ui/mantine-ui/src/components/InfoPageStack.tsx ================================================ import { FC, ReactNode } from 'react'; import { Stack } from '@mantine/core'; const InfoPageStack: FC<{ children: ReactNode }> = ({ children }) => { return ( {children} ); }; export default InfoPageStack; ================================================ FILE: ui/mantine-ui/src/data/api.ts ================================================ import { QueryKey, useQuery, useSuspenseQuery } from '@tanstack/react-query'; // TODO(@sysadmind): Infer this from the current location. // We don't have a good strategy for storing global settings yet. const pathPrefix = ''; export const API_PATH = 'api/v2'; type APIError = { status: 'error'; error?: string; errorType?: string; }; type APISuccess = { status: 'success'; data: T; }; export type APIResponse = APISuccess | APIError; const isAPIEnvelope = (value: unknown): value is APIResponse => { return ( typeof value === 'object' && value !== null && 'status' in value && ((value as { status?: unknown }).status === 'success' || (value as { status?: unknown }).status === 'error') ); }; const createQueryFn = ({ pathPrefix, path, params, recordResponseTime, }: { pathPrefix: string; path: string; params?: Record; recordResponseTime?: (time: number) => void; }) => async ({ signal }: { signal: AbortSignal }) => { const queryParams = new URLSearchParams(); if (params) { Object.entries(params).forEach(([key, value]) => { if (Array.isArray(value)) { value.forEach((v) => queryParams.append(key, v)); } else { queryParams.set(key, value); } }); } const queryString = params ? `?${queryParams.toString()}` : ''; try { const startTime = Date.now(); const res = await fetch(`${pathPrefix}/${API_PATH}${path}${queryString}`, { cache: 'no-store', credentials: 'same-origin', signal, }); if (!res.ok && !res.headers.get('content-type')?.startsWith('application/json')) { // For example, Alertmanager may send a 503 Service Unavailable response // with a "text/plain" content type when it's starting up. But the API // may also respond with a JSON error message and the same error code. throw new Error(res.statusText); } const parsed = await res.json(); if (recordResponseTime) { recordResponseTime(Date.now() - startTime); } if (isAPIEnvelope(parsed)) { if (parsed.status === 'error') { throw new Error( parsed.error !== undefined ? parsed.error : 'missing "error" field in response JSON' ); } return parsed.data; } return parsed as T; } catch (error) { if (!(error instanceof Error)) { throw new Error('Unknown error'); } switch (error.name) { case 'TypeError': throw new Error('Network error or unable to reach the server'); case 'SyntaxError': throw new Error('Invalid JSON response'); default: throw error; } } }; type QueryOptions = { key?: QueryKey; path: string; params?: Record; enabled?: boolean; refetchInterval?: false | number; recordResponseTime?: (time: number) => void; }; export const useAPIQuery = ({ key, path, params, enabled, refetchInterval, recordResponseTime, }: QueryOptions) => { return useQuery({ queryKey: key ?? [API_PATH, path, params], retry: false, refetchOnWindowFocus: false, refetchInterval, gcTime: 0, enabled, queryFn: createQueryFn({ pathPrefix, path, params, recordResponseTime }), }); }; export const useSuspenseAPIQuery = ({ key, path, params }: QueryOptions) => { return useSuspenseQuery({ queryKey: key !== undefined ? key : [path, params], retry: false, refetchOnWindowFocus: false, gcTime: 0, queryFn: createQueryFn({ pathPrefix, path, params }), }); }; ================================================ FILE: ui/mantine-ui/src/data/groups.ts ================================================ import { useSuspenseAPIQuery } from '@/data/api'; type Group = { alerts: Alert[]; labels: Record; receiver: Receiver; }; type Receiver = { name: string; }; type AlertStatus = { inhibitedBy: string[]; silencedBy: string[]; mutedBy: string[]; state: 'active'; }; type Alert = { annotations: Record; endsAt: string; fingerprint: string; receivers: Receiver[]; startsAt: string; status: AlertStatus; updatedAt: string; labels: Record; }; export const useGroups = () => { return useSuspenseAPIQuery>({ path: '/alerts/groups', }); }; ================================================ FILE: ui/mantine-ui/src/data/silences.ts ================================================ import { useSuspenseAPIQuery } from '@/data/api'; type Silence = { id: string; status: { state: 'active' | 'expired' | 'pending'; }; startsAt: string; updatedAt: string; endsAt: string; createdBy: string; comment: string; matchers: Array<{ name: string; value: string; isRegex: boolean; isEqual: boolean; }>; }; export const useSilences = () => { return useSuspenseAPIQuery>({ path: '/silences', }); }; export const useSilence = (id: string) => { return useSuspenseAPIQuery({ path: `/silence/${id}`, }); }; ================================================ FILE: ui/mantine-ui/src/data/status.ts ================================================ import { useSuspenseAPIQuery } from '@/data/api'; type Status = { cluster: { name: string; peers: Array<{ name: string; address: string; }>; status: 'ready' | 'not_ready'; }; config: { original: string; }; uptime: string; versionInfo: { branch: string; buildDate: string; buildUser: string; goVersion: string; version: string; revision: string; }; }; export const useStatus = () => { return useSuspenseAPIQuery({ path: '/status', }); }; ================================================ FILE: ui/mantine-ui/src/highlightjs.css ================================================ /* Adapted from Mantine 7, where highlighting was still included automatically as part of , see https://github.com/mantinedev/mantine/blob/v7/packages/%40mantine/code-highlight/src/CodeHighlight.theme.module.css */ .hljs { color: var(--code-text-color); background: var(--code-background); @mixin where-light { --code-text-color: var(--mantine-color-gray-7); --code-background: var(--mantine-color-gray-0); --code-comment-color: var(--mantine-color-gray-6); --code-keyword-color: var(--mantine-color-violet-8); --code-tag-color: var(--mantine-color-red-9); --code-literal-color: var(--mantine-color-blue-6); --code-string-color: var(--mantine-color-blue-9); --code-variable-color: var(--mantine-color-lime-9); --code-class-color: var(--mantine-color-orange-9); } @mixin where-dark { --code-text-color: var(--mantine-color-dark-1); --code-background: var(--mantine-color-dark-8); --code-comment-color: var(--mantine-color-dark-3); --code-keyword-color: var(--mantine-color-violet-3); --code-tag-color: var(--mantine-color-yellow-4); --code-literal-color: var(--mantine-color-blue-4); --code-string-color: var(--mantine-color-green-6); --code-variable-color: var(--mantine-color-blue-2); --code-class-color: var(--mantine-color-orange-5); } .hljs-comment, .hljs-quote { font-style: italic; color: var(--code-comment-color); } .hljs-doctag, .hljs-formula, .hljs-keyword { color: var(--code-keyword-color); } .hljs-deletion, .hljs-name, .hljs-section, .hljs-selector-tag, .hljs-subst { color: var(--code-tag-color); } .hljs-literal { color: var(--code-literal-color); } .hljs-addition, .hljs-attribute, .hljs-meta .hljs-string, .hljs-regexp, .hljs-string { color: var(--code-string-color); } .hljs-attr, .hljs-number, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-pseudo, .hljs-template-variable, .hljs-type, .hljs-variable { color: var(--code-variable-color); } .hljs-bullet, .hljs-link, .hljs-meta, .hljs-selector-id, .hljs-symbol, .hljs-title, .hljs-built_in, .hljs-class .hljs-title, .hljs-title.class_ { color: var(--code-class-color); } .hljs-emphasis { font-style: italic; } .hljs-strong { font-weight: 700; } .hljs-link { text-decoration: underline; } } ================================================ FILE: ui/mantine-ui/src/main.tsx ================================================ import ReactDOM from 'react-dom/client'; import App from './App'; ReactDOM.createRoot(document.getElementById('root')!).render(); ================================================ FILE: ui/mantine-ui/src/pages/Alerts.page.test.tsx ================================================ import { render } from '@test-utils'; import { AlertsPage } from './Alerts.page'; describe('AlertsPage', () => { it('renders without crashing', () => { render(); }); }); ================================================ FILE: ui/mantine-ui/src/pages/Alerts.page.tsx ================================================ import { Text } from '@mantine/core'; export function AlertsPage() { return ( Alerts List ); } ================================================ FILE: ui/mantine-ui/src/pages/Config.page.tsx ================================================ import { CodeHighlight } from '@mantine/code-highlight'; import { useStatus } from '@/data/status'; export function ConfigPage() { const { data } = useStatus(); return ( ); } ================================================ FILE: ui/mantine-ui/src/pages/Silences.page.tsx ================================================ import { Text } from '@mantine/core'; export function SilencesPage() { return ( Silences ); } ================================================ FILE: ui/mantine-ui/src/pages/Status.page.tsx ================================================ import { Table } from '@mantine/core'; import InfoPageCard from '@/components/InfoPageCard'; import InfoPageStack from '@/components/InfoPageStack'; import { useStatus } from '@/data/status'; export function StatusPage() { const { data } = useStatus(); return ( Version {data.versionInfo.version} Revision {data.versionInfo.revision} Branch {data.versionInfo.branch} Build User {data.versionInfo.buildUser} Build Date {data.versionInfo.buildDate} Go Version {data.versionInfo.goVersion}
Uptime {data.uptime} Cluster Name {data.cluster.name} Cluster Status {data.cluster.status} Number of Peers {data.cluster.peers.length}
Peer Name Address {data.cluster.peers.map((peer, index) => ( {peer.name} {peer.address} ))}
); } ================================================ FILE: ui/mantine-ui/src/theme.ts ================================================ import { createTheme } from '@mantine/core'; export const theme = createTheme({ /** Put your mantine theme override here */ }); ================================================ FILE: ui/mantine-ui/src/vite-env.d.ts ================================================ /// ================================================ FILE: ui/mantine-ui/test-utils/index.ts ================================================ import userEvent from '@testing-library/user-event'; export * from '@testing-library/react'; export { render } from './render'; export { userEvent }; ================================================ FILE: ui/mantine-ui/test-utils/render.tsx ================================================ import { render as testingLibraryRender } from '@testing-library/react'; import { MantineProvider } from '@mantine/core'; import { theme } from '../src/theme'; export function render(ui: React.ReactNode) { return testingLibraryRender(ui, { wrapper: ({ children }: { children: React.ReactNode }) => ( {children} ), }); } ================================================ FILE: ui/mantine-ui/tsconfig.json ================================================ { "compilerOptions": { "types": ["node", "@testing-library/jest-dom", "vitest/globals"], "target": "ES2020", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": false, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "paths": { "@/*": ["./src/*"], "@test-utils": ["./test-utils"] } }, "include": ["src", "test-utils"] } ================================================ FILE: ui/mantine-ui/vite.config.mjs ================================================ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { globals: true, environment: 'jsdom', setupFiles: './vitest.setup.mjs', }, server: { proxy: { '/api': { target: 'http://127.0.0.1:9093', }, }, }, }); ================================================ FILE: ui/mantine-ui/vitest.setup.mjs ================================================ import '@testing-library/jest-dom/vitest'; import { vi } from 'vitest'; const { getComputedStyle } = window; window.getComputedStyle = (elt) => getComputedStyle(elt); window.HTMLElement.prototype.scrollIntoView = () => {}; Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation((query) => ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), }); class ResizeObserver { observe() {} unobserve() {} disconnect() {} } window.ResizeObserver = ResizeObserver; ================================================ FILE: ui/web.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ui import ( "embed" "fmt" "io/fs" "log/slog" "net/http" _ "net/http/pprof" // Comment this line to disable pprof endpoint. "path" "strings" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/common/route" ) //go:embed app/script.js app/index.html app/favicon.ico app/lib var asset embed.FS // Register registers handlers to serve files for the web interface. func Register(r *route.Router, reloadCh chan<- chan error, logger *slog.Logger) { r.Get("/metrics", promhttp.Handler().ServeHTTP) appFS, err := fs.Sub(asset, "app") if err != nil { panic(err) // During build step, we did not embed a directory named `app`. } fs := http.FileServerFS(appFS) r.Get("/", func(w http.ResponseWriter, req *http.Request) { disableCaching(w) fs.ServeHTTP(w, req) }) r.Get("/script.js", func(w http.ResponseWriter, req *http.Request) { disableCaching(w) fs.ServeHTTP(w, req) }) r.Get("/favicon.ico", func(w http.ResponseWriter, req *http.Request) { disableCaching(w) fs.ServeHTTP(w, req) }) r.Get("/lib/*path", func(w http.ResponseWriter, req *http.Request) { disableCaching(w) fs.ServeHTTP(w, req) }) r.Post("/-/reload", func(w http.ResponseWriter, req *http.Request) { errc := make(chan error) defer close(errc) reloadCh <- errc if err := <-errc; err != nil { http.Error(w, fmt.Sprintf("failed to reload config: %s", err), http.StatusInternalServerError) } }) r.Get("/-/healthy", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "OK") }) r.Head("/-/healthy", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) r.Get("/-/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "OK") }) r.Head("/-/ready", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) debugHandlerFunc := func(w http.ResponseWriter, req *http.Request) { subpath := route.Param(req.Context(), "subpath") req.URL.Path = path.Join("/debug", subpath) // path.Join removes trailing slashes, but some pprof handlers expect them. if strings.HasSuffix(subpath, "/") && !strings.HasSuffix(req.URL.Path, "/") { req.URL.Path += "/" } http.DefaultServeMux.ServeHTTP(w, req) } r.Get("/debug/*subpath", debugHandlerFunc) r.Post("/debug/*subpath", debugHandlerFunc) } func disableCaching(w http.ResponseWriter) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") // Prevent proxies from caching. } ================================================ FILE: ui/web_test.go ================================================ // Copyright The Prometheus Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package ui import ( "log/slog" "net/http" "net/http/httptest" "os" "testing" "github.com/prometheus/common/route" "github.com/stretchr/testify/require" ) func TestDebugHandlersWithRoutePrefix(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) reloadCh := make(chan chan error) // Test with route prefix routePrefix := "/prometheus/alertmanager" router := route.New().WithPrefix(routePrefix) Register(router, reloadCh, logger) // Test GET request to pprof index (note: pprof index returns text/html) req := httptest.NewRequest("GET", routePrefix+"/debug/pprof/", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), "/debug/pprof/", "pprof page did not load with expected content when using a route prefix") // Test GET request to pprof heap endpoint req = httptest.NewRequest("GET", routePrefix+"/debug/pprof/heap", nil) w = httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) // Test without route prefix (should also work) router2 := route.New() Register(router2, reloadCh, logger) req = httptest.NewRequest("GET", "/debug/pprof/", nil) w = httptest.NewRecorder() router2.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.Contains(t, w.Body.String(), "/debug/pprof/", "pprof page did not load with expected content") } func TestWebRoutes(t *testing.T) { router := route.New() logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) Register(router, make(chan chan error), logger) tests := []struct { name string path string expectedCode int }{ { name: "root", path: "/", }, { name: "script.js", path: "/script.js", }, { name: "favicon.ico", path: "/favicon.ico", }, { name: "Lib wildcard path", // Replace with any path under `lib`, in case you want to remove elm-datepicker. path: "/lib/elm-datepicker/css/elm-datepicker.css", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, tt.path, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) res := w.Result() defer res.Body.Close() require.Equal(t, http.StatusOK, res.StatusCode) }) } }