diff --git a/assets/translations/en-us.yaml b/assets/translations/en-us.yaml index 5c1f69969d..ca9f17155b 100644 --- a/assets/translations/en-us.yaml +++ b/assets/translations/en-us.yaml @@ -817,6 +817,13 @@ monitoring: block: Block file: Filesystem v1Warning: Monitoring is currently deployed from Cluster Manager. If you are migrating from an older version of Rancher with monitoring enabled, please disable monitoring in Cluster Manager before attempting to install the new Rancher Monitoring chart in Cluster Explorer. + route: + fields: + receiver: Receiver + groupBy: Group By + groupWait: Group Wait + groupInterval: Group Interval + repeatInterval: Repeat Interval nameNsDescription: name: @@ -1343,6 +1350,10 @@ validation: min: '"{key}" should be at least {count} {count, plural, =1 {character} other {characters}}' targets: missingProjectId: A target must have a project selected. + monitoring: + route: + match: At least one Match or Match Regex must be selected + interval: '"{key}" must be of a format with digits followed by a unit i.e. 1h, 2m, 30s' wizard: back: Back @@ -1901,3 +1912,8 @@ typeLabel: one { Receiver } other { Receivers } } + monitoring.coreos.com.route: |- + {count, plural, + one { Route } + other { Routes } + } diff --git a/config/product/monitoring.js b/config/product/monitoring.js index 78a4344809..956b9e5394 100644 --- a/config/product/monitoring.js +++ b/config/product/monitoring.js @@ -1,9 +1,7 @@ import { DSL } from '@/store/type-map'; -import { MONITORING, SECRET } from '@/config/types'; +import { MONITORING } from '@/config/types'; import { STATE, NAME as NAME_COL, AGE } from '@/config/table-headers'; -import { base64Decode } from '@/utils/crypto'; -import jsyaml from 'js-yaml'; -import { FILENAME, getSecretId } from '@/models/monitoring.coreos.com.receiver'; +import { getAllReceivers, getAllRoutes } from '@/utils/alertmanagerconfig'; export const NAME = 'monitoring'; export const CHART_NAME = 'rancher-monitoring'; @@ -25,39 +23,11 @@ export function init(store) { PROMETHEUSRULE, PROMETHEUS, SPOOFED: { - RECEIVER, RECEIVER_SPEC, RECEIVER_EMAIL, RECEIVER_SLACK, RECEIVER_WEBHOOK, RECEIVER_HTTP_CONFIG + RECEIVER, RECEIVER_SPEC, RECEIVER_EMAIL, RECEIVER_SLACK, RECEIVER_WEBHOOK, RECEIVER_HTTP_CONFIG, + ROUTE, ROUTE_SPEC } } = MONITORING; - async function getSecretFile() { - const secretId = await getSecretId(store.dispatch); - const fileName = FILENAME; - const secret = await store.dispatch('cluster/find', { - type: SECRET, id: secretId, opt: { force: true } - }); - const file = secret?.data?.[fileName]; - const decodedFile = file ? base64Decode(file) : '{receivers: []}'; - - return jsyaml.safeLoad(decodedFile); - } - - async function getAllReceivers() { - try { - const file = await getSecretFile(); - const receivers = file.receivers || []; - const receiversWithName = receivers.filter(receiver => receiver.name); - const mapped = receiversWithName.map(receiver => store.dispatch('cluster/create', { - id: receiver.name, - spec: receiver, - type: 'monitoring.coreos.com.receiver' - })); - - return Promise.all(mapped); - } catch (ex) { - return []; - } - } - product({ ifHaveType: PODMONITOR, // possible RBAC issue here if mon turned on but user doesn't have view/read roles on pod monitors icon: 'monitoring' @@ -73,72 +43,99 @@ export function init(store) { exact: true }); - spoofedType( - { - label: 'Receivers', - type: RECEIVER, - schemas: [ - { - id: RECEIVER, - type: 'schema', - collectionMethods: ['POST'], - resourceFields: { spec: { type: RECEIVER_SPEC } } - }, - { - id: RECEIVER_SPEC, - type: 'schema', - resourceFields: { - name: { type: 'string' }, - email_configs: { type: `array[${ RECEIVER_EMAIL }]` }, - slack_configs: { type: `array[${ RECEIVER_SLACK }]` }, - webhook_configs: { type: `array[${ RECEIVER_WEBHOOK }]` } - } - }, - { - id: RECEIVER_EMAIL, - type: 'schema', - resourceFields: { - to: { type: 'string' }, - send_resolved: { type: 'boolean' }, - from: { type: 'string' }, - smarthost: { type: 'string' }, - require_tls: { type: 'boolean' }, - auth_username: { type: 'string' }, - auth_password: { type: 'string' } - } - }, - { - id: RECEIVER_SLACK, - type: 'schema', - resourceFields: { - api_url: { type: 'string' }, - channel: { type: 'string' }, - http_config: { type: RECEIVER_HTTP_CONFIG }, - send_resolved: { type: 'boolean' } - } - }, - { - id: RECEIVER_WEBHOOK, - type: 'schema', - resourceFields: { - url: { type: 'string' }, - http_config: { type: RECEIVER_HTTP_CONFIG }, - send_resolved: { type: 'boolean' } - } - }, - { - id: RECEIVER_HTTP_CONFIG, - type: 'schema', - resourceFields: { proxy_url: { type: 'string' } } - }, + spoofedType({ + label: 'Receivers', + type: RECEIVER, + schemas: [ + { + id: RECEIVER, + type: 'schema', + collectionMethods: ['POST'], + resourceFields: { spec: { type: RECEIVER_SPEC } } + }, + { + id: RECEIVER_SPEC, + type: 'schema', + resourceFields: { + name: { type: 'string' }, + email_configs: { type: `array[${ RECEIVER_EMAIL }]` }, + slack_configs: { type: `array[${ RECEIVER_SLACK }]` }, + webhook_configs: { type: `array[${ RECEIVER_WEBHOOK }]` } + } + }, + { + id: RECEIVER_EMAIL, + type: 'schema', + resourceFields: { + to: { type: 'string' }, + send_resolved: { type: 'boolean' }, + from: { type: 'string' }, + smarthost: { type: 'string' }, + require_tls: { type: 'boolean' }, + auth_username: { type: 'string' }, + auth_password: { type: 'string' } + } + }, + { + id: RECEIVER_SLACK, + type: 'schema', + resourceFields: { + api_url: { type: 'string' }, + channel: { type: 'string' }, + http_config: { type: RECEIVER_HTTP_CONFIG }, + send_resolved: { type: 'boolean' } + } + }, + { + id: RECEIVER_WEBHOOK, + type: 'schema', + resourceFields: { + url: { type: 'string' }, + http_config: { type: RECEIVER_HTTP_CONFIG }, + send_resolved: { type: 'boolean' } + } + }, + { + id: RECEIVER_HTTP_CONFIG, + type: 'schema', + resourceFields: { proxy_url: { type: 'string' } } + }, - ], - getInstances: getAllReceivers - }); + ], + getInstances: () => getAllReceivers(store.dispatch) + }); + + spoofedType({ + label: 'Routes', + type: ROUTE, + schemas: [ + { + id: ROUTE, + type: 'schema', + collectionMethods: ['POST'], + resourceFields: { spec: { type: ROUTE_SPEC } } + }, + { + id: ROUTE_SPEC, + type: 'schema', + resourceFields: { + receiver: { type: 'string' }, + group_by: { type: 'array[string]' }, + group_wait: { type: 'string' }, + group_interval: { type: 'string' }, + repeat_interval: { type: 'string' }, + match: { type: 'map[string]' }, + match_re: { type: 'map[string]' }, + } + }, + ], + getInstances: () => getAllRoutes(store.dispatch) + }); basicType([ 'monitoring-overview', RECEIVER, + ROUTE, SERVICEMONITOR, PODMONITOR, PROMETHEUSRULE, @@ -151,6 +148,7 @@ export function init(store) { mapType(PROMETHEUSRULE, store.getters['i18n/t'](`typeLabel.${ PROMETHEUSRULE }`, { count: 2 })); mapType(ALERTMANAGER, store.getters['i18n/t'](`typeLabel.${ ALERTMANAGER }`, { count: 2 })); mapType(RECEIVER, store.getters['i18n/t'](`typeLabel.${ RECEIVER }`, { count: 2 })); + mapType(ROUTE, store.getters['i18n/t'](`typeLabel.${ ROUTE }`, { count: 2 })); weightType(SERVICEMONITOR, 104, true); weightType(PODMONITOR, 103, true); @@ -168,6 +166,17 @@ export function init(store) { } ]); + headers(ROUTE, [ + NAME_COL, + { + name: 'receiver', + label: 'Configured Receiver', + value: 'spec.receiver', + sort: 'spec.receiver', + width: '85%' + } + ]); + headers(ALERTMANAGER, [ STATE, NAME_COL, diff --git a/config/types.js b/config/types.js index 4fb4ca777e..1b1cc58b71 100644 --- a/config/types.js +++ b/config/types.js @@ -87,7 +87,9 @@ export const MONITORING = { RECEIVER_EMAIL: 'monitoring.coreos.com.receiver.email', RECEIVER_SLACK: 'monitoring.coreos.com.receiver.slack', RECEIVER_WEBHOOK: 'monitoring.coreos.com.receiver.webhook', - RECEIVER_HTTP_CONFIG: 'monitoring.coreos.com.receiver.httpconfig' + RECEIVER_HTTP_CONFIG: 'monitoring.coreos.com.receiver.httpconfig', + ROUTE: 'monitoring.coreos.com.route', + ROUTE_SPEC: 'monitoring.coreos.com.route.spec', } }; diff --git a/edit/monitoring.coreos.com.route.vue b/edit/monitoring.coreos.com.route.vue new file mode 100644 index 0000000000..0d72082aeb --- /dev/null +++ b/edit/monitoring.coreos.com.route.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/list/monitoring.coreos.com.route.vue b/list/monitoring.coreos.com.route.vue new file mode 100644 index 0000000000..37734b9729 --- /dev/null +++ b/list/monitoring.coreos.com.route.vue @@ -0,0 +1,49 @@ + + + diff --git a/models/monitoring.coreos.com.receiver.js b/models/monitoring.coreos.com.receiver.js index 556ea65eba..592b9fff82 100644 --- a/models/monitoring.coreos.com.receiver.js +++ b/models/monitoring.coreos.com.receiver.js @@ -1,11 +1,4 @@ -import jsyaml from 'js-yaml'; -import { base64Decode, base64Encode } from '@/utils/crypto'; -import { MONITORING, SECRET } from '@/config/types'; - -const DEFAULT_SECRET_ID = 'cattle-monitoring-system/alertmanager-rancher-monitoring-alertmanager'; -const ALERTMANAGER_ID = 'cattle-monitoring-system/rancher-monitoring-alertmanager'; - -export const FILENAME = 'alertmanager.yaml'; +import { canCreate, updateConfig } from '@/utils/alertmanagerconfig'; export const RECEIVERS_TYPES = [ { @@ -38,21 +31,14 @@ export const RECEIVERS_TYPES = [ }, ]; -export async function getSecretId(dispatch, useCluster = true) { - const action = useCluster ? 'cluster/find' : 'find'; - const alertManager = await dispatch(action, { type: MONITORING.ALERTMANAGER, id: ALERTMANAGER_ID }); - - return alertManager?.spec?.configSecret || DEFAULT_SECRET_ID; -} - export default { removeSerially() { return true; }, remove() { - return async() => { - await this.updateReceivers((currentReceivers) => { + return () => { + return this.updateReceivers((currentReceivers) => { return currentReceivers.filter(r => r.name !== this.spec?.name); }); }; @@ -77,7 +63,7 @@ export default { }, canUpdate() { - return this.$rootGetters['cluster/byId'](SECRET, DEFAULT_SECRET_ID)?.canUpdate; + return this.secret.canUpdate; }, canCustomEdit() { @@ -85,11 +71,11 @@ export default { }, canCreate() { - return this.$rootGetters['type-map/isCreatable'](SECRET); + return canCreate(this.$rootGetters); }, canDelete() { - return this.$rootGetters['cluster/byId'](SECRET, DEFAULT_SECRET_ID)?.canDelete; + return this.secret.canDelete; }, canViewInApi() { @@ -121,46 +107,7 @@ export default { return types; }, - async secret() { - const secretId = await getSecretId(this.$dispatch, false); - - try { - return await this.$dispatch('find', { type: SECRET, id: secretId }); - } catch (ex) { - const [namespace, name] = secretId.split('/'); - const secret = await this.$dispatch('create', { type: SECRET }); - - secret.metadata = { - namespace, - name - }; - - return secret; - } - }, - updateReceivers() { - return async(fn) => { - const secret = await this.secret; - - secret.data = secret.data || {}; - const file = secret.data[FILENAME]; - const decodedFile = file ? base64Decode(file) : null; - const loadedFile = decodedFile ? jsyaml.safeLoad(decodedFile) : {}; - - loadedFile.receivers = loadedFile.receivers || []; - - const newReceivers = fn(loadedFile.receivers); - - loadedFile.receivers = newReceivers; - - const newFile = jsyaml.safeDump(loadedFile); - const encodedFile = base64Encode(newFile); - - secret.data[FILENAME] = encodedFile; - await secret.save(); - // Force a store update - await this.$dispatch('findAll', { type: this.type, opt: { force: true } }); - }; + return fn => updateConfig(this.$dispatch, 'receivers', this.type, fn); } }; diff --git a/models/monitoring.coreos.com.route.js b/models/monitoring.coreos.com.route.js new file mode 100644 index 0000000000..5ca4f3cd4a --- /dev/null +++ b/models/monitoring.coreos.com.route.js @@ -0,0 +1,130 @@ +import { isEmpty, set } from '@/utils/object'; +import { areRoutesSupportedFormat, canCreate, updateConfig } from '@/utils/alertmanagerconfig'; + +export const ROOT_NAME = 'root'; + +export default { + applyDefaults() { + return () => { + const spec = this.spec || {}; + + spec.group_by = spec.group_by || []; + spec.group_wait = spec.group_wait || '30s'; + spec.group_interval = spec.group_interval || '5m'; + spec.repeat_interval = spec.repeat_interval || '4h'; + spec.match = spec.match || {}; + spec.match_re = spec.match || {}; + + set(this, 'spec', spec); + }; + }, + + removeSerially() { + return true; + }, + + remove() { + return () => { + return this.updateRoutes((currentRoutes) => { + return currentRoutes.filter(r => r.name !== this.spec?.name); + }); + }; + }, + + save() { + return async() => { + const errors = await this.validationErrors(this); + + if (!isEmpty(errors)) { + return Promise.reject(errors); + } + + await this.updateRoutes((currentRoutes) => { + const existingRoute = currentRoutes.find(r => r.name === this.spec?.name); + + if (existingRoute) { + Object.assign(existingRoute, this.spec); + } else { + currentRoutes.push(this.spec); + } + + return currentRoutes; + }); + + return {}; + }; + }, + + canUpdate() { + return this.secret.canUpdate; + }, + + canCustomEdit() { + return true; + }, + + canCreate() { + return canCreate(this.$rootGetters) && areRoutesSupportedFormat(this.secret); + }, + + canDelete() { + return !this.isRoot && this.secret.canDelete; + }, + + canViewInApi() { + return false; + }, + + canYaml() { + return areRoutesSupportedFormat(this.secret); + }, + + customValidationRules() { + const rules = [ + { + nullable: false, + path: 'spec.name', + required: true, + translationKey: 'generic.name' + }, + { + nullable: false, + path: 'spec.receiver', + required: true, + translationKey: 'monitoring.route.fields.receiver' + }, + { + path: 'spec.group_wait', + validators: ['interval'], + translationKey: 'monitoring.route.fields.groupWait' + }, + { + path: 'spec.group_interval', + validators: ['interval'], + translationKey: 'monitoring.route.fields.groupInterval' + }, + { + path: 'spec.repeat_interval', + validators: ['interval'], + translationKey: 'monitoring.route.fields.repeatInterval' + } + ]; + + if (!this.isRoot) { + rules.push({ + path: 'spec', + validators: ['matching'] + }); + } + + return rules; + }, + + updateRoutes() { + return fn => updateConfig(this.$dispatch, 'route.routes', this.type, fn); + }, + + isRoot() { + return this.id === ROOT_NAME; + } +}; diff --git a/plugins/steve/resource-instance.js b/plugins/steve/resource-instance.js index 2fa485e9f8..54fb45d0a2 100644 --- a/plugins/steve/resource-instance.js +++ b/plugins/steve/resource-instance.js @@ -1236,7 +1236,7 @@ export default { const validatorExists = Object.prototype.hasOwnProperty.call(CustomValidators, validatorName); if (!isEmpty(validatorName) && validatorExists) { - CustomValidators[validatorName](pathValue, this.$rootGetters, errors, validatorArgs); + CustomValidators[validatorName](pathValue, this.$rootGetters, errors, validatorArgs, displayKey); } else if (!isEmpty(validatorName) && !validatorExists) { // eslint-disable-next-line console.warn(this.t('validation.custom.missing', { validatorName })); diff --git a/store/type-map.js b/store/type-map.js index 4f61d285a3..befae9d099 100644 --- a/store/type-map.js +++ b/store/type-map.js @@ -1214,6 +1214,15 @@ export const mutations = { state.uncreatable.push(match); }, + removeUncreatableType(state, {match}) { + match = ensureRegex(match); + match = regexToString(match); + const matchingIndex = state.uncreatable.findIndex((regex) => regex === match); + if (matchingIndex >= 0) { + state.uncreatable.splice(matchingIndex, 1); + } + }, + immutableType(state, { match }) { match = ensureRegex(match); match = regexToString(match); @@ -1261,6 +1270,12 @@ export const actions = { dispatch('prefs/set', { key: EXPANDED_GROUPS, value: groups }, { root: true }); }, + uncreatableType({ commit }, match) { + commit(`uncreatableType`, match); + }, + removeUncreatableType({ commit }, match) { + commit(`removeUncreatableType`, match); + } }; function _sortGroup(tree, mode) { diff --git a/utils/alertmanagerconfig.js b/utils/alertmanagerconfig.js new file mode 100644 index 0000000000..e760df677a --- /dev/null +++ b/utils/alertmanagerconfig.js @@ -0,0 +1,147 @@ +import jsyaml from 'js-yaml'; +import { base64Decode, base64Encode } from '@/utils/crypto'; +import { MONITORING, SECRET } from '@/config/types'; +import { get, set } from '@/utils/object'; +import isEmpty from 'lodash/isEmpty'; +import { ROOT_NAME } from '@/models/monitoring.coreos.com.route'; + +const DEFAULT_SECRET_ID = 'cattle-monitoring-system/alertmanager-rancher-monitoring-alertmanager'; +const ALERTMANAGER_ID = 'cattle-monitoring-system/rancher-monitoring-alertmanager'; + +export const FILENAME = 'alertmanager.yaml'; + +export async function getSecretId(dispatch) { + const isLocal = dispatch.name === 'boundDispatch'; + const action = isLocal ? 'cluster/find' : 'find'; + const alertManager = await dispatch(action, { type: MONITORING.ALERTMANAGER, id: ALERTMANAGER_ID }); + + return alertManager?.spec?.configSecret || DEFAULT_SECRET_ID; +} + +export async function getSecret(dispatch) { + const secretId = await getSecretId(dispatch, false); + + try { + const isLocal = dispatch.name === 'boundDispatch'; + const action = isLocal ? 'cluster/find' : 'find'; + + return await dispatch(action, { type: SECRET, id: secretId }); + } catch (ex) { + const [namespace, name] = secretId.split('/'); + const secret = await dispatch('create', { type: SECRET }); + + secret.metadata = { + namespace, + name + }; + + return secret; + } +} + +function extractConfig(secret) { + secret.data = secret.data || {}; + const file = secret.data[FILENAME]; + const decodedFile = file ? base64Decode(file) : '{}'; + const config = jsyaml.safeLoad(decodedFile); + + config.receivers = config.receivers || []; + config.route = config.route || {}; + config.route.routes = config.route.routes || []; + + return config; +} + +export async function loadConfig(dispatch) { + const secret = await getSecret(dispatch); + + return { + config: extractConfig(secret), + secret + }; +} + +export async function updateConfig(dispatch, path, type, updateFn) { + const { config, secret } = await loadConfig(dispatch); + + set(config, path, get(config, path) || []); + + const newValue = updateFn(get(config, path)); + + set(config, path, newValue); + + const routes = config.route.routes; + const rootIndex = routes.findIndex(route => route.name === ROOT_NAME); + + if (rootIndex >= 0) { + const rootRoute = routes.splice(rootIndex, 1)[0]; + + rootRoute.routes = routes; + config.route = rootRoute; + } + + const newFile = jsyaml.safeDump(config); + const encodedFile = base64Encode(newFile); + + secret.data[FILENAME] = encodedFile; + await secret.save(); + // Force a store update + await dispatch('findAll', { type, opt: { force: true } }); +} + +export async function getAllReceivers(dispatch) { + try { + const { config, secret } = await loadConfig(dispatch); + const receivers = config.receivers || []; + const receiversWithName = receivers.filter(receiver => receiver.name); + const mapped = receiversWithName.map(receiver => dispatch('cluster/create', { + id: receiver.name, + spec: receiver, + type: MONITORING.SPOOFED.RECEIVER, + secret + })); + + return Promise.all(mapped); + } catch (ex) { + return []; + } +} + +export async function getAllRoutes(dispatch) { + try { + const { config, secret } = await loadConfig(dispatch); + + config.route = config.route || {}; + config.route.name = ROOT_NAME; + const routes = config.route?.routes || []; + const routesWithName = routes.filter(route => route.name); + + routesWithName.push(config.route); + + const mapped = routesWithName.map(route => dispatch('cluster/create', { + id: route.name, + spec: route, + type: MONITORING.SPOOFED.ROUTE, + secret + })); + + return Promise.all(mapped); + } catch (ex) { + return []; + } +} + +export function areRoutesSupportedFormat(secret) { + try { + const config = extractConfig(secret); + const routes = config.route?.routes || []; + + return !routes.some(isEmpty); + } catch (ex) { + return false; + } +} + +export function canCreate(rootGetters) { + return rootGetters['type-map/isCreatable'](SECRET); +} diff --git a/utils/custom-validators.js b/utils/custom-validators.js index 74997f8f33..9a3d120777 100644 --- a/utils/custom-validators.js +++ b/utils/custom-validators.js @@ -1,6 +1,7 @@ import { flowOutput } from '@/utils/validators/flow-output'; import { clusterIp, externalName, servicePort } from '@/utils/validators/service'; import { ruleGroups, groupsAreValid } from '@/utils/validators/prometheusrule'; +import { interval, matching } from '@/utils/validators/monitoring-route'; /** * Custom validation functions beyond normal scalr types @@ -13,5 +14,7 @@ export default { flowOutput, groupsAreValid, ruleGroups, + interval, servicePort, + matching }; diff --git a/utils/validators/monitoring-route.js b/utils/validators/monitoring-route.js new file mode 100644 index 0000000000..54151dd3a9 --- /dev/null +++ b/utils/validators/monitoring-route.js @@ -0,0 +1,13 @@ +import isEmpty from 'lodash/isEmpty'; + +export function matching(spec, getters, errors, validatorArgs) { + if (isEmpty(spec?.match) && isEmpty(spec?.['match_re'])) { + errors.push(getters['i18n/t']('validation.monitoring.route.match')); + } +} + +export function interval(value, getters, errors, validatorArgs, displayKey) { + if (!/^\d+[hms]$/.test(value)) { + errors.push(getters['i18n/t']('validation.monitoring.route.interval', { key: displayKey })); + } +}