Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Throttle handling of shoot events in the frontend #1637

Merged
merged 82 commits into from
Dec 7, 2023
Merged
Show file tree
Hide file tree
Changes from 66 commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
0ad910f
forward only { type, uid } to the client for shoot watches
holgerkoser Nov 8, 2023
b5fe8e9
Always use `shootInfo` when accessing additional shoot info
holgerkoser Nov 8, 2023
20a2df8
add throttle to lodash
holgerkoser Nov 8, 2023
0cea07e
rename `handleEvent` to `onPodEvent` to avoid name clashes with the s…
holgerkoser Nov 8, 2023
d35f3ed
move `shootHasIssue` to shootStore helper
holgerkoser Nov 8, 2023
163fe9d
add synchronize action to socketStore
holgerkoser Nov 8, 2023
5674e76
throttle handling of shoot events
holgerkoser Nov 8, 2023
fd7e3c2
fixed existing tests
holgerkoser Nov 9, 2023
b36c513
add cache test
holgerkoser Nov 9, 2023
922b52b
added io tests
holgerkoser Nov 9, 2023
70509e4
replace deprecated `selectedItems` with `selectedShoots`
holgerkoser Nov 9, 2023
694e0fa
do not hard-code throttleDelay
holgerkoser Nov 9, 2023
7226ba2
fixed bug in `handleEvent`
holgerkoser Nov 9, 2023
3d87035
expose `activeShoots` for testing
holgerkoser Nov 9, 2023
03ca81a
adapted tests to throttled event handling
holgerkoser Nov 9, 2023
c69742f
Merge branch 'master' into bug/fix-1636
holgerkoser Nov 9, 2023
a35038f
namespace is no longer in the event payload
holgerkoser Nov 10, 2023
fe0fe61
fixed shoot event handing for shoot items
holgerkoser Nov 10, 2023
bf6a720
Merge branch 'bug/fix-1636' of github.com:gardener/dashboard into bug…
holgerkoser Nov 10, 2023
112b559
Do not call cancel if the function does not exist
holgerkoser Nov 10, 2023
96ed81f
fixed shoot item placeholder bug and removed unused `throttleDelay` p…
holgerkoser Nov 10, 2023
0bcf9b8
Remove `key` from `<g-shoot-list-row>`
holgerkoser Nov 10, 2023
db10b93
fixed a bug
holgerkoser Nov 17, 2023
be320df
dynamic throttle delay
holgerkoser Nov 17, 2023
67fd201
improve sorting and filtering
holgerkoser Nov 17, 2023
97c6b44
set synchronization timout to 80% of throttleDelay
holgerkoser Nov 20, 2023
33e132b
shoot to be synchronized is meanwhile deleted
holgerkoser Nov 20, 2023
2d6ed76
set synchronize timeout to 60 sec and make sure it is not called twice
holgerkoser Nov 20, 2023
2378483
only send all shoot details for single shoot subscriptions
holgerkoser Nov 20, 2023
ea5cbec
Updated backend snapshots
holgerkoser Nov 20, 2023
42da4a5
cache projectNameByNamespace
holgerkoser Nov 21, 2023
4c40feb
improve filtering by ticket labels
holgerkoser Nov 21, 2023
e34bc39
fixed lint errors
holgerkoser Nov 21, 2023
7499818
more readable
holgerkoser Nov 21, 2023
d377c03
unsubscribe only in global beforeAll router guard
holgerkoser Nov 22, 2023
6144c3a
fixed bug: always return an array!
holgerkoser Nov 22, 2023
9e022c5
throttleDelay can be reduced to 2 ms per shoot
holgerkoser Nov 22, 2023
476d165
handle 429 gracefully
holgerkoser Nov 22, 2023
14ec594
fixed import
holgerkoser Nov 22, 2023
9d3f6bd
optimize subscription state handling
holgerkoser Nov 22, 2023
f2d8434
always await unsubscribe before subscribing
holgerkoser Nov 22, 2023
d03b361
Show loading and no-data text in shoot list
holgerkoser Nov 22, 2023
225f04e
trim object metadata in http list requests
holgerkoser Nov 22, 2023
c1e5a66
serve from cache
holgerkoser Nov 23, 2023
ce41ed0
fixes #1644
holgerkoser Nov 23, 2023
aa05cca
merge wellKnownConditions and knownConditions
holgerkoser Nov 23, 2023
9721d6a
remove cachedItems in `GShootList`
holgerkoser Nov 23, 2023
182b1a4
fix authorization checks and throw 403
holgerkoser Nov 24, 2023
e20caf7
client can choose to serve from cache or force fetch from apiserver
holgerkoser Nov 24, 2023
e3adec8
fixed and added tests
holgerkoser Nov 24, 2023
7c5c3a9
rename test description
holgerkoser Nov 24, 2023
a6c925a
move selector parsing and filtering to utils
holgerkoser Nov 24, 2023
362ba1d
switch from `force` to `useCache`
holgerkoser Nov 24, 2023
67ee94f
the user should be able to switch useCache on/off
holgerkoser Nov 24, 2023
cb84aa5
clear shootEvents in clearAll
holgerkoser Nov 26, 2023
556c721
Small improvement on settings page
holgerkoser Nov 26, 2023
7458a8b
Use constant for exists, notExists, equal and notEqual
holgerkoser Nov 28, 2023
72696a5
add utility function that merges config settings and client specifica…
holgerkoser Nov 28, 2023
9b143c1
add `useCache` option to list function and use the value for data ret…
holgerkoser Nov 28, 2023
ad9d130
fix tests
holgerkoser Nov 28, 2023
84d07da
add config property `experimentalUseWatchCacheForListShoots` to charts
holgerkoser Nov 28, 2023
a088ca9
rename localStorage property for useWatchCache
holgerkoser Nov 28, 2023
aca6305
remove configuration option from GSettings page
holgerkoser Nov 28, 2023
8414c06
added some unit tests
holgerkoser Nov 28, 2023
9f904d5
Increase min and decrease max throttleDelay
holgerkoser Dec 4, 2023
5d5a837
Better not found error message in monitoring server. Do not syncroniz…
holgerkoser Dec 4, 2023
a39b93c
PR Feedback I
holgerkoser Dec 4, 2023
55cc890
stopp propagation of event on popper
holgerkoser Dec 7, 2023
2c492e2
No need to have a ref for timeoutId
holgerkoser Dec 7, 2023
3351c72
remove log message
holgerkoser Dec 7, 2023
2e0638d
Better no data text
holgerkoser Dec 7, 2023
f7a8052
Assign shootInfo to the selected item (this is required for GShootAcc…
holgerkoser Dec 7, 2023
3d69a9b
Do not throw an error if the user is not authorized for a namespace. …
holgerkoser Dec 7, 2023
5824356
use always a timeout of 60 sec
holgerkoser Dec 7, 2023
7ea705a
do not synchronize shoots if running in the background
holgerkoser Dec 7, 2023
f109422
increase throttle delay to save bandwidth
holgerkoser Dec 7, 2023
fedaab9
split set and unset shoot subscription code into multipe smaller func…
holgerkoser Dec 7, 2023
ff6acbf
improve subscription state handling
holgerkoser Dec 7, 2023
7f89fcf
Merge branch 'master' into bug/fix-1636
holgerkoser Dec 7, 2023
cb3db3c
allow configuration of throttleDelayPerCluster
holgerkoser Dec 7, 2023
a998a4c
Merge branch 'bug/fix-1636' of github.com:gardener/dashboard into bug…
holgerkoser Dec 7, 2023
75287a5
Merge branch 'master' into bug/fix-1636
holgerkoser Dec 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/__fixtures__/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const defaultConfig = {
apiServerUrl: 'https://kubernetes.external.foo.bar',
apiServerCaData: toBase64(ca),
tokenRequestAudiences: ['aud1', 'aud2'],
experimentalUseWatchCacheForListShoots: 'no',
gitHub: {
apiUrl: 'https://api.github.com',
org: 'gardener',
Expand Down
3 changes: 3 additions & 0 deletions backend/__fixtures__/shoots.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ const shoots = {
const items = shoots.list(namespace)
return find(items, ['metadata.name', name])
},
getByUid (uid) {
return find(shootList, ['metadata.uid', uid])
},
list (namespace) {
const items = cloneDeep(shootList)
return namespace
Expand Down
16 changes: 14 additions & 2 deletions backend/lib/cache/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const _ = require('lodash')
const { NotFound } = require('http-errors')
const createTicketCache = require('./tickets')
const { parseSelectors, filterBySelectors } = require('../utils')

/*
In file `lib/api.js` the synchronization is started with the privileged dashboardClient.
Expand Down Expand Up @@ -97,12 +98,23 @@ module.exports = {
.get('spec.namespace')
.value()
},
getShoots () {
return cache.getShoots()
getShoots (namespace, query = {}) {
let items = cache.getShoots()
if (namespace && namespace !== '_all') {
holgerkoser marked this conversation as resolved.
Show resolved Hide resolved
items = items.filter(item => item.metadata.namespace === namespace)
}
const selectors = parseSelectors(query.labelSelector?.split(',') ?? [])
if (selectors.length) {
items = items.filter(filterBySelectors(selectors))
}
return items
},
getShoot (namespace, name) {
return cache.get('shoots').find({ metadata: { namespace, name } })
},
getShootByUid (uid) {
return cache.get('shoots').find(['metadata.uid', uid])
},
getControllerRegistrations () {
return cache.getControllerRegistrations()
},
Expand Down
112 changes: 111 additions & 1 deletion backend/lib/io.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const createError = require('http-errors')
const kubernetesClient = require('@gardener-dashboard/kube-client')
const cache = require('./cache')
const logger = require('./logger')
const { projectFilter } = require('./utils')
const { projectFilter, trimObjectMetadata } = require('./utils')
const { authenticate } = require('./security')
const { authorization } = require('./services')

Expand Down Expand Up @@ -126,6 +126,103 @@ async function unsubscribe (socket, key) {
}
}

function parseRooms (socket) {
holgerkoser marked this conversation as resolved.
Show resolved Hide resolved
let isAdmin = false
const namespaces = []
const qualifiedNames = []
for (const room of Array.from(socket.rooms)) {
if (room === socket.id) {
continue
}
const parts = room.split(';')
const keys = parts[0].split(':')
if (keys.shift() !== 'shoots') {
continue
}
if (keys.pop() === 'admin') {
isAdmin = true
}
if (parts.length < 2) {
continue
}
const [namespace, name] = parts[1].split('/')
if (!name) {
namespaces.push(namespace)
} else {
qualifiedNames.push([namespace, name].join('/'))
}
}
return [
isAdmin,
namespaces,
qualifiedNames
]
}

function synchronizeShoots (socket, uids = []) {
holgerkoser marked this conversation as resolved.
Show resolved Hide resolved
const user = getUserFromSocket(socket)
const [
isAdmin,
namespaces,
qualifiedNames
] = parseRooms(socket)

return uids.map(uid => {
const object = cache.getShootByUid(uid)
if (!object) {
// the shoot has been removed from the cache
return {
holgerkoser marked this conversation as resolved.
Show resolved Hide resolved
kind: 'Status',
apiVersion: 'v1',
status: 'Failure',
message: `Shoot with uid ${uid} is no longer available`,
reason: 'Gone',
details: {
uid,
group: 'core.gardener.cloud',
kind: 'shoots'
},
code: 410
}
}
const { namespace, name } = object.metadata
const qualifiedName = [namespace, name].join('/')
if (!isAdmin && !namespaces.includes(namespace) && !qualifiedNames.includes(qualifiedName)) {
// the socket has NOT joined a room (admin, namespace or individual shoot) the current shoot belongs to
logger.error('User %s has no authorization to synchronize shoot %s in namespace %s', user.id, name, namespace)
return {
kind: 'Status',
apiVersion: 'v1',
status: 'Failure',
message: `Insufficient authorization to synchronize shoot ${name} in namespace ${namespace}`,
reason: 'Forbidden',
details: {
uid,
name,
namespace,
group: 'core.gardener.cloud',
kind: 'shoots'
},
code: 403
}
}
// only send all shoot details for single shoot subscriptions
if (!qualifiedNames.includes(qualifiedName)) {
trimObjectMetadata(object)
}
return object
holgerkoser marked this conversation as resolved.
Show resolved Hide resolved
})
}

function synchronize (socket, key, ...args) {
switch (key) {
case 'shoots':
return synchronizeShoots(socket, ...args)
default:
throw new TypeError(`Invalid synchronization type - ${key}`)
}
}

function setDisconnectTimeout (socket, delay) {
delay = Math.min(2147483647, delay) // setTimeout delay must not exceed 32-bit signed integer
logger.debug('Socket %s will expire in %d seconds', socket.id, Math.floor(delay / 1000))
Expand Down Expand Up @@ -205,6 +302,19 @@ function init (httpServer, cache) {
}
})

// handle 'synchronize' events
socket.on('synchronize', async (key, ...args) => {
const done = args.pop()
try {
const items = await synchronize(socket, key, ...args)
done({ statusCode: 200, items })
} catch (err) {
logger.error('Socket %s synchronize error: %s', socket.id, err.message)
const { statusCode = 500, name, message } = err
done({ statusCode, name, message })
}
})

// handle 'disconnect' event
socket.once('disconnect', reason => {
clearTimeout(timeoutId)
Expand Down
8 changes: 7 additions & 1 deletion backend/lib/routes/shoots.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
const express = require('express')
const { shoots } = require('../services')
const { metricsRoute } = require('../middleware')
const { trimObjectMetadata, useWatchCacheForListShoots } = require('../utils')

const router = module.exports = express.Router({
mergeParams: true
Expand All @@ -23,7 +24,12 @@ router.route('/')
const user = req.user
const namespace = req.params.namespace
const labelSelector = req.query.labelSelector
res.send(await shoots.list({ user, namespace, labelSelector }))
const useCache = useWatchCacheForListShoots(req.query.useCache)
const shootList = await shoots.list({ user, namespace, labelSelector, useCache })
for (const object of shootList.items) {
trimObjectMetadata(object)
}
res.send(shootList)
} catch (err) {
next(err)
}
Expand Down
44 changes: 37 additions & 7 deletions backend/lib/services/shoots.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
const { isHttpError } = require('@gardener-dashboard/request')
const { cleanKubeconfig, Config } = require('@gardener-dashboard/kube-config')
const { dashboardClient } = require('@gardener-dashboard/kube-client')
const createError = require('http-errors')
const utils = require('../utils')
const cache = require('../cache')
const authorization = require('./authorization')
Expand All @@ -20,31 +21,60 @@ const config = require('../config')
const { decodeBase64, getSeedNameFromShoot, getSeedIngressDomain, projectFilter } = utils
const { getSeed } = cache

exports.list = async function ({ user, namespace, labelSelector, shootsWithIssuesOnly = false }) {
exports.list = async function ({ user, namespace, labelSelector, useCache = false }) {
const client = user.client
const query = {}
if (labelSelector) {
query.labelSelector = labelSelector
} else if (shootsWithIssuesOnly) {
query.labelSelector = 'shoot.gardener.cloud/status!=healthy'
}
if (namespace === '_all') {
if (await authorization.canListShoots(user)) {
// user is permitted to list shoots in all namespaces
if (useCache) {
return {
apiVersion: 'v1',
kind: 'List',
items: cache.getShoots(namespace, query)
}
}
return client['core.gardener.cloud'].shoots.listAllNamespaces(query)
} else {
const promises = _
// user is permitted to list shoots only in namespaces associated with their projects
const namespaces = _
.chain(cache.getProjects())
.filter(projectFilter(user, false))
.map(project => client['core.gardener.cloud'].shoots.list(project.spec.namespace, query))
.map('spec.namespace')
.value()
const shootLists = await Promise.all(promises)
if (useCache) {
const isAllowedList = await Promise.all(namespaces.map(namespace => authorization.canListShoots(user, namespace)))
if (isAllowedList.some(value => !value)) {
throw createError(403, 'No authorization to list shoots in all namespaces')
}
holgerkoser marked this conversation as resolved.
Show resolved Hide resolved
return {
apiVersion: 'v1',
kind: 'List',
items: namespaces.flatMap(namespace => cache.getShoots(namespace, query))
}
}
const shootLists = await Promise.all(namespaces.map(namespace, client['core.gardener.cloud'].shoots.list(namespace, query)))
return {
apiVersion: 'v1',
kind: 'List',
items: _.flatMap(shootLists, 'items')
items: shootLists.flatMap(({ items }) => items)
}
}
}
if (useCache) {
const isAllowed = await authorization.canListShoots(user, namespace)
if (!isAllowed) {
throw createError(403, `No authorization to list shoots in namespace ${namespace}`)
}
return {
apiVersion: 'v1',
kind: 'List',
items: cache.getShoots(namespace, query)
}
}
return client['core.gardener.cloud'].shoots.list(namespace, query)
}

Expand Down
93 changes: 92 additions & 1 deletion backend/lib/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ const _ = require('lodash')
const config = require('../config')
const assert = require('assert').strict

const EXISTS = '∃'
const NOT_EXISTS = '!∃'
const EQUAL = '='
const NOT_EQUAL = '!='

function decodeBase64 (value) {
if (!value) {
return
Expand Down Expand Up @@ -64,6 +69,82 @@ function projectFilter (user, canListProjects = false) {
}
}

function trimObjectMetadata (object) {
object.metadata.managedFields = undefined
if (object.metadata.annotations) {
object.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration'] = undefined
}
return object
}

function parseSelectors (selectors) {
const items = []
for (const selector of selectors) {
const [, notOperator, key, operator, value = ''] = /^(!)?([a-zA-Z0-9._/-]+)(=|==|!=)?([a-zA-Z0-9._-]+)?$/.exec(selector) ?? []
if (notOperator) {
if (!operator) {
items.push({ op: NOT_EXISTS, key })
}
} else if (!operator) {
items.push({ op: EXISTS, key })
} else if (operator === '!=') {
items.push({ op: NOT_EQUAL, key, value })
} else if (operator === '=' || operator === '==') {
items.push({ op: EQUAL, key, value })
}
}
return items
}

function filterBySelectors (selectors) {
return item => {
const labels = item.metadata.labels ?? {}
for (const { op, key, value } of selectors) {
const labelValue = labels[key] ?? ''
switch (op) {
case NOT_EXISTS: {
if (key in labels) {
return false
}
break
}
case EXISTS: {
if (!(key in labels)) {
return false
}
break
}
case NOT_EQUAL: {
if (labelValue === value) {
return false
}
break
}
case EQUAL: {
if (labelValue !== value) {
return false
}
break
}
}
}
return true
}
}

function useWatchCacheForListShoots (useCache) {
switch (config.experimentalUseWatchCacheForListShoots) {
case 'never':
return false
case 'always':
return true
case 'no':
return ['true', 'yes', 'on'].includes(useCache)
case 'yes':
return !['false', 'no', 'off'].includes(useCache)
}
}

function getConfigValue (path, defaultValue) {
const value = _.get(config, path, defaultValue)
if (arguments.length === 1 && typeof value === 'undefined') {
Expand Down Expand Up @@ -98,9 +179,19 @@ module.exports = {
decodeBase64,
encodeBase64,
projectFilter,
trimObjectMetadata,
parseSelectors,
filterBySelectors,
useWatchCacheForListShoots,
getConfigValue,
getSeedNameFromShoot,
shootHasIssue,
isSeedUnreachable,
getSeedIngressDomain
getSeedIngressDomain,
constants: Object.freeze({
EXISTS,
NOT_EXISTS,
EQUAL,
NOT_EQUAL
})
}
Loading