diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 22bd06c2aeb..9ecda983b78 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -8,6 +8,13 @@ > Users must be able to say: “Nice enhancement, I'm eager to test it” - [Backup/Restore] Button to open the raw log in the REST API (PR [#6936](https://github.com/vatesfr/xen-orchestra/pull/6936)) +- [Netbox] New major version. BREAKING: in order for this new version to work, you need to assign the type `virtualization > vminterface` to the custom field `UUID` in your Netbox instance. [See documentation](https://xen-orchestra.com/docs/advanced.html#netbox). [#6038](https://github.com/vatesfr/xen-orchestra/issues/6038) [#6135](https://github.com/vatesfr/xen-orchestra/issues/6135) [#6024](https://github.com/vatesfr/xen-orchestra/issues/6024) [#6036](https://github.com/vatesfr/xen-orchestra/issues/6036) [Forum#6070](https://xcp-ng.org/forum/topic/6070) [Forum#6149](https://xcp-ng.org/forum/topic/6149) [Forum#6332](https://xcp-ng.org/forum/topic/6332) (PR [#6950](https://github.com/vatesfr/xen-orchestra/pull/6950)) + - Synchronize VM description + - Fix duplicated VMs in Netbox after disconnecting one pool + - Migrating a VM from one pool to another keeps VM data added manually + - Fix largest IP prefix being picked instead of smallest + - Fix synchronization not working if some pools are unavailable + - Better error messages ### Bug fixes @@ -48,6 +55,7 @@ - xo-server patch - xo-server-transport-xmpp patch - xo-server-audit patch +- xo-server-netbox major - xo-web minor diff --git a/docs/advanced.md b/docs/advanced.md index 024fdecae1a..381ffcd1fc3 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -354,7 +354,7 @@ XO will try to find the right prefix for each IP address. If it can't find a pre - Add a UUID custom field: - Go to Other > Custom fields > Add - Create a custom field called "uuid" (lower case!) - - Assign it to object types `virtualization > cluster` and `virtualization > virtual machine` + - Assign it to object types `virtualization > cluster`, `virtualization > virtual machine` and `virtualization > vminterface` ![](./assets/customfield.png) diff --git a/packages/xo-server-netbox/package.json b/packages/xo-server-netbox/package.json index fad900a4b40..ed6ee1ddeeb 100644 --- a/packages/xo-server-netbox/package.json +++ b/packages/xo-server-netbox/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@babel/cli": "^7.13.16", "@babel/core": "^7.14.0", + "@babel/plugin-proposal-export-default-from": "^7.18.10", "@babel/preset-env": "^7.14.1", "cross-env": "^7.0.3" }, diff --git a/packages/xo-server-netbox/src/configuration-schema.js b/packages/xo-server-netbox/src/configuration-schema.js new file mode 100644 index 00000000000..cb44039afd2 --- /dev/null +++ b/packages/xo-server-netbox/src/configuration-schema.js @@ -0,0 +1,39 @@ +const configurationSchema = { + description: + 'Synchronize pools managed by Xen Orchestra with Netbox. Configuration steps: https://xen-orchestra.com/docs/advanced.html#netbox.', + type: 'object', + properties: { + endpoint: { + type: 'string', + title: 'Endpoint', + description: 'Netbox URI', + }, + allowUnauthorized: { + type: 'boolean', + title: 'Unauthorized certificates', + description: 'Enable this if your Netbox instance uses a self-signed SSL certificate', + }, + token: { + type: 'string', + title: 'Token', + description: 'Generate a token with write permissions from your Netbox interface', + }, + pools: { + type: 'array', + title: 'Pools', + description: 'Pools to synchronize with Netbox', + items: { + type: 'string', + $type: 'pool', + }, + }, + syncInterval: { + type: 'number', + title: 'Interval', + description: 'Synchronization interval in hours - leave empty to disable auto-sync', + }, + }, + required: ['endpoint', 'token', 'pools'], +} + +export { configurationSchema as default } diff --git a/packages/xo-server-netbox/src/diff.js b/packages/xo-server-netbox/src/diff.js new file mode 100644 index 00000000000..aa7e592c12c --- /dev/null +++ b/packages/xo-server-netbox/src/diff.js @@ -0,0 +1,31 @@ +import isEmpty from 'lodash/isEmpty' + +import { compareNames } from './name-dedup' + +/** + * Deeply compares 2 objects and returns an object representing the difference + * between the 2 objects. Returns undefined if the 2 objects are equal. + * In Netbox context: properly ignores differences found in names that could be + * due to name deduplication. e.g.: "foo" and "foo (2)" are considered equal. + * @param {any} newer + * @param {any} older + * @returns {Object|undefined} The patch that needs to be applied to older to get newer + */ +export default function diff(newer, older) { + if (typeof newer !== 'object') { + return newer === older ? undefined : newer + } + + newer = { ...newer } + Object.keys(newer).forEach(key => { + if ((key === 'name' && compareNames(newer[key], older[key])) || diff(newer[key], older?.[key]) === undefined) { + delete newer[key] + } + }) + + if (isEmpty(newer)) { + return + } + + return { ...newer, id: older.id } +} diff --git a/packages/xo-server-netbox/src/index.js b/packages/xo-server-netbox/src/index.js index 1c2c1c26e55..74ec5ab692c 100644 --- a/packages/xo-server-netbox/src/index.js +++ b/packages/xo-server-netbox/src/index.js @@ -1,39 +1,35 @@ -import assert from 'assert' import ipaddr from 'ipaddr.js' import semver from 'semver' import { createLogger } from '@xen-orchestra/log' -import { find, flatten, forEach, groupBy, isEmpty, keyBy, mapValues, omit, trimEnd, zipObject } from 'lodash' +import find from 'lodash/find' +import isEmpty from 'lodash/isEmpty' +import keyBy from 'lodash/keyBy' +import pick from 'lodash/pick' +import pickBy from 'lodash/pickBy' +import trimEnd from 'lodash/trimEnd' + +import diff from './diff' +import { deduplicateName } from './name-dedup' const log = createLogger('xo:netbox') const CLUSTER_TYPE = 'XCP-ng Pool' +const TYPES_WITH_UUID = ['virtualization.cluster', 'virtualization.virtualmachine', 'virtualization.vminterface'] const CHUNK_SIZE = 100 -const NAME_MAX_LENGTH = 64 +export const NAME_MAX_LENGTH = 64 +export const DESCRIPTION_MAX_LENGTH = 200 const REQUEST_TIMEOUT = 120e3 // 2min const M = 1024 ** 2 const G = 1024 ** 3 const { push } = Array.prototype -const diff = (newer, older) => { - if (typeof newer !== 'object') { - return newer === older ? undefined : newer - } +// ============================================================================= - newer = { ...newer } - Object.keys(newer).forEach(key => { - if (diff(newer[key], older[key]) === undefined) { - delete newer[key] - } - }) +export configurationSchema from './configuration-schema' +export default opts => new Netbox(opts) - return isEmpty(newer) ? undefined : newer -} - -const indexName = (name, index) => { - const suffix = ` (${index})` - return name.slice(0, NAME_MAX_LENGTH - suffix.length) + suffix -} +// ============================================================================= class Netbox { #allowUnauthorized @@ -49,6 +45,14 @@ class Netbox { constructor({ xo }) { this.#xo = xo + + this.getObject = function getObject(id) { + try { + return this.#xo.getObject(id) + } catch (err) {} + } + + this.getObjects = xo.getObjects.bind(xo) } configure(configuration) { @@ -95,7 +99,27 @@ class Netbox { this.#loaded = false } - async #makeRequest(path, method, data) { + async test() { + const randomSuffix = Math.random().toString(36).slice(2, 11) + const name = '[TMP] Xen Orchestra Netbox plugin test - ' + randomSuffix + await this.#request('/virtualization/cluster-types/', 'POST', { + name, + slug: 'xo-test-' + randomSuffix, + description: + "This type has been created by Xen Orchestra's Netbox plugin test. If it hasn't been properly deleted, you may delete it manually.", + }) + const clusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(name)}`) + + await this.#checkCustomFields() + + if (clusterTypes.length !== 1) { + throw new Error('Could not properly write and read Netbox') + } + + await this.#request('/virtualization/cluster-types/', 'DELETE', [{ id: clusterTypes[0].id }]) + } + + async #request(path, method = 'GET', data) { const dataDebug = Array.isArray(data) && data.length > 2 ? [...data.slice(0, 2), `and ${data.length - 2} others`] : data log.debug(`${method} ${path}`, dataDebug) @@ -166,35 +190,38 @@ class Netbox { } async #checkCustomFields() { - const customFields = await this.#makeRequest('/extras/custom-fields/', 'GET') + const customFields = await this.#request('/extras/custom-fields/') const uuidCustomField = customFields.find(field => field.name === 'uuid') if (uuidCustomField === undefined) { throw new Error('UUID custom field was not found. Please create it manually from your Netbox interface.') } const { content_types: types } = uuidCustomField - if (!types.includes('virtualization.cluster') || !types.includes('virtualization.virtualmachine')) { - throw new Error( - 'UUID custom field must be assigned to types virtualization.cluster and virtualization.virtualmachine' - ) + if (TYPES_WITH_UUID.some(type => !types.includes(type))) { + throw new Error('UUID custom field must be assigned to types ' + TYPES_WITH_UUID.join(', ')) } } + // --------------------------------------------------------------------------- + async #synchronize(pools = this.#pools) { await this.#checkCustomFields() - const xo = this.#xo - log.debug('synchronizing') - // Cluster type - const clusterTypes = await this.#makeRequest( - `/virtualization/cluster-types/?name=${encodeURIComponent(CLUSTER_TYPE)}`, - 'GET' - ) + log.info(`Synchronizing ${pools.length} pools with Netbox`, { pools }) + + // Cluster type ------------------------------------------------------------ + + // Create a single cluster type called "XCP-ng Pool" to identify clusters + // that have been created from XO + + // Check if a cluster type called XCP-ng already exists otherwise create it + const clusterTypes = await this.#request(`/virtualization/cluster-types/?name=${encodeURIComponent(CLUSTER_TYPE)}`) if (clusterTypes.length > 1) { throw new Error('Found more than 1 "XCP-ng Pool" cluster type') } let clusterType if (clusterTypes.length === 0) { - clusterType = await this.#makeRequest('/virtualization/cluster-types/', 'POST', { + log.info('Creating cluster type') + clusterType = await this.#request('/virtualization/cluster-types/', 'POST', { name: CLUSTER_TYPE, slug: CLUSTER_TYPE.toLowerCase().replace(/[^a-z0-9]+/g, '-'), description: 'Created by Xen Orchestra', @@ -203,23 +230,45 @@ class Netbox { clusterType = clusterTypes[0] } - // Clusters - const clusters = keyBy( - await this.#makeRequest(`/virtualization/clusters/?type_id=${clusterType.id}`, 'GET'), + // Clusters ---------------------------------------------------------------- + + // Update and create clusters. Deleting a cluster is manual action. + + log.info('Synchronizing clusters') + + const createCluster = (pool, clusterType) => ({ + custom_fields: { uuid: pool.uuid }, + name: pool.name_label.slice(0, NAME_MAX_LENGTH), + type: clusterType.id, + }) + + // { Pool UUID → cluster } + const allClusters = keyBy( + await this.#request(`/virtualization/clusters/?type_id=${clusterType.id}`), 'custom_fields.uuid' ) + const clusters = pick(allClusters, pools) + + if (!isEmpty(allClusters[undefined])) { + // FIXME: Should we delete clusters from this cluster type that don't have + // a UUID? + log.warn('Found some clusters with missing UUID custom field', allClusters[undefined]) + } const clustersToCreate = [] const clustersToUpdate = [] for (const poolId of pools) { - const pool = xo.getObject(poolId) + const pool = this.getObject(poolId) + if (pool === undefined) { + // If we can't find the pool, don't synchronize anything within that pool + log.warn('Synchronizing pools: cannot find pool', { pool: poolId }) + delete allClusters[poolId] + delete clusters[poolId] + continue + } const cluster = clusters[pool.uuid] - const updatedCluster = { - name: pool.name_label.slice(0, NAME_MAX_LENGTH), - type: clusterType.id, - custom_fields: { uuid: pool.uuid }, - } + const updatedCluster = createCluster(pool, clusterType) if (cluster === undefined) { clustersToCreate.push(updatedCluster) @@ -227,335 +276,310 @@ class Netbox { // `type` needs to be flattened so we can compare the 2 objects const patch = diff(updatedCluster, { ...cluster, type: cluster.type.id }) if (patch !== undefined) { - clustersToUpdate.push({ ...patch, id: cluster.id }) + clustersToUpdate.push(patch) } } } - // FIXME: Should we deduplicate cluster names even though it also fails when - // a cluster within another cluster type has the same name? - // FIXME: Should we delete clusters from this cluster type that don't have a - // UUID? - Object.assign( - clusters, - keyBy( - flatten( - await Promise.all( - clustersToCreate.length === 0 - ? [] - : await this.#makeRequest('/virtualization/clusters/', 'POST', clustersToCreate), - clustersToUpdate.length === 0 - ? [] - : await this.#makeRequest('/virtualization/clusters/', 'PATCH', clustersToUpdate) - ) + // FIXME: Should we deduplicate cluster names even though it also fails + // when a cluster within another cluster type has the same name? + const newClusters = [] + if (clustersToUpdate.length > 0) { + log.info(`Updating ${clustersToUpdate.length} clusters`) + newClusters.push(...(await this.#request('/virtualization/clusters/', 'PATCH', clustersToUpdate))) + } + if (clustersToCreate.length > 0) { + log.info(`Creating ${clustersToCreate.length} clusters`) + newClusters.push(...(await this.#request('/virtualization/clusters/', 'POST', clustersToCreate))) + } + Object.assign(clusters, keyBy(newClusters, 'custom_fields.uuid')) + Object.assign(allClusters, clusters) + // Only keep pools that were found in XO and up to date in Netbox + pools = Object.keys(clusters) + + const clusterFilter = Object.values(clusters) + .map(cluster => `cluster_id=${cluster.id}`) + .join('&') + + // VMs --------------------------------------------------------------------- + + log.info('Synchronizing VMs') + + const createNetboxVm = (vm, cluster) => { + const netboxVm = { + custom_fields: { uuid: vm.uuid }, + name: vm.name_label.slice(0, NAME_MAX_LENGTH).trim(), + comments: vm.name_description.slice(0, DESCRIPTION_MAX_LENGTH).trim(), + vcpus: vm.CPUs.number, + disk: Math.floor( + vm.$VBDs + .map(vbdId => this.getObject(vbdId)) + .filter(vbd => !vbd.is_cd_drive) + .map(vbd => this.getObject(vbd.VDI)) + .reduce((total, vdi) => total + vdi.size, 0) / G ), - 'custom_fields.uuid' - ) - ) + memory: Math.floor(vm.memory.dynamic[1] / M), + cluster: cluster.id, + status: vm.power_state === 'Running' ? 'active' : 'offline', + } - // VMs - const vms = xo.getObjects({ filter: object => object.type === 'VM' && pools.includes(object.$pool) }) - let oldNetboxVms = flatten( - // FIXME: It should be doable with one request: - // `cluster_id=1&cluster_id=2` but it doesn't work - // https://netbox.readthedocs.io/en/stable/rest-api/filtering/#filtering-objects - await Promise.all( - pools.map(poolId => - this.#makeRequest(`/virtualization/virtual-machines/?cluster_id=${clusters[poolId].id}`, 'GET') - ) - ) - ) + // https://netbox.readthedocs.io/en/stable/release-notes/version-2.7/#api-choice-fields-now-use-string-values-3569 + if ( + this.#netboxApiVersion !== undefined && + !semver.satisfies(semver.coerce(this.#netboxApiVersion).version, '>=2.7.0') + ) { + netboxVm.status = vm.power_state === 'Running' ? 1 : 0 + } - const vmsWithNoUuid = oldNetboxVms.filter(vm => vm.custom_fields.uuid === null) - oldNetboxVms = omit(keyBy(oldNetboxVms, 'custom_fields.uuid'), null) - - // Delete VMs that don't have a UUID custom field. This can happen if they - // were created manually or if the custom field config was changed after - // their creation - if (vmsWithNoUuid !== undefined) { - log.warn(`Found ${vmsWithNoUuid.length} VMs with no UUID. Deleting them.`) - await this.#makeRequest( - '/virtualization/virtual-machines/', - 'DELETE', - vmsWithNoUuid.map(vm => ({ id: vm.id })) - ) + return netboxVm } - // Build collections for later - const netboxVms = {} // VM UUID → Netbox VM - const vifsByVm = {} // VM UUID → VIF UUID[] - const ipsByDeviceByVm = {} // VM UUID → (VIF device → IP) - const primaryIpsByVm = {} // VM UUID → { ipv4, ipv6 } + // Some props need to be flattened to satisfy the POST request schema + const flattenNested = vm => ({ ...vm, cluster: vm.cluster?.id, status: vm.status?.value }) + // Get all the VMs in the cluster type "XCP-ng Pool" even from clusters + // we're not synchronizing right now, so we can "migrate" them back if + // necessary + const allNetboxVmsList = (await this.#request(`/virtualization/virtual-machines/`)).filter( + netboxVm => Object.values(allClusters).find(cluster => cluster.id === netboxVm.cluster.id) !== undefined + ) + // Then get only the ones from the pools we're synchronizing + const netboxVmsList = allNetboxVmsList.filter( + netboxVm => Object.values(clusters).find(cluster => cluster.id === netboxVm.cluster.id) !== undefined + ) + // Then make them objects to map the Netbox VMs to their XO VMs + // { VM UUID → Netbox VM } + const allNetboxVms = keyBy(allNetboxVmsList, 'custom_fields.uuid') + const netboxVms = keyBy(netboxVmsList, 'custom_fields.uuid') + + const usedNames = [] // Used for name deduplication + // Build the 3 collections of VMs and perform all the API calls at the end + const vmsToDelete = netboxVmsList + .filter(netboxVm => netboxVm.custom_fields.uuid == null) + .map(netboxVm => ({ id: netboxVm.id })) + const vmsToUpdate = [] const vmsToCreate = [] - let vmsToUpdate = [] // will be reused for primary IPs - for (const vm of Object.values(vms)) { - vifsByVm[vm.uuid] = vm.VIFs - const vmIpsByDevice = (ipsByDeviceByVm[vm.uuid] = {}) + for (const poolId of pools) { + // Get XO VMs that are on this pool + const poolVms = this.getObjects({ filter: { type: 'VM', $pool: poolId } }) - if (primaryIpsByVm[vm.uuid] === undefined) { - primaryIpsByVm[vm.uuid] = {} - } - if (vm.addresses['0/ipv4/0'] !== undefined) { - primaryIpsByVm[vm.uuid].ipv4 = vm.addresses['0/ipv4/0'] - } - if (vm.addresses['0/ipv6/0'] !== undefined) { - primaryIpsByVm[vm.uuid].ipv6 = ipaddr.parse(vm.addresses['0/ipv6/0']).toString() - } + const cluster = clusters[poolId] - forEach(vm.addresses, (address, key) => { - const device = key.split('/')[0] - if (vmIpsByDevice[device] === undefined) { - vmIpsByDevice[device] = [] - } - vmIpsByDevice[device].push(address) - }) + // Get Netbox VMs that are supposed to be in this pool + const poolNetboxVms = pickBy(netboxVms, netboxVm => netboxVm.cluster.id === cluster.id) - const oldNetboxVm = oldNetboxVms[vm.uuid] - delete oldNetboxVms[vm.uuid] - const cluster = clusters[vm.$pool] - assert(cluster !== undefined) - - const disk = Math.floor( - vm.$VBDs - .map(vbdId => xo.getObject(vbdId)) - .filter(vbd => !vbd.is_cd_drive) - .map(vbd => xo.getObject(vbd.VDI)) - .reduce((total, vdi) => total + vdi.size, 0) / G - ) + // For each XO VM of this pool (I) + for (const vm of Object.values(poolVms)) { + // Grab the Netbox VM from the list of all VMs so that if the VM is on + // another cluster, we update the existing object instead of creating a + // new one + const netboxVm = allNetboxVms[vm.uuid] + delete poolNetboxVms[vm.uuid] - const updatedVm = { - name: vm.name_label.slice(0, NAME_MAX_LENGTH), - cluster: cluster.id, - vcpus: vm.CPUs.number, - disk, - memory: Math.floor(vm.memory.dynamic[1] / M), - custom_fields: { uuid: vm.uuid }, - } + const updatedVm = createNetboxVm(vm, cluster) - if (this.#netboxApiVersion !== undefined) { - // https://netbox.readthedocs.io/en/stable/release-notes/version-2.7/#api-choice-fields-now-use-string-values-3569 - if (semver.satisfies(semver.coerce(this.#netboxApiVersion).version, '>=2.7.0')) { - updatedVm.status = vm.power_state === 'Running' ? 'active' : 'offline' + if (netboxVm !== undefined) { + // VM found in Netbox: update VM (I.1) + const patch = diff(updatedVm, flattenNested(netboxVm)) + if (patch !== undefined) { + vmsToUpdate.push(patch) + } else { + // The VM is up to date, just store its name as being used + usedNames.push(netboxVm.name) + } } else { - updatedVm.status = vm.power_state === 'Running' ? 1 : 0 + // VM not found in Netbox: create VM (I.2) + vmsToCreate.push(updatedVm) } } - if (oldNetboxVm === undefined) { - vmsToCreate.push(updatedVm) - } else { - // Some properties need to be flattened to match the expected POST - // request objects - let patch = diff(updatedVm, { - ...oldNetboxVm, - cluster: oldNetboxVm.cluster.id, - status: oldNetboxVm.status?.value, - }) - - // Check if a name mismatch is due to a name deduplication - if (patch?.name !== undefined) { - let match - if ((match = oldNetboxVm.name.match(/.* \((\d+)\)$/)) !== null) { - if (indexName(patch.name, match[1]) === oldNetboxVm.name) { - delete patch.name - if (isEmpty(patch)) { - patch = undefined - } - } + // For each REMAINING Netbox VM of this pool (II) + for (const netboxVm of Object.values(poolNetboxVms)) { + const vmUuid = netboxVm.custom_fields?.uuid + const vm = this.getObject(vmUuid) + // We check if the VM was moved to another pool in XO + const pool = this.getObject(vm?.$pool) + const cluster = allClusters[pool?.uuid] + if (cluster !== undefined) { + // If the VM is found in XO: update it if necessary (II.1) + const updatedVm = createNetboxVm(vm, cluster) + const patch = diff(updatedVm, flattenNested(netboxVm)) + + if (patch === undefined) { + // Should never happen since at least the cluster should be different + log.warn('Found a VM that should be on another cluster', { vm: netboxVm }) + continue } - } - if (patch !== undefined) { - // $cluster is needed to deduplicate the VM names within the same - // cluster. It will be removed at that step. - vmsToUpdate.push({ ...patch, id: oldNetboxVm.id, $cluster: cluster.id }) + vmsToUpdate.push(patch) } else { - netboxVms[vm.uuid] = oldNetboxVm + // Otherwise, delete it from Netbox (II.2) + vmsToDelete.push({ id: netboxVm.id }) + delete netboxVms[vmUuid] } } } // Deduplicate VM names - vmsToCreate.forEach((vm, i) => { - const name = vm.name - let nameIndex = 1 - while ( - find(netboxVms, netboxVm => netboxVm.cluster.id === vm.cluster && netboxVm.name === vm.name) !== undefined || - find( - vmsToCreate, - (vmToCreate, j) => vmToCreate.cluster === vm.cluster && vmToCreate.name === vm.name && i !== j - ) !== undefined - ) { - if (nameIndex >= 1e3) { - throw new Error(`Cannot deduplicate name of VM ${name}`) - } - vm.name = indexName(name, nameIndex++) - } - }) - vmsToUpdate.forEach((vm, i) => { - const name = vm.name - if (name === undefined) { - delete vm.$cluster - return - } - let nameIndex = 1 - while ( - find(netboxVms, netboxVm => netboxVm.cluster.id === vm.$cluster && netboxVm.name === vm.name) !== undefined || - find(vmsToCreate, vmToCreate => vmToCreate.cluster === vm.$cluster && vmToCreate.name === vm.name) !== - undefined || - find( - vmsToUpdate, - (vmToUpdate, j) => vmToUpdate.$cluster === vm.$cluster && vmToUpdate.name === vm.name && i !== j - ) !== undefined - ) { - if (nameIndex >= 1e3) { - throw new Error(`Cannot deduplicate name of VM ${name}`) - } - vm.name = indexName(name, nameIndex++) + // Deduplicate vmsToUpdate first to avoid back and forth changes + // Deduplicate even between pools to simplify and avoid back and forth + // changes if the VM is migrated + for (const netboxVm of [...vmsToUpdate, ...vmsToCreate]) { + if (netboxVm.name === undefined) { + continue } - delete vm.$cluster - }) + netboxVm.name = deduplicateName(netboxVm.name, usedNames) + usedNames.push(netboxVm.name) + } - const vmsToDelete = Object.values(oldNetboxVms).map(vm => ({ id: vm.id })) - Object.assign( - netboxVms, - keyBy( - flatten( - ( - await Promise.all([ - vmsToDelete.length !== 0 && - (await this.#makeRequest('/virtualization/virtual-machines/', 'DELETE', vmsToDelete)), - vmsToCreate.length === 0 - ? [] - : await this.#makeRequest('/virtualization/virtual-machines/', 'POST', vmsToCreate), - vmsToUpdate.length === 0 - ? [] - : await this.#makeRequest('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate), - ]) - ).slice(1) - ), - 'custom_fields.uuid' - ) - ) + // Perform calls to Netbox. "Delete → Update → Create" one at a time to + // avoid name conflicts with outdated VMs + const newVms = [] + if (vmsToDelete.length > 0) { + log.info(`Deleting ${vmsToDelete.length} VMs`) + await this.#request('/virtualization/virtual-machines/', 'DELETE', vmsToDelete) + } + if (vmsToUpdate.length > 0) { + log.info(`Updating ${vmsToUpdate.length} VMs`) + newVms.push(...(await this.#request('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate))) + } + if (vmsToCreate.length > 0) { + log.info(`Creating ${vmsToCreate.length} VMs`) + newVms.push(...(await this.#request('/virtualization/virtual-machines/', 'POST', vmsToCreate))) + } + Object.assign(netboxVms, keyBy(newVms, 'custom_fields.uuid')) + Object.assign(allNetboxVms, netboxVms) - // Interfaces - // { vmUuid: { ifName: if } } - const oldInterfaces = mapValues( - groupBy( - flatten( - await Promise.all( - pools.map(poolId => - this.#makeRequest(`/virtualization/interfaces/?cluster_id=${clusters[poolId].id}`, 'GET') - ) - ) - ), - 'virtual_machine.id' - ), - interfaces => keyBy(interfaces, 'name') - ) + // VIFs -------------------------------------------------------------------- - const interfaces = {} // VIF UUID → interface + log.info('Synchronizing VIFs') - const interfacesToCreateByVif = {} // VIF UUID → interface - const interfacesToUpdateByVif = {} // VIF UUID → interface - for (const [vmUuid, vifs] of Object.entries(vifsByVm)) { - const netboxVmId = netboxVms[vmUuid].id - const vmInterfaces = oldInterfaces[netboxVmId] ?? {} - for (const vifId of vifs) { - const vif = xo.getObject(vifId) - const name = `eth${vif.device}` + const createIf = (vif, vm) => { + const name = `eth${vif.device}` + const netboxVm = netboxVms[vm.uuid] - const oldInterface = vmInterfaces[name] - delete vmInterfaces[name] + const netboxIf = { + custom_fields: { uuid: vif.uuid }, + name, + mac_address: vif.MAC.toUpperCase(), + } - const updatedInterface = { - name, - mac_address: vif.MAC.toUpperCase(), - virtual_machine: netboxVmId, + if (netboxVm !== undefined) { + netboxIf.virtual_machine = netboxVm.id + } + + return netboxIf + } + + const netboxIfsList = await this.#request(`/virtualization/interfaces/?${clusterFilter}`) + // { ID → Interface } + const netboxIfs = keyBy(netboxIfsList, 'custom_fields.uuid') + + const ifsToDelete = netboxIfsList + .filter(netboxIf => netboxIf.custom_fields.uuid == null) + .map(netboxIf => ({ id: netboxIf.id })) + const ifsToUpdate = [] + const ifsToCreate = [] + for (const netboxVm of Object.values(netboxVms)) { + const vm = this.getObject(netboxVm.custom_fields.uuid) + if (vm === undefined) { + log.warn('Synchronizing VIFs: cannot find VM from UUID custom field', { vm: netboxVm.custom_fields.uuid }) + continue + } + // Start by deleting old interfaces attached to this Netbox VM + Object.entries(netboxIfs).forEach(([id, netboxIf]) => { + if (netboxIf.virtual_machine.id === netboxVm.id && !vm.VIFs.includes(netboxIf.custom_fields.uuid)) { + ifsToDelete.push({ id: netboxIf.id }) + delete netboxIfs[id] } + }) - if (oldInterface === undefined) { - interfacesToCreateByVif[vif.uuid] = updatedInterface + // For each XO VIF, create or update the Netbox interface + for (const vifId of vm.VIFs) { + const vif = this.getObject(vifId) + const netboxIf = netboxIfs[vif.uuid] + const updatedIf = createIf(vif, vm) + if (netboxIf === undefined) { + ifsToCreate.push(updatedIf) } else { - const patch = diff(updatedInterface, { - ...oldInterface, - virtual_machine: oldInterface.virtual_machine.id, - }) + // `virtual_machine` needs to be flattened so we can compare the 2 objects + const patch = diff(updatedIf, { ...netboxIf, virtual_machine: netboxIf.virtual_machine.id }) if (patch !== undefined) { - interfacesToUpdateByVif[vif.uuid] = { ...patch, id: oldInterface.id } - } else { - interfaces[vif.uuid] = oldInterface + ifsToUpdate.push(patch) } } } } - const interfacesToDelete = flatten( - Object.values(oldInterfaces).map(oldInterfacesByName => - Object.values(oldInterfacesByName).map(oldInterface => ({ id: oldInterface.id })) - ) - ) - ;( - await Promise.all([ - interfacesToDelete.length !== 0 && - this.#makeRequest('/virtualization/interfaces/', 'DELETE', interfacesToDelete), - isEmpty(interfacesToCreateByVif) - ? {} - : this.#makeRequest('/virtualization/interfaces/', 'POST', Object.values(interfacesToCreateByVif)).then( - interfaces => zipObject(Object.keys(interfacesToCreateByVif), interfaces) - ), - isEmpty(interfacesToUpdateByVif) - ? {} - : this.#makeRequest('/virtualization/interfaces/', 'PATCH', Object.values(interfacesToUpdateByVif)).then( - interfaces => zipObject(Object.keys(interfacesToUpdateByVif), interfaces) - ), - ]) - ) - .slice(1) - .forEach(newInterfaces => Object.assign(interfaces, newInterfaces)) - - // IPs - const [oldNetboxIps, netboxPrefixes] = await Promise.all([ - this.#makeRequest('/ipam/ip-addresses/', 'GET').then(addresses => - groupBy( - // In Netbox, a device interface and a VM interface can have the same - // ID and an IP address can be assigned to both types of interface, so - // we need to make sure that we only get IPs that are assigned to a VM - // interface before grouping them by their `assigned_object_id` - addresses.filter(address => address.assigned_object_type === 'virtualization.vminterface'), - 'assigned_object_id' - ) + // Perform calls to Netbox + const newIfs = [] + if (ifsToDelete.length > 0) { + log.info(`Deleting ${ifsToDelete.length} interfaces`) + await this.#request('/virtualization/interfaces/', 'DELETE', ifsToDelete) + } + if (ifsToUpdate.length > 0) { + log.info(`Updating ${ifsToUpdate.length} interfaces`) + newIfs.push(...(await this.#request('/virtualization/interfaces/', 'PATCH', ifsToUpdate))) + } + if (ifsToCreate.length > 0) { + log.info(`Creating ${ifsToCreate.length} interfaces`) + newIfs.push(...(await this.#request('/virtualization/interfaces/', 'POST', ifsToCreate))) + } + Object.assign(netboxIfs, keyBy(newIfs, 'custom_fields.uuid')) + + // IPs --------------------------------------------------------------------- + + log.info('Synchronizing IP addresses') + + const createIp = (ip, prefix, netboxIf) => { + return { + address: `${ip}/${prefix.split('/')[1]}`, + assigned_object_type: 'virtualization.vminterface', + assigned_object_id: netboxIf, + } + } + + // In Netbox, a device interface and a VM interface can have the same ID and + // an IP address can be assigned to both types of interface, so we need to + // make sure that we only get IPs that are assigned to a VM interface + const netboxIps = keyBy( + (await this.#request('/ipam/ip-addresses/')).filter( + address => address.assigned_object_type === 'virtualization.vminterface' ), - this.#makeRequest('/ipam/prefixes/', 'GET'), - ]) + 'id' + ) + const netboxPrefixes = await this.#request('/ipam/prefixes/') const ipsToDelete = [] const ipsToCreate = [] - const ignoredIps = [] - const netboxIpsByVif = {} - for (const [vmUuid, vifs] of Object.entries(vifsByVm)) { - const vmIpsByDevice = ipsByDeviceByVm[vmUuid] - if (vmIpsByDevice === undefined) { + const ignoredIps = [] // IPs for which a valid prefix could not be found in Netbox + // For each VM, for each interface, for each IP: create IP in Netbox + for (const netboxVm of Object.values(netboxVms)) { + const vm = this.getObject(netboxVm.custom_fields.uuid) + if (vm === undefined) { + log.warn('Synchronizing IPs: cannot find VM from UUID custom field', { vm: netboxVm.custom_fields.uuid }) continue } - for (const vifId of vifs) { - const vif = xo.getObject(vifId) - const vifIps = vmIpsByDevice[vif.device] - if (vifIps === undefined) { - continue - } - - netboxIpsByVif[vifId] = [] - const interface_ = interfaces[vif.uuid] - const interfaceOldIps = oldNetboxIps[interface_.id] ?? [] + // Find the Netbox interface associated with the vif + const netboxVmIfs = Object.values(netboxIfs).filter(netboxIf => netboxIf.virtual_machine.id === netboxVm.id) + for (const netboxIf of netboxVmIfs) { + // Store old IPs and remove them one by one. At the end, delete the remaining ones. + const netboxIpsToCheck = pickBy(netboxIps, netboxIp => netboxIp.assigned_object_id === netboxIf.id) - for (const ip of vifIps) { + const vif = this.getObject(netboxIf.custom_fields.uuid) + if (vif === undefined) { + // Cannot create IPs if interface was not found + log.warn('Could not find VIF', { vm: vm.uuid, vif: netboxIf.custom_fields.uuid }) + continue + } + const ips = Object.values(pickBy(vm.addresses, (_, key) => key.startsWith(vif.device + '/'))) + for (const ip of ips) { const parsedIp = ipaddr.parse(ip) const ipKind = parsedIp.kind() - const ipCompactNotation = parsedIp.toString() + // Find the smallest prefix within Netbox's existing prefixes + // Users must create prefixes themselves let smallestPrefix let highestBits = 0 netboxPrefixes.forEach(({ prefix }) => { @@ -566,156 +590,104 @@ class Netbox { highestBits = bits } }) + if (smallestPrefix === undefined) { - ignoredIps.push(ip) + // A valid prefix is required to create an IP in Netbox. If none matches, ignore the IP. + ignoredIps.push({ vm: vm.uuid, ip }) continue } - const netboxIpIndex = interfaceOldIps.findIndex(netboxIp => { + const compactIp = parsedIp.toString() // use compact notation (e.g. ::1) before ===-comparison + const netboxIp = find(netboxIpsToCheck, netboxIp => { const [ip, bits] = netboxIp.address.split('/') - return ipaddr.parse(ip).toString() === ipCompactNotation && bits === highestBits + return ipaddr.parse(ip).toString() === compactIp && bits === highestBits }) - - if (netboxIpIndex >= 0) { - netboxIpsByVif[vifId].push(interfaceOldIps[netboxIpIndex]) - interfaceOldIps.splice(netboxIpIndex, 1) + if (netboxIp !== undefined) { + // IP is up to date, don't do anything with it + delete netboxIpsToCheck[netboxIp.id] } else { - ipsToCreate.push({ - address: `${ip}/${smallestPrefix.split('/')[1]}`, - assigned_object_type: 'virtualization.vminterface', - assigned_object_id: interface_.id, - vifId, // needed to populate netboxIpsByVif with newly created IPs - }) + // IP wasn't found in Netbox, create it + ipsToCreate.push(createIp(ip, smallestPrefix, netboxIf.id)) } } - ipsToDelete.push(...interfaceOldIps.map(oldIp => ({ id: oldIp.id }))) + + // Delete the remaining IPs found in Netbox for this VM + ipsToDelete.push(...Object.values(netboxIpsToCheck).map(netboxIp => ({ id: netboxIp.id }))) } } if (ignoredIps.length > 0) { - log.warn('Could not find prefix for some IPs: ignoring them.', { ips: ignoredIps }) - } - - await Promise.all([ - ipsToDelete.length !== 0 && this.#makeRequest('/ipam/ip-addresses/', 'DELETE', ipsToDelete), - ipsToCreate.length !== 0 && - this.#makeRequest( - '/ipam/ip-addresses/', - 'POST', - ipsToCreate.map(ip => omit(ip, 'vifId')) - ).then(newNetboxIps => { - newNetboxIps.forEach((newNetboxIp, i) => { - const { vifId } = ipsToCreate[i] - if (netboxIpsByVif[vifId] === undefined) { - netboxIpsByVif[vifId] = [] - } - netboxIpsByVif[vifId].push(newNetboxIp) - }) - }), - ]) - - // Primary IPs - vmsToUpdate = [] - Object.entries(netboxVms).forEach(([vmId, netboxVm]) => { - if (netboxVm.primary_ip4 !== null && netboxVm.primary_ip6 !== null) { - return - } - const newNetboxVm = { id: netboxVm.id } - const vifs = vifsByVm[vmId] - vifs.forEach(vifId => { - const netboxIps = netboxIpsByVif[vifId] - const vmMainIps = primaryIpsByVm[vmId] - - netboxIps?.forEach(netboxIp => { - const address = netboxIp.address.split('/')[0] - if ( - newNetboxVm.primary_ip4 === undefined && - address === vmMainIps.ipv4 && - netboxVm.primary_ip4?.address !== netboxIp.address - ) { - newNetboxVm.primary_ip4 = netboxIp.id - } - if ( - newNetboxVm.primary_ip6 === undefined && - address === vmMainIps.ipv6 && - netboxVm.primary_ip6?.address !== netboxIp.address - ) { - newNetboxVm.primary_ip6 = netboxIp.id - } - }) - }) - if (newNetboxVm.primary_ip4 !== undefined || newNetboxVm.primary_ip6 !== undefined) { - vmsToUpdate.push(newNetboxVm) - } - }) + // Only show the first ignored IP in order to not flood logs if there are + // many and it should be enough to fix the issues one by one + log.warn(`Could not find matching prefix in Netbox for ${ignoredIps.length} IP addresses`, ignoredIps[0]) + } - if (vmsToUpdate.length > 0) { - await this.#makeRequest('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate) + // Perform calls to Netbox + if (ipsToDelete.length > 0) { + log.info(`Deleting ${ipsToDelete.length} IPs`) + await this.#request('/ipam/ip-addresses/', 'DELETE', ipsToDelete) + } + if (ipsToCreate.length > 0) { + log.info(`Creating ${ipsToCreate.length} IPs`) + Object.assign(netboxIps, keyBy(await this.#request('/ipam/ip-addresses/', 'POST', ipsToCreate), 'id')) } - log.debug('synchronized') - } + // Primary IPs ------------------------------------------------------------- - async test() { - const randomSuffix = Math.random().toString(36).slice(2, 11) - const name = '[TMP] Xen Orchestra Netbox plugin test - ' + randomSuffix - await this.#makeRequest('/virtualization/cluster-types/', 'POST', { - name, - slug: 'xo-test-' + randomSuffix, - description: - "This type has been created by Xen Orchestra's Netbox plugin test. If it hasn't been properly deleted, you may delete it manually.", - }) - const clusterTypes = await this.#makeRequest( - `/virtualization/cluster-types/?name=${encodeURIComponent(name)}`, - 'GET' - ) + // Use the first IPs found in vm.addresses as the VMs' primary IPs in + // Netbox, both for IPv4 and IPv6 - await this.#checkCustomFields() + log.info("Setting VMs' primary IPs") - if (clusterTypes.length !== 1) { - throw new Error('Could not properly write and read Netbox') + const vmsToUpdate2 = [] + for (const netboxVm of Object.values(netboxVms)) { + const vm = this.getObject(netboxVm.custom_fields.uuid) + if (vm === undefined) { + log.warn('Updating primary IPs: cannot find VM from UUID custom field', { vm: netboxVm.custom_fields.uuid }) + continue + } + const patch = { id: netboxVm.id } + + const netboxVmIps = Object.values(netboxIps).filter( + netboxIp => netboxIp.assigned_object?.virtual_machine.id === netboxVm.id + ) + + const ipv4 = vm.addresses['0/ipv4/0'] + if (ipv4 === undefined && netboxVm.primary_ip4 !== null) { + patch.primary_ip4 = null + } else if (ipv4 !== undefined) { + const netboxIp = netboxVmIps.find(netboxIp => netboxIp.address.split('/')[0] === ipv4) + if (netboxIp === undefined && netboxVm.primary_ip4 !== null) { + patch.primary_ip4 = null + } else if (netboxIp !== undefined && netboxIp.id !== netboxVm.primary_ip4?.id) { + patch.primary_ip4 = netboxIp.id + } + } + + const _ipv6 = vm.addresses['0/ipv6/0'] + // For IPv6, compare with the compact notation + const ipv6 = _ipv6 && ipaddr.parse(_ipv6).toString() + if (ipv6 === undefined && netboxVm.primary_ip6 !== null) { + patch.primary_ip6 = null + } else if (ipv6 !== undefined) { + const netboxIp = netboxVmIps.find(netboxIp => netboxIp.address.split('/')[0] === ipv6) + if (netboxIp === undefined && netboxVm.primary_ip6 !== null) { + patch.primary_ip6 = null + } else if (netboxIp !== undefined && netboxIp.id !== netboxVm.primary_ip6?.id) { + patch.primary_ip6 = netboxIp.id + } + } + + if (patch.primary_ip4 !== undefined || patch.primary_ip6 !== undefined) { + vmsToUpdate2.push(patch) + } } - await this.#makeRequest('/virtualization/cluster-types/', 'DELETE', [{ id: clusterTypes[0].id }]) + if (vmsToUpdate2.length > 0) { + log.info(`Updating primary IPs of ${vmsToUpdate2.length} VMs`) + Object.assign(netboxVms, keyBy(await this.#request('/virtualization/virtual-machines/', 'PATCH', vmsToUpdate2))) + } + + log.info(`Done synchronizing ${pools.length} pools with Netbox`, { pools }) } } - -export const configurationSchema = ({ xo: { apiMethods } }) => ({ - description: - 'Synchronize pools managed by Xen Orchestra with Netbox. Configuration steps: https://xen-orchestra.com/docs/advanced.html#netbox.', - type: 'object', - properties: { - endpoint: { - type: 'string', - title: 'Endpoint', - description: 'Netbox URI', - }, - allowUnauthorized: { - type: 'boolean', - title: 'Unauthorized certificates', - description: 'Enable this if your Netbox instance uses a self-signed SSL certificate', - }, - token: { - type: 'string', - title: 'Token', - description: 'Generate a token with write permissions from your Netbox interface', - }, - pools: { - type: 'array', - title: 'Pools', - description: 'Pools to synchronize with Netbox', - items: { - type: 'string', - $type: 'pool', - }, - }, - syncInterval: { - type: 'number', - title: 'Interval', - description: 'Synchronization interval in hours - leave empty to disable auto-sync', - }, - }, - required: ['endpoint', 'token', 'pools'], -}) - -export default opts => new Netbox(opts) diff --git a/packages/xo-server-netbox/src/name-dedup.js b/packages/xo-server-netbox/src/name-dedup.js new file mode 100644 index 00000000000..e7005331945 --- /dev/null +++ b/packages/xo-server-netbox/src/name-dedup.js @@ -0,0 +1,50 @@ +import { NAME_MAX_LENGTH } from '.' + +/** + * Generates the string "[name] ([index])" while also making sure it remains + * shorter than the max authorized length + * @param {string} name + * @param {number} index + * @returns {string} + */ +export function indexName(name, index) { + const suffix = ` (${index})` + + return name.slice(0, NAME_MAX_LENGTH - suffix.length) + suffix +} + +/** + * Compares name with the collection of usedNames and returns the next available + * name in the format "My Name (n)" + * @param {string} name + * @param {string[]} usedNames + * @returns {string} + */ +export function deduplicateName(name, usedNames) { + let index = 1 + let uniqName = name + while (index < 1e3 && usedNames.includes(uniqName)) { + uniqName = indexName(name, index++, NAME_MAX_LENGTH) + } + if (index === 1e3) { + throw new Error(`Cannot deduplicate name ${name}`) + } + + return uniqName +} + +/** + * Checks if 2 names are identical or if their difference is only due to name + * deduplication + * @param {string} original + * @param {string} copy + * @returns {boolean} + */ +export function compareNames(original, copy) { + if (original === copy) { + return true + } + + const match = copy.match(/.* \((\d+)\)$/) + return match !== null && indexName(original, match[1], NAME_MAX_LENGTH) === copy +} diff --git a/yarn.lock b/yarn.lock index 00534120f27..37800459c2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -885,7 +885,7 @@ dependencies: "@babel/types" "^7.22.5" -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz#dd7ee3735e8a313b9f7b05a773d892e88e6d7295" integrity sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg== @@ -1023,13 +1023,13 @@ "@babel/helper-split-export-declaration" "^7.22.6" "@babel/plugin-syntax-decorators" "^7.22.5" -"@babel/plugin-proposal-export-default-from@^7.0.0", "@babel/plugin-proposal-export-default-from@^7.12.13": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.22.5.tgz#825924eda1fad382c3de4db6fe1711b6fa03362f" - integrity sha512-UCe1X/hplyv6A5g2WnQ90tnHRvYL29dabCWww92lO7VdfMVTVReBTRrhiMrKQejHD9oVkdnRdwYuzUZkBVQisg== +"@babel/plugin-proposal-export-default-from@^7.0.0", "@babel/plugin-proposal-export-default-from@^7.12.13", "@babel/plugin-proposal-export-default-from@^7.18.10": + version "7.18.10" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.18.10.tgz#091f4794dbce4027c03cf4ebc64d3fb96b75c206" + integrity sha512-5H2N3R2aQFxkV4PIBUR/i7PUSwgTZjouJKzI8eKswfIjT0PhvzkPn0t0wIS5zn6maQuvtT0t1oHtMUz61LOuow== dependencies: - "@babel/helper-plugin-utils" "^7.22.5" - "@babel/plugin-syntax-export-default-from" "^7.22.5" + "@babel/helper-plugin-utils" "^7.18.9" + "@babel/plugin-syntax-export-default-from" "^7.18.6" "@babel/plugin-proposal-function-bind@^7.0.0", "@babel/plugin-proposal-function-bind@^7.12.13": version "7.22.5" @@ -1121,7 +1121,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-export-default-from@^7.22.5": +"@babel/plugin-syntax-export-default-from@^7.18.6": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.22.5.tgz#ac3a24b362a04415a017ab96b9b4483d0e2a6e44" integrity sha512-ODAqWWXB/yReh/jVQDag/3/tl6lgBueQkk/TcfW/59Oykm4c8a55XloX0CTk2k2VJiFWMgHby9xNX29IbCv9dQ== @@ -12759,6 +12759,11 @@ istanbul-reports@^3.0.2, istanbul-reports@^3.1.3: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +isutf8@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/isutf8/-/isutf8-4.0.0.tgz#8b0061a96cf896faff3f086d7efa4ae93be8a872" + integrity sha512-mJtsQGFfAphKdVuRitEpc0eon4v5fuaB6v9ZJIrLnIyybh02sIIwJ2RQbLMp6UICVCfquezllupZIVcqzGzCPg== + iterable-backoff@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/iterable-backoff/-/iterable-backoff-0.1.0.tgz#2e0d0292a0a268d8037eebd28a894663e995fda2"