mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
7 Commits
326c152fac
...
fbp_load_b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3686e07ba | ||
|
|
15c37f79e2 | ||
|
|
f4b6f80f7f | ||
|
|
fc32121484 | ||
|
|
abf3b5e316 | ||
|
|
a82aa74147 | ||
|
|
96065ae917 |
49
packages/xo-server-load-balancer-ng/package.json
Normal file
49
packages/xo-server-load-balancer-ng/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "xo-server-load-balancer-ng",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"description": "Load balancer for XO-Server (next generation)",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"load",
|
||||
"balancer",
|
||||
"server",
|
||||
"pool",
|
||||
"host"
|
||||
],
|
||||
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/packages/xo-server-load-balancer-ng",
|
||||
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
|
||||
"repository": {
|
||||
"directory": "packages/xo-server-load-balancer-ng",
|
||||
"type": "git",
|
||||
"url": "https://github.com/vatesfr/xen-orchestra.git"
|
||||
},
|
||||
"author": {
|
||||
"name": "Vates SAS",
|
||||
"url": "https://vates.fr"
|
||||
},
|
||||
"main": "dist/index.mjs",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vates/async-each": "^1.0.0",
|
||||
"@vates/types": "^1.21.0",
|
||||
"@xen-orchestra/log": "^0.7.1",
|
||||
"promise-toolbox": "^0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rimraf dist/",
|
||||
"dev": "yarn run clean && tsc --watch",
|
||||
"prebuild": "yarn run clean",
|
||||
"prepublishOnly": "yarn run build",
|
||||
"test": "cd dist && node --test"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
30
packages/xo-server-load-balancer-ng/src/constraint.mts
Normal file
30
packages/xo-server-load-balancer-ng/src/constraint.mts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
|
||||
/**
|
||||
* Immutable snapshot of running VM placements.
|
||||
* Maps each running VM id to the id of its current host.
|
||||
*
|
||||
* Note: this reflects the *simulated* state after higher-priority
|
||||
* constraints have already been applied — not the live XAPI state.
|
||||
*/
|
||||
export type Placement = ReadonlyMap<XoVm['id'], XoHost['id']>
|
||||
|
||||
/**
|
||||
* The output of a plan: maps VMs that must move to their destination host.
|
||||
* VMs already in the correct position are absent.
|
||||
*/
|
||||
export type MigrationPlan = Map<XoVm['id'], XoHost['id']>
|
||||
|
||||
/**
|
||||
* A single placement constraint.
|
||||
*
|
||||
* Constraints are applied in descending priority order by the solver.
|
||||
* Each `apply()` receives the placement already updated by all higher-priority
|
||||
* constraints, so each constraint sees the committed result of earlier ones.
|
||||
*
|
||||
* `apply()` must be a pure computation: no I/O, no side effects.
|
||||
*/
|
||||
export interface Constraint {
|
||||
readonly priority: number
|
||||
apply(placement: Placement, hosts: ReadonlyArray<XoHost>): MigrationPlan
|
||||
}
|
||||
158
packages/xo-server-load-balancer-ng/src/constraints/affinity.mts
Normal file
158
packages/xo-server-load-balancer-ng/src/constraints/affinity.mts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
|
||||
import type { Constraint, MigrationPlan, Placement } from '../constraint.mjs'
|
||||
|
||||
// ─── Anti-affinity conflict data ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Data passed to AffinityConstraint so it can detect anti-affinity conflicts
|
||||
* at apply() time without performing any I/O.
|
||||
*
|
||||
* A conflict exists when moving a VM to the affinity target host would place it
|
||||
* on the same host as another VM in one of its anti-affinity groups.
|
||||
* In that case the VM is skipped: anti-affinity has priority.
|
||||
* Only the conflicting VM is excluded — the rest of the coalition is unaffected.
|
||||
*/
|
||||
export type AntiAffinityData = {
|
||||
/** Maps each coalition VM id → the anti-affinity group names it belongs to. */
|
||||
readonly vmGroups: ReadonlyMap<XoVm['id'], ReadonlySet<string>>
|
||||
/** Maps each anti-affinity group name → all VM ids in that group. */
|
||||
readonly groupVms: ReadonlyMap<string, ReadonlySet<XoVm['id']>>
|
||||
}
|
||||
|
||||
const EMPTY_ANTI_AFFINITY_DATA: AntiAffinityData = {
|
||||
vmGroups: new Map(),
|
||||
groupVms: new Map(),
|
||||
}
|
||||
|
||||
// ─── AffinityConstraint ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Affinity constraint: all VMs in a coalition must be colocated on the same host.
|
||||
*
|
||||
* A coalition is the transitive closure of affinity groups linked by shared VMs:
|
||||
* if VM A has tags [web, db] and VM B has tags [db, cache], then web/db/cache
|
||||
* form one coalition and all three groups' VMs must end up on the same host.
|
||||
* Coalition merging happens upstream in extractConstraints(), not here.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. Count coalition VMs per host from the current placement.
|
||||
* 2. Target = the host with the most coalition VMs already on it.
|
||||
* Ties are broken by Map iteration order (stable, deterministic).
|
||||
* 3. For each coalition VM not already on the target host:
|
||||
* a. If moving it to the target would violate any of its anti-affinity
|
||||
* constraints → skip the VM (anti-affinity priority rule).
|
||||
* b. Otherwise → add it to the migration plan.
|
||||
*
|
||||
* Documented assumptions:
|
||||
* - `vmIds` contains only VMs for which `VM.assert_can_migrate` succeeded.
|
||||
* - VMs absent from `placement` (e.g. stopped concurrently) are silently skipped.
|
||||
* - Only hosts present in the `hosts` array are considered.
|
||||
*/
|
||||
export class AffinityConstraint implements Constraint {
|
||||
// Lower priority than anti-affinity (5 < 10) — applied after it by the solver.
|
||||
readonly priority = 5
|
||||
|
||||
readonly #antiAffinityData: AntiAffinityData
|
||||
|
||||
constructor(
|
||||
/** Names of all groups merged into this coalition (for logging/debugging). */
|
||||
readonly groups: ReadonlySet<string>,
|
||||
/**
|
||||
* VM ids of all VMs in this coalition.
|
||||
* Pre-filtered: only VMs for which `VM.assert_can_migrate` succeeded.
|
||||
*/
|
||||
readonly vmIds: ReadonlyArray<XoVm['id']>,
|
||||
/**
|
||||
* Anti-affinity data for per-VM conflict detection.
|
||||
* Omit when no VM in this coalition has anti-affinity tags.
|
||||
*/
|
||||
antiAffinityData: AntiAffinityData = EMPTY_ANTI_AFFINITY_DATA
|
||||
) {
|
||||
this.#antiAffinityData = antiAffinityData
|
||||
}
|
||||
|
||||
apply(placement: Placement, hosts: ReadonlyArray<XoHost>): MigrationPlan {
|
||||
const moves: MigrationPlan = new Map()
|
||||
|
||||
if (hosts.length <= 1 || this.vmIds.length === 0) {
|
||||
return moves
|
||||
}
|
||||
|
||||
// Count coalition VMs per available host.
|
||||
const hostCount = new Map<XoHost['id'], number>(hosts.map(h => [h.id, 0]))
|
||||
for (const vmId of this.vmIds) {
|
||||
const hostId = placement.get(vmId)
|
||||
if (hostId === undefined || !hostCount.has(hostId)) continue
|
||||
hostCount.set(hostId, (hostCount.get(hostId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
// Target = host already holding the most coalition VMs.
|
||||
const targetId = findHostWithMostVms(hostCount)
|
||||
if (targetId === undefined) return moves // no coalition VMs in placement
|
||||
|
||||
// Plan moves for VMs not yet on the target host.
|
||||
for (const vmId of this.vmIds) {
|
||||
const currentHostId = placement.get(vmId)
|
||||
if (currentHostId === undefined || !hostCount.has(currentHostId)) continue
|
||||
if (currentHostId === targetId) continue
|
||||
|
||||
// Anti-affinity priority: skip this VM if colocating it with the coalition
|
||||
// would place it on a host that already has another VM from the same
|
||||
// anti-affinity group.
|
||||
if (this.#wouldViolateAntiAffinity(vmId, targetId, placement)) {
|
||||
continue
|
||||
}
|
||||
|
||||
moves.set(vmId, targetId)
|
||||
}
|
||||
|
||||
return moves
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if placing `vmId` on `targetHostId` would violate any of
|
||||
* its anti-affinity constraints given the current `placement`.
|
||||
*
|
||||
* Conflict condition: another VM in the same anti-affinity group is already
|
||||
* on `targetHostId` in the current (post-anti-affinity) placement.
|
||||
*/
|
||||
#wouldViolateAntiAffinity(vmId: XoVm['id'], targetHostId: XoHost['id'], placement: Placement): boolean {
|
||||
const aaGroups = this.#antiAffinityData.vmGroups.get(vmId)
|
||||
if (aaGroups === undefined || aaGroups.size === 0) return false
|
||||
|
||||
for (const group of aaGroups) {
|
||||
const groupVms = this.#antiAffinityData.groupVms.get(group)
|
||||
if (groupVms === undefined) continue
|
||||
|
||||
for (const otherVmId of groupVms) {
|
||||
if (otherVmId !== vmId && placement.get(otherVmId) === targetHostId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private helper ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the host id with the highest VM count.
|
||||
* Returns undefined only when every host has count 0 (no VMs in placement).
|
||||
* Ties are broken by Map insertion order (hosts array order).
|
||||
*/
|
||||
function findHostWithMostVms(hostCount: Map<XoHost['id'], number>): XoHost['id'] | undefined {
|
||||
let bestId: XoHost['id'] | undefined
|
||||
let bestCount = 0
|
||||
|
||||
for (const [hostId, count] of hostCount) {
|
||||
if (count > bestCount) {
|
||||
bestCount = count
|
||||
bestId = hostId
|
||||
}
|
||||
}
|
||||
|
||||
return bestId
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import { strict as assert } from 'node:assert'
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
|
||||
import { AffinityConstraint, type AntiAffinityData } from './affinity.mjs'
|
||||
import type { Placement } from '../constraint.mjs'
|
||||
|
||||
// ─── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const h = (id: string) => id as XoHost['id']
|
||||
const v = (id: string) => id as XoVm['id']
|
||||
|
||||
const host = (id: string) => ({ id: h(id) }) as XoHost
|
||||
|
||||
function mkPlacement(pairs: [string, string][]): Placement {
|
||||
return new Map(pairs.map(([vmId, hostId]) => [v(vmId), h(hostId)]))
|
||||
}
|
||||
|
||||
// Build AntiAffinityData for a single group.
|
||||
function mkAntiAffinityData(group: string, vmIdsInGroup: string[], vmIdsWithTag: string[]): AntiAffinityData {
|
||||
return {
|
||||
vmGroups: new Map(vmIdsWithTag.map(id => [v(id), new Set([group])])),
|
||||
groupVms: new Map([[group, new Set(vmIdsInGroup.map(v))]]),
|
||||
}
|
||||
}
|
||||
|
||||
const groups = (name: string) => new Set([name])
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AffinityConstraint', () => {
|
||||
describe('no-op cases', () => {
|
||||
it('returns no moves for an empty VM list', () => {
|
||||
const c = new AffinityConstraint(groups('web'), [])
|
||||
assert.equal(c.apply(mkPlacement([]), [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
|
||||
it('returns no moves when there is only one host', () => {
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
])
|
||||
assert.equal(c.apply(p, [host('h1')]).size, 0)
|
||||
})
|
||||
|
||||
it('returns no moves when all VMs are already colocated', () => {
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2'), v('vm-3')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
])
|
||||
assert.equal(c.apply(p, [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
|
||||
it('returns no moves when no coalition VMs are in the placement', () => {
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2')])
|
||||
// placement has unrelated VMs only
|
||||
const p = mkPlacement([['vm-other', 'h1']])
|
||||
assert.equal(c.apply(p, [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('colocating', () => {
|
||||
it('moves the minority VM to the host that already has the most coalition VMs', () => {
|
||||
// h1=[vm-1, vm-2], h2=[vm-3] → target=h1, move vm-3→h1
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2'), v('vm-3')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h2'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-3')), h('h1'))
|
||||
})
|
||||
|
||||
it('moves all spread VMs to the host with the majority', () => {
|
||||
// h1=[vm-1, vm-2, vm-3], h2=[vm-4], h3=[vm-5] → target=h1
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2'), v('vm-3'), v('vm-4'), v('vm-5')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
['vm-4', 'h2'],
|
||||
['vm-5', 'h3'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2'), host('h3')])
|
||||
|
||||
assert.equal(result.size, 2)
|
||||
assert.equal(result.get(v('vm-4')), h('h1'))
|
||||
assert.equal(result.get(v('vm-5')), h('h1'))
|
||||
})
|
||||
|
||||
it('breaks ties in target selection by Map insertion order (first host wins)', () => {
|
||||
// h1=[vm-1], h2=[vm-2] — equal counts, h1 is first in hosts array → target=h1
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h2'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-2')), h('h1'))
|
||||
})
|
||||
|
||||
it('silently skips VMs absent from placement', () => {
|
||||
// vm-ghost is in vmIds but not in placement — only vm-1 and vm-2 count
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2'), v('vm-ghost')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h2'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-2')), h('h1'))
|
||||
})
|
||||
|
||||
it('silently skips VMs on hosts not in the hosts array', () => {
|
||||
// vm-3 is on 'excluded' which is not offered as a destination
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2'), v('vm-3')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'excluded'],
|
||||
])
|
||||
// vm-3 is excluded from hostCount → doesn't influence target → can't be moved
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 0) // vm-3 can't migrate (excluded host), others already colocated
|
||||
})
|
||||
})
|
||||
|
||||
describe('anti-affinity conflict detection', () => {
|
||||
it('skips a VM whose move to the target would violate its anti-affinity group', () => {
|
||||
// Coalition: vm-1 (h1), vm-2 (h2)
|
||||
// vm-2 is in anti-affinity group "db", and vm-3 (also "db") is already on h1
|
||||
// Moving vm-2 → h1 would put two "db" VMs on h1 → conflict → skip vm-2
|
||||
const aaData = mkAntiAffinityData('db', ['vm-2', 'vm-3'], ['vm-2'])
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2')], aaData)
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h2'],
|
||||
['vm-3', 'h1'], // another "db" VM already on h1
|
||||
])
|
||||
|
||||
assert.equal(c.apply(p, [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
|
||||
it('does NOT skip a VM when the target host has no other VMs from its anti-affinity group', () => {
|
||||
// Same setup but vm-3 is on h2, not h1 → no conflict → vm-2 can move
|
||||
const aaData = mkAntiAffinityData('db', ['vm-2', 'vm-3'], ['vm-2'])
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2')], aaData)
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h2'],
|
||||
['vm-3', 'h2'], // vm-3 is on h2, not on target h1
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-2')), h('h1'))
|
||||
})
|
||||
|
||||
it('moves coalition members that have no anti-affinity conflict even when one is skipped', () => {
|
||||
// h1=[vm-1, vm-2, vm-3] (majority), h2=[vm-4, vm-5] → target=h1 (count=3)
|
||||
// vm-4 has anti-affinity=db; vm-extra (also db) is on h1 → moving vm-4→h1 conflicts → skip
|
||||
// vm-5 has no anti-affinity → moves freely to h1
|
||||
const aaData = mkAntiAffinityData('db', ['vm-4', 'vm-extra'], ['vm-4'])
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2'), v('vm-3'), v('vm-4'), v('vm-5')], aaData)
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
['vm-4', 'h2'],
|
||||
['vm-5', 'h2'],
|
||||
['vm-extra', 'h1'], // non-coalition VM in "db" group, already on h1 — blocks vm-4
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-5')), h('h1'))
|
||||
assert.equal(result.has(v('vm-4')), false)
|
||||
})
|
||||
|
||||
it('checks conflict against the placement passed in (reflects earlier anti-affinity moves)', () => {
|
||||
// The solver passes the post-anti-affinity placement to affinity.
|
||||
// Simulate: anti-affinity already moved vm-4 to h1 (it appears on h1 in placement).
|
||||
// vm-2 (affinity=web, anti-affinity=db) cannot go to h1 because vm-4 (db) is there.
|
||||
const aaData = mkAntiAffinityData('db', ['vm-2', 'vm-4'], ['vm-2'])
|
||||
const c = new AffinityConstraint(groups('web'), [v('vm-1'), v('vm-2')], aaData)
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h2'],
|
||||
['vm-4', 'h1'], // placed here by anti-affinity constraint before affinity ran
|
||||
])
|
||||
|
||||
assert.equal(c.apply(p, [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
|
||||
import type { Constraint, MigrationPlan, Placement } from '../constraint.mjs'
|
||||
|
||||
/**
|
||||
* Anti-affinity constraint: VMs in the same group must be spread across
|
||||
* distinct hosts, minimising the difference in per-host group-VM count.
|
||||
*
|
||||
* Algorithm (greedy):
|
||||
* 1. Count group VMs per host from the current placement.
|
||||
* 2. While max_count − min_count > 1:
|
||||
* a. Source = host with the most group VMs.
|
||||
* b. Destination = host with the fewest group VMs where
|
||||
* dest_count + 1 < source_count.
|
||||
* (This condition ensures the move makes progress without overshoot.)
|
||||
* c. VM = lexicographically smallest VM id on the source host.
|
||||
* Within a single group all candidates yield the same count delta,
|
||||
* so lex order gives deterministic, stable output.
|
||||
* d. Record the move and update the simulated counts.
|
||||
*
|
||||
* Documented assumptions:
|
||||
* - `vmIds` contains only VMs for which `VM.assert_can_migrate` succeeded on
|
||||
* at least one available host (filtered upstream before constraint construction).
|
||||
* - VMs absent from `placement` (e.g. stopped concurrently) are silently skipped.
|
||||
* - Only hosts present in the `hosts` array are considered as destinations.
|
||||
*/
|
||||
export class AntiAffinityConstraint implements Constraint {
|
||||
// Higher priority than affinity (10 > 5) — applied first by the solver.
|
||||
readonly priority = 10
|
||||
|
||||
constructor(
|
||||
/** The group name from `xo:load:balancer:anti-affinity=<group>`. */
|
||||
readonly group: string,
|
||||
/**
|
||||
* VM ids belonging to this group.
|
||||
* Pre-filtered: only VMs for which `VM.assert_can_migrate` succeeded.
|
||||
*/
|
||||
readonly vmIds: ReadonlyArray<XoVm['id']>
|
||||
) {}
|
||||
|
||||
apply(placement: Placement, hosts: ReadonlyArray<XoHost>): MigrationPlan {
|
||||
const moves: MigrationPlan = new Map()
|
||||
|
||||
if (hosts.length <= 1 || this.vmIds.length === 0) {
|
||||
return moves
|
||||
}
|
||||
|
||||
// Per-host group-VM count; every available host starts at 0.
|
||||
const hostCount = new Map<XoHost['id'], number>(hosts.map(h => [h.id, 0]))
|
||||
|
||||
// Mutable vm→host index derived from `placement`.
|
||||
// Only includes VMs that are on an available host.
|
||||
const vmHost = new Map<XoVm['id'], XoHost['id']>()
|
||||
for (const vmId of this.vmIds) {
|
||||
const hostId = placement.get(vmId)
|
||||
if (hostId === undefined || !hostCount.has(hostId)) {
|
||||
continue
|
||||
}
|
||||
vmHost.set(vmId, hostId)
|
||||
hostCount.set(hostId, (hostCount.get(hostId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
// Greedy improvement loop — terminates because each iteration strictly
|
||||
// reduces the source host's count, bounding total iterations by vmIds.length.
|
||||
for (;;) {
|
||||
const source = findMaxHost(hostCount)
|
||||
if (source === undefined) break
|
||||
|
||||
const { hostId: sourceId, count: sourceCount } = source
|
||||
if (sourceCount - findMinCount(hostCount) <= 1) break
|
||||
|
||||
const destId = findBestDest(hostCount, sourceId, sourceCount)
|
||||
if (destId === undefined) break
|
||||
|
||||
const vmId = findFirstVmOnHost(vmHost, sourceId)
|
||||
if (vmId === undefined) break
|
||||
|
||||
// Record move and update simulated state.
|
||||
moves.set(vmId, destId)
|
||||
hostCount.set(sourceId, sourceCount - 1)
|
||||
hostCount.set(destId, (hostCount.get(destId) ?? 0) + 1)
|
||||
vmHost.set(vmId, destId)
|
||||
}
|
||||
|
||||
return moves
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the host with the highest group-VM count, or undefined if all are 0. */
|
||||
function findMaxHost(hostCount: Map<XoHost['id'], number>): { hostId: XoHost['id']; count: number } | undefined {
|
||||
let bestId: XoHost['id'] | undefined
|
||||
let bestCount = 0
|
||||
|
||||
for (const [hostId, count] of hostCount) {
|
||||
if (count > bestCount) {
|
||||
bestCount = count
|
||||
bestId = hostId
|
||||
}
|
||||
}
|
||||
|
||||
return bestId !== undefined ? { hostId: bestId, count: bestCount } : undefined
|
||||
}
|
||||
|
||||
/** Returns the minimum group-VM count across all hosts. */
|
||||
function findMinCount(hostCount: Map<XoHost['id'], number>): number {
|
||||
let min = Infinity
|
||||
for (const count of hostCount.values()) {
|
||||
if (count < min) min = count
|
||||
}
|
||||
return min === Infinity ? 0 : min
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the host with the fewest group VMs satisfying `count + 1 < sourceCount`,
|
||||
* or undefined if no valid destination exists.
|
||||
*/
|
||||
function findBestDest(
|
||||
hostCount: Map<XoHost['id'], number>,
|
||||
sourceId: XoHost['id'],
|
||||
sourceCount: number
|
||||
): XoHost['id'] | undefined {
|
||||
let bestId: XoHost['id'] | undefined
|
||||
let bestCount = Infinity
|
||||
|
||||
for (const [hostId, count] of hostCount) {
|
||||
if (hostId !== sourceId && count + 1 < sourceCount && count < bestCount) {
|
||||
bestCount = count
|
||||
bestId = hostId
|
||||
}
|
||||
}
|
||||
|
||||
return bestId
|
||||
}
|
||||
|
||||
/** Returns the lexicographically smallest VM id currently assigned to `hostId`. */
|
||||
function findFirstVmOnHost(vmHost: Map<XoVm['id'], XoHost['id']>, hostId: XoHost['id']): XoVm['id'] | undefined {
|
||||
let found: XoVm['id'] | undefined
|
||||
|
||||
for (const [vmId, hId] of vmHost) {
|
||||
if (hId === hostId && (found === undefined || vmId < found)) {
|
||||
found = vmId
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import { strict as assert } from 'node:assert'
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
|
||||
import { AntiAffinityConstraint } from './anti-affinity.mjs'
|
||||
import type { Placement } from '../constraint.mjs'
|
||||
|
||||
// ─── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Cast plain strings to branded types — safe for tests only.
|
||||
const h = (id: string) => id as XoHost['id']
|
||||
const v = (id: string) => id as XoVm['id']
|
||||
|
||||
const host = (id: string) => ({ id: h(id) }) as XoHost
|
||||
|
||||
function mkPlacement(pairs: [string, string][]): Placement {
|
||||
return new Map(pairs.map(([vmId, hostId]) => [v(vmId), h(hostId)]))
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AntiAffinityConstraint', () => {
|
||||
describe('no-op cases', () => {
|
||||
it('returns no moves for an empty VM list', () => {
|
||||
const c = new AntiAffinityConstraint('g', [])
|
||||
assert.equal(c.apply(mkPlacement([]), [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
|
||||
it('returns no moves when there is only one host', () => {
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
])
|
||||
assert.equal(c.apply(p, [host('h1')]).size, 0)
|
||||
})
|
||||
|
||||
it('returns no moves when already perfectly balanced (max − min = 0)', () => {
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h2'],
|
||||
])
|
||||
assert.equal(c.apply(p, [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
|
||||
it('returns no moves when balanced within tolerance (max − min = 1)', () => {
|
||||
// 3 VMs, 2 hosts: 2 on h1, 1 on h2 — acceptable spread
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2'), v('vm-3')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h2'],
|
||||
])
|
||||
assert.equal(c.apply(p, [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('spreading', () => {
|
||||
it('spreads 3 VMs evenly across 3 hosts when all start on one host', () => {
|
||||
// Initial: h1=[vm-1,vm-2,vm-3], h2=[], h3=[]
|
||||
// Expected: vm-1→h2, vm-2→h3 (vm-1 is lex-smallest, moved first to h2;
|
||||
// then vm-2 is lex-smallest on h1, moved to h3 which now has 0)
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2'), v('vm-3')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2'), host('h3')])
|
||||
|
||||
assert.equal(result.size, 2)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.get(v('vm-2')), h('h3'))
|
||||
})
|
||||
|
||||
it('spreads 4 VMs evenly across 2 hosts when all start on one host', () => {
|
||||
// Initial: h1=[vm-1..vm-4], h2=[] → 2 VMs must move to h2
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2'), v('vm-3'), v('vm-4')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
['vm-4', 'h1'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 2)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.get(v('vm-2')), h('h2'))
|
||||
})
|
||||
|
||||
it('balances a 3 vs 1 distribution on 2 hosts with a single move', () => {
|
||||
// Initial: h1=[vm-1,vm-2,vm-3], h2=[vm-4]
|
||||
// vm-1 (lex-smallest on h1) → h2
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2'), v('vm-3'), v('vm-4')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
['vm-4', 'h2'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
})
|
||||
|
||||
it('prefers the host with the fewest VMs as destination', () => {
|
||||
// h1=[vm-1,vm-2,vm-3], h2=[vm-4,vm-5], h3=[vm-6]
|
||||
// source=h1(3), dest must satisfy count+1 < 3 → count < 2 → only h3(1) qualifies
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2'), v('vm-3'), v('vm-4'), v('vm-5'), v('vm-6')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
['vm-4', 'h2'],
|
||||
['vm-5', 'h2'],
|
||||
['vm-6', 'h3'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2'), host('h3')])
|
||||
|
||||
// Only h3 qualifies (count=1, 1+1=2 < 3). vm-1 moves there.
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h3'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('VM selection — lexicographic order', () => {
|
||||
it('always picks the lexicographically smallest VM id on the source host', () => {
|
||||
// h1=[vm-z, vm-a], h2=[] — vm-a must be picked, not vm-z
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-a'), v('vm-z')])
|
||||
const p = mkPlacement([
|
||||
['vm-a', 'h1'],
|
||||
['vm-z', 'h1'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-a')), h('h2'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('silently skips VMs absent from placement (e.g. assert_can_migrate filtered them out late)', () => {
|
||||
// Only vm-1 is in placement; vm-ghost is not — only 1 VM total, nothing to spread
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-ghost')])
|
||||
const p = mkPlacement([['vm-1', 'h1']])
|
||||
assert.equal(c.apply(p, [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
|
||||
it('silently skips VMs on hosts not in the hosts array', () => {
|
||||
// vm-2 is on 'excluded' which is not in the hosts list → only vm-1 counts
|
||||
// h1=1, h2=0 → max−min=1 ≤ 1 → no moves
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'excluded'],
|
||||
])
|
||||
assert.equal(c.apply(p, [host('h1'), host('h2')]).size, 0)
|
||||
})
|
||||
|
||||
it('uses the placement passed in, not the original vm.$container', () => {
|
||||
// Simulates the solver having already moved vm-1 to h2 via a higher-priority constraint.
|
||||
// The placement reflects that move; the constraint must see the updated state.
|
||||
// vm-1 on h2, vm-2 on h2 → h1=0, h2=2 → vm-1 or vm-2 must move back to h1
|
||||
const c = new AntiAffinityConstraint('g', [v('vm-1'), v('vm-2')])
|
||||
const p = mkPlacement([
|
||||
['vm-1', 'h2'],
|
||||
['vm-2', 'h2'],
|
||||
])
|
||||
const result = c.apply(p, [host('h1'), host('h2')])
|
||||
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h1')) // vm-1 is lex-smallest
|
||||
})
|
||||
})
|
||||
})
|
||||
24
packages/xo-server-load-balancer-ng/src/global.d.ts
vendored
Normal file
24
packages/xo-server-load-balancer-ng/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module '@xen-orchestra/log' {
|
||||
export interface Logger {
|
||||
debug(message: string, data?: Record<string, unknown>): void
|
||||
info(message: string, data?: Record<string, unknown>): void
|
||||
warn(message: string, data?: Record<string, unknown>): void
|
||||
error(message: string, data?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
export function createLogger(namespace: string): Logger
|
||||
}
|
||||
|
||||
declare module '@vates/async-each' {
|
||||
export interface AsyncEachOptions {
|
||||
concurrency?: number
|
||||
signal?: AbortSignal
|
||||
stopOnError?: boolean
|
||||
}
|
||||
|
||||
export function asyncEach<T>(
|
||||
iterable: Iterable<T> | AsyncIterable<T>,
|
||||
iteratee: (item: T, index: number) => Promise<void> | void,
|
||||
options?: AsyncEachOptions
|
||||
): Promise<void>
|
||||
}
|
||||
121
packages/xo-server-load-balancer-ng/src/index.mts
Normal file
121
packages/xo-server-load-balancer-ng/src/index.mts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { XoApp, XoHost, XoPool, XoVm } from '@vates/types'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
import { asyncEach } from '@vates/async-each'
|
||||
|
||||
import type { MigrationPlan } from './constraint.mjs'
|
||||
import { computeSimplePlan } from './plans/simple.mjs'
|
||||
|
||||
const log = createLogger('xo:load-balancer-ng')
|
||||
|
||||
// ─── XO interface ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `registerRestApi` is added by xo-server's rest-api mixin at runtime but is
|
||||
* not yet declared in XoApp. Extend locally until it lands in @vates/types.
|
||||
*/
|
||||
type Xo = XoApp & {
|
||||
registerRestApi(spec: object, base: string): () => void
|
||||
}
|
||||
|
||||
// ─── Plan execution ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Executes a migration plan returned by the solver.
|
||||
*
|
||||
* Migrations run concurrently (bounded to 5 at a time).
|
||||
* Individual failures are logged and do not abort the remaining migrations.
|
||||
*/
|
||||
async function executePlan(plan: MigrationPlan, xo: Xo): Promise<void> {
|
||||
await asyncEach(
|
||||
plan,
|
||||
async ([vmId, destHostId]) => {
|
||||
// getObject throws if the object vanished between planning and execution.
|
||||
// Catch it and log rather than aborting the whole plan.
|
||||
let vm: XoVm, destHost: XoHost
|
||||
try {
|
||||
vm = xo.getObject<XoVm>(vmId as XoVm['id'])
|
||||
destHost = xo.getObject<XoHost>(destHostId as XoHost['id'])
|
||||
} catch {
|
||||
log.warn('migration skipped: VM or host no longer exists', { vmId, destHostId })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const srcXapi = xo.getXapi(vm)
|
||||
const destXapi = xo.getXapi(destHost)
|
||||
await srcXapi.migrateVm(vm.id, destXapi, destHost.id)
|
||||
log.info('VM migrated', { vmId, destHostId })
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
log.warn('migration failed', { vmId, destHostId, error: error.message })
|
||||
}
|
||||
},
|
||||
{ concurrency: 5 }
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class LoadBalancerNgPlugin {
|
||||
readonly #xo: Xo
|
||||
#unregisterRestApi: (() => void) | undefined
|
||||
|
||||
constructor({ xo }: { xo: Xo }) {
|
||||
this.#xo = xo
|
||||
}
|
||||
|
||||
load(): void {
|
||||
const xo = this.#xo
|
||||
|
||||
this.#unregisterRestApi = xo.registerRestApi(
|
||||
{
|
||||
pools: {
|
||||
':id': {
|
||||
_post: async (req: { params: { id: string }; body: unknown }) => {
|
||||
const poolId = req.params.id as XoPool['id']
|
||||
const body = req.body as Record<string, unknown>
|
||||
const dryRun = body.dryRun !== false // default true
|
||||
|
||||
const pool = xo.getObjectsByType<XoPool>('pool')?.[poolId]
|
||||
if (pool === undefined) {
|
||||
throw Object.assign(new Error(`Pool not found: ${poolId}`), { statusCode: 404 })
|
||||
}
|
||||
|
||||
const allHosts = xo.getObjectsByType<XoHost>('host') ?? {}
|
||||
const allVms = xo.getObjectsByType<XoVm>('VM') ?? {}
|
||||
|
||||
const hosts = Object.values(allHosts).filter(h => h.$pool === poolId && h.power_state === 'Running')
|
||||
const hostIds = new Set(hosts.map(h => h.id))
|
||||
const vms = Object.values(allVms).filter(
|
||||
vm => vm.power_state === 'Running' && hostIds.has(vm.$container as XoHost['id'])
|
||||
)
|
||||
|
||||
log.info('computing simple plan', {
|
||||
poolId,
|
||||
hostCount: hosts.length,
|
||||
vmCount: vms.length,
|
||||
dryRun,
|
||||
})
|
||||
|
||||
const plan = computeSimplePlan(hosts, vms)
|
||||
|
||||
if (!dryRun && plan.size > 0) {
|
||||
await executePlan(plan, xo)
|
||||
}
|
||||
|
||||
return Object.fromEntries(plan)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/plugins/load-balancer'
|
||||
)
|
||||
}
|
||||
|
||||
unload(): void {
|
||||
this.#unregisterRestApi?.()
|
||||
this.#unregisterRestApi = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export default ({ xo }: { xo: Xo }) => new LoadBalancerNgPlugin({ xo })
|
||||
227
packages/xo-server-load-balancer-ng/src/plans/simple.mts
Normal file
227
packages/xo-server-load-balancer-ng/src/plans/simple.mts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
import type { Constraint, MigrationPlan, Placement } from '../constraint.mjs'
|
||||
import { AffinityConstraint, type AntiAffinityData } from '../constraints/affinity.mjs'
|
||||
import { AntiAffinityConstraint } from '../constraints/anti-affinity.mjs'
|
||||
import { solve } from '../solver.mjs'
|
||||
import { getVmConfig } from '../tag-parser.mjs'
|
||||
|
||||
const log = createLogger('xo:load-balancer-ng')
|
||||
|
||||
// ─── Coalition building ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes the transitive closure of affinity groups linked by shared VMs.
|
||||
*
|
||||
* If VM A has tags [web, db] and VM B has tags [db, cache], then web/db/cache
|
||||
* form one coalition and all their VMs must end up on the same host.
|
||||
*
|
||||
* Uses union-find with path compression.
|
||||
*/
|
||||
function buildAffinityCoalitions(
|
||||
affinityGroupVms: ReadonlyMap<string, ReadonlySet<XoVm['id']>>
|
||||
): Map<ReadonlySet<string>, ReadonlySet<XoVm['id']>> {
|
||||
// Union-find over group names.
|
||||
const parent = new Map<string, string>([...affinityGroupVms.keys()].map(g => [g, g]))
|
||||
|
||||
function find(g: string): string {
|
||||
let root = g
|
||||
while (parent.get(root) !== root) {
|
||||
root = parent.get(root) ?? root
|
||||
}
|
||||
// Path compression.
|
||||
let cur = g
|
||||
while (cur !== root) {
|
||||
const next = parent.get(cur) ?? root
|
||||
parent.set(cur, root)
|
||||
cur = next
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function union(a: string, b: string): void {
|
||||
const ra = find(a)
|
||||
const rb = find(b)
|
||||
if (ra !== rb) parent.set(ra, rb)
|
||||
}
|
||||
|
||||
// Merge groups that share at least one VM.
|
||||
const vmToFirstGroup = new Map<XoVm['id'], string>()
|
||||
for (const [group, vmIds] of affinityGroupVms) {
|
||||
for (const vmId of vmIds) {
|
||||
const existing = vmToFirstGroup.get(vmId)
|
||||
if (existing !== undefined) {
|
||||
union(group, existing)
|
||||
} else {
|
||||
vmToFirstGroup.set(vmId, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect groups and VMs by coalition root.
|
||||
const byRoot = new Map<string, { groups: Set<string>; vms: Set<XoVm['id']> }>()
|
||||
for (const [group, vmIds] of affinityGroupVms) {
|
||||
const root = find(group)
|
||||
let entry = byRoot.get(root)
|
||||
if (entry === undefined) {
|
||||
entry = { groups: new Set(), vms: new Set() }
|
||||
byRoot.set(root, entry)
|
||||
}
|
||||
entry.groups.add(group)
|
||||
for (const vmId of vmIds) entry.vms.add(vmId)
|
||||
}
|
||||
|
||||
const result = new Map<ReadonlySet<string>, ReadonlySet<XoVm['id']>>()
|
||||
for (const { groups, vms } of byRoot.values()) {
|
||||
result.set(groups, vms)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Constraint extraction ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parses VM tags and builds anti-affinity + affinity constraints.
|
||||
*
|
||||
* VMs with the `xo:load:balancer:ignore` tag are excluded from all groups.
|
||||
* They are still physically on hosts (and their memory is already reflected in
|
||||
* `host.memory.usage`), but the load balancer will not move them.
|
||||
*
|
||||
* Returns:
|
||||
* - `constraints`: list of `AntiAffinityConstraint` and `AffinityConstraint`
|
||||
* - `ignoredVmIds`: VMs skipped due to the ignore tag (for caller logging)
|
||||
*/
|
||||
export function extractConstraints(vms: ReadonlyArray<XoVm>): {
|
||||
constraints: ReadonlyArray<Constraint>
|
||||
ignoredVmIds: ReadonlyArray<XoVm['id']>
|
||||
} {
|
||||
const ignoredVmIds: XoVm['id'][] = []
|
||||
|
||||
// group name → vm ids with that anti-affinity tag
|
||||
const antiAffinityGroupVms = new Map<string, Set<XoVm['id']>>()
|
||||
// group name → vm ids with that affinity tag
|
||||
const affinityGroupVms = new Map<string, Set<XoVm['id']>>()
|
||||
// vm id → set of anti-affinity group names it belongs to
|
||||
const vmAntiAffinityGroups = new Map<XoVm['id'], Set<string>>()
|
||||
|
||||
for (const vm of vms) {
|
||||
const config = getVmConfig(vm.tags)
|
||||
|
||||
if (config.ignored) {
|
||||
ignoredVmIds.push(vm.id)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const group of config.antiAffinityGroups) {
|
||||
let vmSet = antiAffinityGroupVms.get(group)
|
||||
if (vmSet === undefined) {
|
||||
vmSet = new Set()
|
||||
antiAffinityGroupVms.set(group, vmSet)
|
||||
}
|
||||
vmSet.add(vm.id)
|
||||
|
||||
let groupSet = vmAntiAffinityGroups.get(vm.id)
|
||||
if (groupSet === undefined) {
|
||||
groupSet = new Set()
|
||||
vmAntiAffinityGroups.set(vm.id, groupSet)
|
||||
}
|
||||
groupSet.add(group)
|
||||
}
|
||||
|
||||
for (const group of config.affinityGroups) {
|
||||
let vmSet = affinityGroupVms.get(group)
|
||||
if (vmSet === undefined) {
|
||||
vmSet = new Set()
|
||||
affinityGroupVms.set(group, vmSet)
|
||||
}
|
||||
vmSet.add(vm.id)
|
||||
}
|
||||
}
|
||||
|
||||
const constraints: Constraint[] = []
|
||||
|
||||
// One AntiAffinityConstraint per group (need ≥ 2 VMs to be meaningful).
|
||||
for (const [group, vmIds] of antiAffinityGroupVms) {
|
||||
if (vmIds.size >= 2) {
|
||||
constraints.push(new AntiAffinityConstraint(group, [...vmIds]))
|
||||
}
|
||||
}
|
||||
|
||||
// One AffinityConstraint per transitive coalition (need ≥ 2 VMs).
|
||||
const coalitions = buildAffinityCoalitions(affinityGroupVms)
|
||||
for (const [coalitionGroups, coalitionVmIds] of coalitions) {
|
||||
if (coalitionVmIds.size < 2) continue
|
||||
|
||||
// Build AntiAffinityData for VMs in this coalition that also have
|
||||
// anti-affinity tags. This lets AffinityConstraint skip moves that would
|
||||
// violate an anti-affinity constraint (anti-affinity has priority).
|
||||
const vmGroupsForCoalition = new Map<XoVm['id'], ReadonlySet<string>>()
|
||||
const relevantGroups = new Set<string>()
|
||||
for (const vmId of coalitionVmIds) {
|
||||
const groups = vmAntiAffinityGroups.get(vmId)
|
||||
if (groups !== undefined && groups.size > 0) {
|
||||
vmGroupsForCoalition.set(vmId, groups)
|
||||
for (const g of groups) relevantGroups.add(g)
|
||||
}
|
||||
}
|
||||
|
||||
let antiAffinityData: AntiAffinityData | undefined
|
||||
if (vmGroupsForCoalition.size > 0) {
|
||||
const groupVmsForCoalition = new Map<string, ReadonlySet<XoVm['id']>>()
|
||||
for (const group of relevantGroups) {
|
||||
const vmSet = antiAffinityGroupVms.get(group)
|
||||
if (vmSet !== undefined) groupVmsForCoalition.set(group, vmSet)
|
||||
}
|
||||
antiAffinityData = { vmGroups: vmGroupsForCoalition, groupVms: groupVmsForCoalition }
|
||||
}
|
||||
|
||||
constraints.push(new AffinityConstraint(coalitionGroups, [...coalitionVmIds], antiAffinityData))
|
||||
}
|
||||
|
||||
return { constraints, ignoredVmIds }
|
||||
}
|
||||
|
||||
// ─── Plan entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes a migration plan for a pool using affinity + anti-affinity tags.
|
||||
*
|
||||
* Inputs:
|
||||
* - `hosts`: all enabled, running hosts in the pool
|
||||
* - `vms`: all running VMs in the pool (including ignored ones — they are
|
||||
* filtered here and their memory is already in host.memory.usage)
|
||||
*
|
||||
* Returns a map of `vmId → destHostId` for VMs that need to migrate.
|
||||
* Returns an empty map when no action is needed.
|
||||
*
|
||||
* This function is pure: no I/O, no side-effects. The caller is responsible
|
||||
* for fetching objects from XO and for executing the returned plan.
|
||||
*/
|
||||
export function computeSimplePlan(hosts: ReadonlyArray<XoHost>, vms: ReadonlyArray<XoVm>): MigrationPlan {
|
||||
if (hosts.length <= 1) {
|
||||
log.debug('skipping simple plan: fewer than 2 hosts', { hostCount: hosts.length })
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const { constraints, ignoredVmIds } = extractConstraints(vms)
|
||||
|
||||
for (const vmId of ignoredVmIds) {
|
||||
log.warn('VM skipped: ignore tag', { vmId })
|
||||
}
|
||||
|
||||
if (constraints.length === 0) {
|
||||
log.debug('skipping simple plan: no constraints found')
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const ignoredSet = new Set(ignoredVmIds)
|
||||
const activeVms = vms.filter(vm => !ignoredSet.has(vm.id))
|
||||
|
||||
// Build placement from $container (host id for running VMs).
|
||||
// Ignored VMs' memory is already reflected in host.memory.usage so they
|
||||
// are excluded from both the placement and the memory simulation.
|
||||
const placement: Placement = new Map(activeVms.map(vm => [vm.id, vm.$container as XoHost['id']]))
|
||||
|
||||
return solve(constraints, placement, hosts, activeVms)
|
||||
}
|
||||
174
packages/xo-server-load-balancer-ng/src/plans/simple.test.mts
Normal file
174
packages/xo-server-load-balancer-ng/src/plans/simple.test.mts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import { strict as assert } from 'node:assert'
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
|
||||
import { computeSimplePlan, extractConstraints } from './simple.mjs'
|
||||
|
||||
// ─── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const h = (id: string) => id as XoHost['id']
|
||||
const v = (id: string) => id as XoVm['id']
|
||||
|
||||
// Hosts have ample memory so no move is ever blocked by memory in these tests.
|
||||
const host = (id: string) => ({ id: h(id), memory: { size: 1024 * 1024, usage: 0 } }) as XoHost
|
||||
|
||||
function vm(id: string, hostId: string, tags: string[] = []): XoVm {
|
||||
return { id: v(id), $container: h(hostId), tags, power_state: 'Running', memory: { size: 512 } } as unknown as XoVm
|
||||
}
|
||||
|
||||
// ─── extractConstraints ───────────────────────────────────────────────────────
|
||||
|
||||
describe('extractConstraints', () => {
|
||||
describe('ignored VMs', () => {
|
||||
it('marks VMs with ignore tag as ignored', () => {
|
||||
const vms = [vm('vm-1', 'h1', ['xo:load:balancer:ignore'])]
|
||||
const { ignoredVmIds, constraints } = extractConstraints(vms)
|
||||
assert.equal(ignoredVmIds.length, 1)
|
||||
assert.equal(ignoredVmIds[0], v('vm-1'))
|
||||
assert.equal(constraints.length, 0)
|
||||
})
|
||||
|
||||
it('does not include ignored VMs in any constraint', () => {
|
||||
// vm-1 is ignored but has anti-affinity=db — it must NOT appear in the constraint
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:ignore', 'xo:load:balancer:anti-affinity=db']),
|
||||
vm('vm-2', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
vm('vm-3', 'h2', ['xo:load:balancer:anti-affinity=db']),
|
||||
]
|
||||
const { constraints } = extractConstraints(vms)
|
||||
// Only vm-2 and vm-3 are in the group — still ≥2, one constraint
|
||||
assert.equal(constraints.length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('anti-affinity constraints', () => {
|
||||
it('creates one constraint per group', () => {
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:anti-affinity=web']),
|
||||
vm('vm-2', 'h1', ['xo:load:balancer:anti-affinity=web', 'xo:load:balancer:anti-affinity=db']),
|
||||
vm('vm-3', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
]
|
||||
const { constraints } = extractConstraints(vms)
|
||||
// Two anti-affinity groups: web=[vm-1,vm-2], db=[vm-2,vm-3]
|
||||
assert.equal(constraints.length, 2)
|
||||
})
|
||||
|
||||
it('skips groups with fewer than 2 VMs', () => {
|
||||
const vms = [vm('vm-1', 'h1', ['xo:load:balancer:anti-affinity=db'])]
|
||||
const { constraints } = extractConstraints(vms)
|
||||
assert.equal(constraints.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('affinity constraints', () => {
|
||||
it('creates one constraint for a single affinity group', () => {
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:affinity=web']),
|
||||
vm('vm-2', 'h2', ['xo:load:balancer:affinity=web']),
|
||||
]
|
||||
const { constraints } = extractConstraints(vms)
|
||||
assert.equal(constraints.length, 1)
|
||||
})
|
||||
|
||||
it('merges groups that share a VM into one coalition constraint', () => {
|
||||
// vm-1: web+db → web and db are in the same coalition
|
||||
// vm-2: web
|
||||
// vm-3: db
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:affinity=web', 'xo:load:balancer:affinity=db']),
|
||||
vm('vm-2', 'h2', ['xo:load:balancer:affinity=web']),
|
||||
vm('vm-3', 'h3', ['xo:load:balancer:affinity=db']),
|
||||
]
|
||||
const { constraints } = extractConstraints(vms)
|
||||
// web and db are linked by vm-1 → one coalition → one constraint
|
||||
assert.equal(constraints.length, 1)
|
||||
})
|
||||
|
||||
it('keeps disjoint affinity groups as separate constraints', () => {
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:affinity=web']),
|
||||
vm('vm-2', 'h2', ['xo:load:balancer:affinity=web']),
|
||||
vm('vm-3', 'h1', ['xo:load:balancer:affinity=db']),
|
||||
vm('vm-4', 'h2', ['xo:load:balancer:affinity=db']),
|
||||
]
|
||||
const { constraints } = extractConstraints(vms)
|
||||
// web and db have no shared VMs → two separate constraints
|
||||
assert.equal(constraints.length, 2)
|
||||
})
|
||||
|
||||
it('skips coalitions with fewer than 2 VMs', () => {
|
||||
const vms = [vm('vm-1', 'h1', ['xo:load:balancer:affinity=web'])]
|
||||
const { constraints } = extractConstraints(vms)
|
||||
assert.equal(constraints.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed affinity + anti-affinity', () => {
|
||||
it('creates both constraint types when a VM has both tags', () => {
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:affinity=web', 'xo:load:balancer:anti-affinity=db']),
|
||||
vm('vm-2', 'h2', ['xo:load:balancer:affinity=web']),
|
||||
vm('vm-3', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
]
|
||||
const { constraints } = extractConstraints(vms)
|
||||
// 1 anti-affinity (db: vm-1+vm-3) + 1 affinity (web: vm-1+vm-2)
|
||||
assert.equal(constraints.length, 2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── computeSimplePlan ────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeSimplePlan', () => {
|
||||
it('returns empty plan when there is only one host', () => {
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
vm('vm-2', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
]
|
||||
const result = computeSimplePlan([host('h1')], vms)
|
||||
assert.equal(result.size, 0)
|
||||
})
|
||||
|
||||
it('returns empty plan when there are no constraints', () => {
|
||||
const vms = [vm('vm-1', 'h1'), vm('vm-2', 'h2')]
|
||||
const result = computeSimplePlan([host('h1'), host('h2')], vms)
|
||||
assert.equal(result.size, 0)
|
||||
})
|
||||
|
||||
it('spreads anti-affinity VMs across hosts', () => {
|
||||
// vm-1 and vm-2 both on h1 with anti-affinity=db → vm-1 should move to h2
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
vm('vm-2', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
]
|
||||
const result = computeSimplePlan([host('h1'), host('h2')], vms)
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
})
|
||||
|
||||
it('colocates affinity VMs on the host with the most of them', () => {
|
||||
// vm-1 and vm-2 on h1, vm-3 on h2, all in affinity=web → vm-3 → h1
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:affinity=web']),
|
||||
vm('vm-2', 'h1', ['xo:load:balancer:affinity=web']),
|
||||
vm('vm-3', 'h2', ['xo:load:balancer:affinity=web']),
|
||||
]
|
||||
const result = computeSimplePlan([host('h1'), host('h2')], vms)
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-3')), h('h1'))
|
||||
})
|
||||
|
||||
it('ignores VMs with the ignore tag', () => {
|
||||
// vm-1 and vm-2 both on h1 with anti-affinity=db
|
||||
// vm-ignored also on h1 with ignore tag — must not be moved
|
||||
const vms = [
|
||||
vm('vm-1', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
vm('vm-2', 'h1', ['xo:load:balancer:anti-affinity=db']),
|
||||
vm('vm-ignored', 'h1', ['xo:load:balancer:ignore']),
|
||||
]
|
||||
const result = computeSimplePlan([host('h1'), host('h2')], vms)
|
||||
assert.equal(result.has(v('vm-ignored')), false)
|
||||
// vm-1 and vm-2 should still be spread
|
||||
assert.equal(result.size, 1)
|
||||
})
|
||||
})
|
||||
147
packages/xo-server-load-balancer-ng/src/solver.mts
Normal file
147
packages/xo-server-load-balancer-ng/src/solver.mts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
|
||||
import type { Constraint, MigrationPlan, Placement } from './constraint.mjs'
|
||||
|
||||
/**
|
||||
* Applies constraints in descending priority order and returns the combined
|
||||
* migration plan.
|
||||
*
|
||||
* Each constraint receives a placement that already reflects all moves from
|
||||
* higher-priority constraints in this pass, so lower-priority constraints
|
||||
* see committed results (e.g. affinity sees where anti-affinity placed VMs).
|
||||
*
|
||||
* The returned plan contains only VMs whose final destination differs from
|
||||
* their initial position. If a lower-priority constraint somehow moves a VM
|
||||
* back to its original host, that entry is removed (net no-op guard).
|
||||
*
|
||||
* Memory awareness: when `vms` is provided, proposed moves are tested against
|
||||
* simulated free memory on the destination host. When a move is rejected:
|
||||
* 1. The destination host is added to a per-attempt "too-full" set.
|
||||
* 2. After processing all moves in that attempt, the constraint is re-invoked
|
||||
* with those hosts removed from the available list.
|
||||
* 3. The constraint then naturally proposes the next-best feasible destination.
|
||||
* This retry loop runs at most `hosts.length` times per constraint, so the
|
||||
* cost stays O(hosts × VMs_in_constraint).
|
||||
*
|
||||
* The simulation tracks:
|
||||
* - departure: source host regains the VM's memory
|
||||
* - arrival: destination host loses the VM's memory
|
||||
* so cascading moves (anti-affinity frees h1 → affinity can then fill h1) are
|
||||
* handled correctly within a single solve() call.
|
||||
*
|
||||
* Documented invariants:
|
||||
* - Constraint sort is stable: equal-priority constraints preserve insertion order.
|
||||
* - `initialPlacement` is never mutated.
|
||||
* - The returned plan is a fresh Map; callers may mutate it safely.
|
||||
*/
|
||||
export function solve(
|
||||
constraints: ReadonlyArray<Constraint>,
|
||||
initialPlacement: Placement,
|
||||
hosts: ReadonlyArray<XoHost>,
|
||||
/** Optional: running VMs in this pool, used for memory feasibility checks. */
|
||||
vms: ReadonlyArray<XoVm> = []
|
||||
): MigrationPlan {
|
||||
// Stable descending sort — Array.prototype.sort is stable in Node 11+
|
||||
const sorted = [...constraints].sort((a, b) => b.priority - a.priority)
|
||||
|
||||
// Index vm.memory.size by vm id for O(1) lookup during move filtering.
|
||||
const vmMemory = new Map<XoVm['id'], number>(vms.map(vm => [vm.id, vm.memory.size]))
|
||||
|
||||
// Index hosts by id for O(1) lookup when processing moves.
|
||||
const hostIndex = new Map<XoHost['id'], XoHost>(hosts.map(h => [h.id, h]))
|
||||
|
||||
// Simulated free memory keyed by host object, updated as moves are committed.
|
||||
// Only populated when vms are provided (skipping it avoids accessing
|
||||
// host.memory in test stubs that omit that field).
|
||||
const freeMemory = new Map<XoHost, number>()
|
||||
if (vmMemory.size > 0) {
|
||||
for (const host of hosts) {
|
||||
freeMemory.set(host, host.memory.size - host.memory.usage)
|
||||
}
|
||||
}
|
||||
|
||||
const allMoves: MigrationPlan = new Map()
|
||||
let currentPlacement: Placement = initialPlacement
|
||||
|
||||
for (const constraint of sorted) {
|
||||
let availableHosts: ReadonlyArray<XoHost> = hosts
|
||||
// partialPlacement tracks moves accepted within this constraint's retry loop
|
||||
// so that retried invocations see the already-committed state.
|
||||
let partialPlacement: Placement = currentPlacement
|
||||
let anyAccepted = false
|
||||
|
||||
// Retry loop: progressively filter out memory-exhausted hosts so the
|
||||
// constraint can propose the next-best feasible destination.
|
||||
// Bounded by hosts.length — at most one host is eliminated per attempt.
|
||||
for (let attempt = 0; attempt < hosts.length; attempt++) {
|
||||
const moves = constraint.apply(partialPlacement, availableHosts)
|
||||
if (moves.size === 0) break
|
||||
|
||||
const tooFullHosts = new Set<XoHost['id']>()
|
||||
|
||||
for (const [vmId, destHostId] of moves) {
|
||||
const vmMem = vmMemory.get(vmId)
|
||||
|
||||
// Memory guard: reject the move if the destination host cannot fit this VM.
|
||||
if (vmMem !== undefined) {
|
||||
const destHost = hostIndex.get(destHostId)
|
||||
if (destHost !== undefined && (freeMemory.get(destHost) ?? 0) < vmMem) {
|
||||
tooFullHosts.add(destHostId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Accept the move and update simulated memory.
|
||||
if (vmMem !== undefined) {
|
||||
const srcHostId = partialPlacement.get(vmId)
|
||||
const srcHost = srcHostId !== undefined ? hostIndex.get(srcHostId) : undefined
|
||||
if (srcHost !== undefined) {
|
||||
freeMemory.set(srcHost, (freeMemory.get(srcHost) ?? 0) + vmMem)
|
||||
}
|
||||
const destHost = hostIndex.get(destHostId)
|
||||
if (destHost !== undefined) {
|
||||
freeMemory.set(destHost, (freeMemory.get(destHost) ?? 0) - vmMem)
|
||||
}
|
||||
}
|
||||
|
||||
allMoves.set(vmId, destHostId)
|
||||
anyAccepted = true
|
||||
}
|
||||
|
||||
if (tooFullHosts.size === 0) break // no rejections — done
|
||||
|
||||
const prevLen = availableHosts.length
|
||||
availableHosts = availableHosts.filter(h => !tooFullHosts.has(h.id))
|
||||
// If no hosts were actually removed (e.g. the constraint ignored the
|
||||
// filtered list and kept proposing already-excluded hosts) we would
|
||||
// loop forever — break to guarantee termination.
|
||||
if (availableHosts.length === prevLen) break
|
||||
if (availableHosts.length < 2) break
|
||||
|
||||
// Update partialPlacement so the next attempt sees the moves accepted so far.
|
||||
const updated = new Map(initialPlacement)
|
||||
for (const [vmId, hostId] of allMoves) {
|
||||
updated.set(vmId, hostId)
|
||||
}
|
||||
partialPlacement = updated
|
||||
}
|
||||
|
||||
if (!anyAccepted) continue
|
||||
|
||||
// Rebuild currentPlacement for the next constraint.
|
||||
const updated = new Map(initialPlacement)
|
||||
for (const [vmId, hostId] of allMoves) {
|
||||
updated.set(vmId, hostId)
|
||||
}
|
||||
currentPlacement = updated
|
||||
}
|
||||
|
||||
// Remove entries where the final destination equals the original host.
|
||||
for (const [vmId, destHostId] of allMoves) {
|
||||
if (initialPlacement.get(vmId) === destHostId) {
|
||||
allMoves.delete(vmId)
|
||||
}
|
||||
}
|
||||
|
||||
return allMoves
|
||||
}
|
||||
330
packages/xo-server-load-balancer-ng/src/solver.test.mts
Normal file
330
packages/xo-server-load-balancer-ng/src/solver.test.mts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import { strict as assert } from 'node:assert'
|
||||
import type { XoHost, XoVm } from '@vates/types'
|
||||
|
||||
import { solve } from './solver.mjs'
|
||||
import { AffinityConstraint } from './constraints/affinity.mjs'
|
||||
import { AntiAffinityConstraint } from './constraints/anti-affinity.mjs'
|
||||
import type { Constraint, Placement } from './constraint.mjs'
|
||||
|
||||
// ─── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const h = (id: string) => id as XoHost['id']
|
||||
const v = (id: string) => id as XoVm['id']
|
||||
|
||||
const host = (id: string) => ({ id: h(id) }) as XoHost
|
||||
|
||||
/** Host with memory fields for memory-aware tests. `free` is the available bytes. */
|
||||
const hostMem = (id: string, total: number, used: number) =>
|
||||
({ id: h(id), memory: { size: total, usage: used } }) as XoHost
|
||||
|
||||
/** VM with a memory.size for memory-aware tests. */
|
||||
const vmMem = (id: string, size: number) => ({ id: v(id), memory: { size } }) as XoVm
|
||||
|
||||
function mkPlacement(pairs: [string, string][]): Placement {
|
||||
return new Map(pairs.map(([vmId, hostId]) => [v(vmId), h(hostId)]))
|
||||
}
|
||||
|
||||
/** Constraint that always returns a fixed set of moves, ignoring placement. */
|
||||
function staticConstraint(priority: number, moves: [string, string][]): Constraint {
|
||||
return {
|
||||
priority,
|
||||
apply: () => new Map(moves.map(([vmId, hostId]) => [v(vmId), h(hostId)])),
|
||||
}
|
||||
}
|
||||
|
||||
/** Constraint that reads placement and moves vm-target to wherever vm-anchor is. */
|
||||
function followConstraint(priority: number, anchor: string, target: string): Constraint {
|
||||
return {
|
||||
priority,
|
||||
apply: (placement: Placement) => {
|
||||
const anchorHost = placement.get(v(anchor))
|
||||
if (anchorHost === undefined) return new Map()
|
||||
return new Map([[v(target), anchorHost]])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('solve', () => {
|
||||
describe('base cases', () => {
|
||||
it('returns an empty plan when there are no constraints', () => {
|
||||
const result = solve([], mkPlacement([['vm-1', 'h1']]), [host('h1'), host('h2')])
|
||||
assert.equal(result.size, 0)
|
||||
})
|
||||
|
||||
it('returns an empty plan when all constraints produce no moves', () => {
|
||||
const c = staticConstraint(10, [])
|
||||
const result = solve([c], mkPlacement([['vm-1', 'h1']]), [host('h1'), host('h2')])
|
||||
assert.equal(result.size, 0)
|
||||
})
|
||||
|
||||
it('returns the moves from a single constraint', () => {
|
||||
const c = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const result = solve([c], mkPlacement([['vm-1', 'h1']]), [host('h1'), host('h2')])
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('priority ordering', () => {
|
||||
it('applies higher-priority constraints before lower-priority ones', () => {
|
||||
// Both want to move vm-1, but to different hosts.
|
||||
// Priority 10 wins: vm-1 → h2. Priority 5 is applied after and moves vm-1 → h3.
|
||||
// The lower-priority constraint's move lands in the final plan.
|
||||
const high = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const low = staticConstraint(5, [['vm-1', 'h3']])
|
||||
const result = solve([low, high], mkPlacement([['vm-1', 'h1']]), [host('h1'), host('h2'), host('h3')])
|
||||
|
||||
// Last writer wins when both constraints move the same VM —
|
||||
// the important thing is that the order is deterministic (priority order).
|
||||
assert.equal(result.get(v('vm-1')), h('h3'))
|
||||
})
|
||||
|
||||
it('collects independent moves from all constraints', () => {
|
||||
const high = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const low = staticConstraint(5, [['vm-2', 'h1']])
|
||||
const result = solve(
|
||||
[low, high],
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h2'],
|
||||
]),
|
||||
[host('h1'), host('h2')]
|
||||
)
|
||||
|
||||
assert.equal(result.size, 2)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.get(v('vm-2')), h('h1'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('placement propagation', () => {
|
||||
it('passes the updated placement (including prior moves) to each subsequent constraint', () => {
|
||||
// Constraint A (priority=10): moves vm-1 → h2
|
||||
// Constraint B (priority=5): moves vm-2 to wherever vm-1 currently is
|
||||
// B must see vm-1 on h2 (A's result), so vm-2 → h2
|
||||
const moveVm1 = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const followVm1 = followConstraint(5, 'vm-1', 'vm-2')
|
||||
|
||||
const result = solve(
|
||||
[moveVm1, followVm1],
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
]),
|
||||
[host('h1'), host('h2')]
|
||||
)
|
||||
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.get(v('vm-2')), h('h2'))
|
||||
})
|
||||
|
||||
it('does NOT pass updated placement to a constraint of equal priority — order is stable', () => {
|
||||
// Both have priority=10. B is inserted after A and must run after A (stable sort).
|
||||
// B follows vm-1 — if run BEFORE A it would see vm-1 on h1, if AFTER it sees h2.
|
||||
const moveVm1 = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const followVm1 = followConstraint(10, 'vm-1', 'vm-2')
|
||||
|
||||
const result = solve(
|
||||
[moveVm1, followVm1], // insertion order: A then B
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
]),
|
||||
[host('h1'), host('h2')]
|
||||
)
|
||||
|
||||
// B runs after A (same priority, stable sort) → sees vm-1 on h2 → vm-2 → h2
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.get(v('vm-2')), h('h2'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('net no-op guard', () => {
|
||||
it('removes a VM from the plan when its final destination equals its original host', () => {
|
||||
// A (priority=10) moves vm-1 → h2; B (priority=5) moves vm-1 back → h1 (original)
|
||||
const forward = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const backward = staticConstraint(5, [['vm-1', 'h1']])
|
||||
|
||||
const result = solve([forward, backward], mkPlacement([['vm-1', 'h1']]), [host('h1'), host('h2')])
|
||||
|
||||
// vm-1 ends up at h1 (original) → not a real migration → removed
|
||||
assert.equal(result.has(v('vm-1')), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory filtering', () => {
|
||||
it('rejects a move when the destination has insufficient free memory', () => {
|
||||
const c = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const result = solve(
|
||||
[c],
|
||||
mkPlacement([['vm-1', 'h1']]),
|
||||
[hostMem('h1', 4096, 0), hostMem('h2', 4096, 3584)], // h2: 512 free
|
||||
[vmMem('vm-1', 1024)] // vm-1 needs 1024
|
||||
)
|
||||
assert.equal(result.size, 0)
|
||||
})
|
||||
|
||||
it('accepts a move when the destination has enough free memory', () => {
|
||||
const c = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const result = solve(
|
||||
[c],
|
||||
mkPlacement([['vm-1', 'h1']]),
|
||||
[hostMem('h1', 4096, 0), hostMem('h2', 4096, 2048)], // h2: 2048 free
|
||||
[vmMem('vm-1', 1024)]
|
||||
)
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
})
|
||||
|
||||
it('accounts for memory freed by a departing VM when evaluating a later move to the same host', () => {
|
||||
// h1: 1024 free, h2: 1024 free. Both vm-1 and vm-2 are 1024 bytes.
|
||||
// Constraint (p=10): vm-1 h1→h2. h2 free: 1024 >= 1024 ✓ → accept.
|
||||
// After: h1 free = 2048, h2 free = 0.
|
||||
// Constraint (p=5): vm-2 h2→h1. h1 free: 2048 >= 1024 ✓ → accept.
|
||||
const moveVm1 = staticConstraint(10, [['vm-1', 'h2']])
|
||||
const moveVm2 = staticConstraint(5, [['vm-2', 'h1']])
|
||||
const result = solve(
|
||||
[moveVm1, moveVm2],
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h2'],
|
||||
]),
|
||||
[hostMem('h1', 4096, 2048), hostMem('h2', 4096, 2048)], // each: 2048 free
|
||||
[vmMem('vm-1', 1024), vmMem('vm-2', 1024)]
|
||||
)
|
||||
assert.equal(result.size, 2)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.get(v('vm-2')), h('h1'))
|
||||
})
|
||||
|
||||
it('blocks a second move to a host already filled by the first move in the same constraint batch', () => {
|
||||
// h2 has exactly 1024 free. vm-1 (1024) and vm-2 (1024) both propose to go to h2.
|
||||
// vm-1 fills h2 → vm-2 is rejected.
|
||||
const c = staticConstraint(10, [
|
||||
['vm-1', 'h2'],
|
||||
['vm-2', 'h2'],
|
||||
])
|
||||
const result = solve(
|
||||
[c],
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
]),
|
||||
[hostMem('h1', 4096, 0), hostMem('h2', 4096, 3072)], // h2: 1024 free
|
||||
[vmMem('vm-1', 1024), vmMem('vm-2', 1024)]
|
||||
)
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.has(v('vm-2')), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('memory retry — filtered hosts', () => {
|
||||
it('retries with the full host removed and picks the next-best destination', () => {
|
||||
// Anti-affinity: vm-1 and vm-2 both on h1.
|
||||
// Attempt 1: best dest = h2 (fewest VMs). h2 has only 50 free < 100 → rejected.
|
||||
// Attempt 2: availableHosts = [h1, h3]. Best dest = h3. h3 has 2000 free >= 100 ✓.
|
||||
// Result: vm-1 → h3.
|
||||
const c = new AntiAffinityConstraint('db', [v('vm-1'), v('vm-2')])
|
||||
const result = solve(
|
||||
[c],
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
]),
|
||||
[hostMem('h1', 4096, 0), hostMem('h2', 4096, 4046), hostMem('h3', 4096, 0)], // h2: 50 free, h3: 4096 free
|
||||
[vmMem('vm-1', 100), vmMem('vm-2', 100)]
|
||||
)
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h3'))
|
||||
})
|
||||
|
||||
it('accepts partial moves and retries only with remaining capacity', () => {
|
||||
// Two VMs need to spread across h2 and h3.
|
||||
// h2: 100 free (fits vm-1=100 but not vm-2=100 after vm-1 fills it).
|
||||
// h3: 200 free (fits vm-2=100).
|
||||
// Attempt 1: vm-1 → h2 (min count=0) ✓ accepted. h2 free → 0.
|
||||
// vm-2 → h2 rejected (h2 now full). tooFullHosts={h2}.
|
||||
// Attempt 2: availableHosts=[h1,h3]. vm-2 → h3 ✓.
|
||||
const c = new AntiAffinityConstraint('db', [v('vm-1'), v('vm-2'), v('vm-3')])
|
||||
const result = solve(
|
||||
[c],
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
]),
|
||||
[
|
||||
hostMem('h1', 4096, 0),
|
||||
hostMem('h2', 4096, 3996), // h2: 100 free — fits exactly one vm
|
||||
hostMem('h3', 4096, 3896), // h3: 200 free — fits two vms
|
||||
],
|
||||
[vmMem('vm-1', 100), vmMem('vm-2', 100), vmMem('vm-3', 100)]
|
||||
)
|
||||
// vm-1 and vm-2 both move out; vm-3 stays on h1 (h1 stays source until balanced)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.get(v('vm-2')), h('h3'))
|
||||
})
|
||||
|
||||
it('gives up when all alternative hosts are also too full', () => {
|
||||
// h2 and h3 both lack memory for vm-1 — no valid destination exists.
|
||||
const c = new AntiAffinityConstraint('db', [v('vm-1'), v('vm-2')])
|
||||
const result = solve(
|
||||
[c],
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
]),
|
||||
[
|
||||
hostMem('h1', 4096, 0),
|
||||
hostMem('h2', 4096, 4090), // 6 free
|
||||
hostMem('h3', 4096, 4090), // 6 free
|
||||
],
|
||||
[vmMem('vm-1', 100), vmMem('vm-2', 100)]
|
||||
)
|
||||
assert.equal(result.size, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('integration — anti-affinity + affinity', () => {
|
||||
it('anti-affinity runs before affinity and affinity respects the resulting placement', () => {
|
||||
// Pool: h1, h2
|
||||
// VMs:
|
||||
// vm-1: affinity=web, anti-affinity=db → on h1
|
||||
// vm-2: affinity=web → on h1
|
||||
// vm-3: anti-affinity=db → on h1
|
||||
//
|
||||
// Anti-affinity group "db" = [vm-1, vm-3], both on h1 → spread:
|
||||
// vm-1 (lex-smallest) → h2
|
||||
//
|
||||
// Affinity group "web" = [vm-1, vm-2], now vm-1 on h2, vm-2 on h1:
|
||||
// target = h1 (has vm-2, count=1 vs h2 count=1 — tie → h1 first in hosts array)
|
||||
// vm-1 needs to move to h1, BUT it has anti-affinity=db and vm-3 is on h1 → conflict → skip
|
||||
//
|
||||
// Final plan: only vm-1 → h2 (from anti-affinity)
|
||||
const antiAffinity = new AntiAffinityConstraint('db', [v('vm-1'), v('vm-3')])
|
||||
const affinity = new AffinityConstraint(new Set(['web']), [v('vm-1'), v('vm-2')], {
|
||||
vmGroups: new Map([[v('vm-1'), new Set(['db'])]]),
|
||||
groupVms: new Map([['db', new Set([v('vm-1'), v('vm-3')])]]),
|
||||
})
|
||||
|
||||
const result = solve(
|
||||
[affinity, antiAffinity], // deliberately pass out of order — solver must sort
|
||||
mkPlacement([
|
||||
['vm-1', 'h1'],
|
||||
['vm-2', 'h1'],
|
||||
['vm-3', 'h1'],
|
||||
]),
|
||||
[host('h1'), host('h2')]
|
||||
)
|
||||
|
||||
// Only anti-affinity move survives; affinity correctly skips vm-1
|
||||
assert.equal(result.size, 1)
|
||||
assert.equal(result.get(v('vm-1')), h('h2'))
|
||||
assert.equal(result.has(v('vm-2')), false)
|
||||
})
|
||||
})
|
||||
})
|
||||
104
packages/xo-server-load-balancer-ng/src/tag-parser.mts
Normal file
104
packages/xo-server-load-balancer-ng/src/tag-parser.mts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Parses XO load-balancer VM tags.
|
||||
*
|
||||
* Tag format:
|
||||
* xo:load:balancer:ignore → VM is excluded from all plans
|
||||
* xo:load:balancer:affinity=<group> → VM must be colocated with its group
|
||||
* xo:load:balancer:anti-affinity=<group> → VM must be spread from its group
|
||||
*
|
||||
* Rules:
|
||||
* - A VM with the `ignore` tag is excluded from all constraints, regardless of other tags.
|
||||
* - Group names are the literal string after the first `=`; they may contain `=` or spaces.
|
||||
* - Unknown sub-keys (e.g. `xo:load:balancer:unknown`) are silently ignored.
|
||||
* - An empty group name (e.g. `xo:load:balancer:affinity=`) is treated as unknown.
|
||||
*/
|
||||
|
||||
const TAG_PREFIX = 'xo:load:balancer:'
|
||||
|
||||
// ─── Discriminated union types for a single parsed tag ───────────────────────
|
||||
|
||||
export type VmTagIgnore = { readonly type: 'ignore' }
|
||||
export type VmTagAffinity = { readonly type: 'affinity'; readonly group: string }
|
||||
export type VmTagAntiAffinity = { readonly type: 'anti-affinity'; readonly group: string }
|
||||
|
||||
export type VmTag = VmTagIgnore | VmTagAffinity | VmTagAntiAffinity
|
||||
|
||||
/**
|
||||
* Parse a single tag string.
|
||||
* Returns `undefined` for non-LB tags or unrecognised LB sub-keys.
|
||||
*/
|
||||
export function parseVmTag(tag: string): VmTag | undefined {
|
||||
if (!tag.startsWith(TAG_PREFIX)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const rest = tag.slice(TAG_PREFIX.length)
|
||||
|
||||
if (rest === 'ignore') {
|
||||
return { type: 'ignore' }
|
||||
}
|
||||
|
||||
// Split on the first `=` only — group names may contain `=`
|
||||
const eqIdx = rest.indexOf('=')
|
||||
if (eqIdx === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const key = rest.slice(0, eqIdx)
|
||||
const group = rest.slice(eqIdx + 1)
|
||||
|
||||
// Empty group name is not valid
|
||||
if (group.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (key === 'affinity') {
|
||||
return { type: 'affinity', group }
|
||||
}
|
||||
|
||||
if (key === 'anti-affinity') {
|
||||
return { type: 'anti-affinity', group }
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ─── VM-level config derived from the full tag list ──────────────────────────
|
||||
|
||||
export type VmLoadBalancerConfig =
|
||||
| { readonly ignored: true }
|
||||
| {
|
||||
readonly ignored: false
|
||||
readonly affinityGroups: ReadonlySet<string>
|
||||
readonly antiAffinityGroups: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the complete load-balancer config for a VM from its tag list.
|
||||
*
|
||||
* If `xo:load:balancer:ignore` is present, returns `{ ignored: true }`
|
||||
* immediately — all other LB tags on the same VM are discarded.
|
||||
*/
|
||||
export function getVmConfig(tags: ReadonlyArray<string>): VmLoadBalancerConfig {
|
||||
const affinityGroups = new Set<string>()
|
||||
const antiAffinityGroups = new Set<string>()
|
||||
|
||||
for (const tag of tags) {
|
||||
const parsed = parseVmTag(tag)
|
||||
if (parsed === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (parsed.type === 'ignore') {
|
||||
return { ignored: true }
|
||||
}
|
||||
|
||||
if (parsed.type === 'affinity') {
|
||||
affinityGroups.add(parsed.group)
|
||||
} else {
|
||||
antiAffinityGroups.add(parsed.group)
|
||||
}
|
||||
}
|
||||
|
||||
return { ignored: false, affinityGroups, antiAffinityGroups }
|
||||
}
|
||||
187
packages/xo-server-load-balancer-ng/src/tag-parser.test.mts
Normal file
187
packages/xo-server-load-balancer-ng/src/tag-parser.test.mts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import { strict as assert } from 'node:assert'
|
||||
|
||||
import { parseVmTag, getVmConfig } from './tag-parser.mjs'
|
||||
|
||||
// ─── parseVmTag ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseVmTag', () => {
|
||||
describe('non-LB tags', () => {
|
||||
it('returns undefined for an unrelated tag', () => {
|
||||
assert.equal(parseVmTag('some-tag'), undefined)
|
||||
})
|
||||
|
||||
it('returns undefined for an empty string', () => {
|
||||
assert.equal(parseVmTag(''), undefined)
|
||||
})
|
||||
|
||||
it('returns undefined for a different xo: namespace', () => {
|
||||
assert.equal(parseVmTag('xo:other:tag'), undefined)
|
||||
})
|
||||
|
||||
it('returns undefined for a prefix-only string', () => {
|
||||
assert.equal(parseVmTag('xo:load:balancer:'), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ignore tag', () => {
|
||||
it('parses the ignore tag', () => {
|
||||
assert.deepEqual(parseVmTag('xo:load:balancer:ignore'), { type: 'ignore' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('affinity tag', () => {
|
||||
it('parses a simple affinity tag', () => {
|
||||
assert.deepEqual(parseVmTag('xo:load:balancer:affinity=web'), {
|
||||
type: 'affinity',
|
||||
group: 'web',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves spaces in the group name', () => {
|
||||
assert.deepEqual(parseVmTag('xo:load:balancer:affinity=my group'), {
|
||||
type: 'affinity',
|
||||
group: 'my group',
|
||||
})
|
||||
})
|
||||
|
||||
it('splits on the first = only — group name may contain =', () => {
|
||||
assert.deepEqual(parseVmTag('xo:load:balancer:affinity=a=b'), {
|
||||
type: 'affinity',
|
||||
group: 'a=b',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined for an empty group name', () => {
|
||||
assert.equal(parseVmTag('xo:load:balancer:affinity='), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('anti-affinity tag', () => {
|
||||
it('parses a simple anti-affinity tag', () => {
|
||||
assert.deepEqual(parseVmTag('xo:load:balancer:anti-affinity=db'), {
|
||||
type: 'anti-affinity',
|
||||
group: 'db',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined for an empty group name', () => {
|
||||
assert.equal(parseVmTag('xo:load:balancer:anti-affinity='), undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown sub-keys', () => {
|
||||
it('returns undefined for an unknown sub-key with a value', () => {
|
||||
assert.equal(parseVmTag('xo:load:balancer:unknown=value'), undefined)
|
||||
})
|
||||
|
||||
it('returns undefined for an unknown sub-key without a value', () => {
|
||||
assert.equal(parseVmTag('xo:load:balancer:unknown'), undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getVmConfig ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getVmConfig', () => {
|
||||
it('returns ignored:false with empty groups for a VM with no LB tags', () => {
|
||||
const config = getVmConfig(['some-tag', 'other-tag'])
|
||||
assert.equal(config.ignored, false)
|
||||
if (!config.ignored) {
|
||||
assert.equal(config.affinityGroups.size, 0)
|
||||
assert.equal(config.antiAffinityGroups.size, 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns ignored:false for an empty tag list', () => {
|
||||
const config = getVmConfig([])
|
||||
assert.equal(config.ignored, false)
|
||||
})
|
||||
|
||||
describe('ignore tag', () => {
|
||||
it('returns ignored:true when the ignore tag is the only LB tag', () => {
|
||||
assert.equal(getVmConfig(['xo:load:balancer:ignore']).ignored, true)
|
||||
})
|
||||
|
||||
it('ignore tag short-circuits affinity tags that appear before it', () => {
|
||||
assert.equal(getVmConfig(['xo:load:balancer:affinity=web', 'xo:load:balancer:ignore']).ignored, true)
|
||||
})
|
||||
|
||||
it('ignore tag short-circuits affinity tags that appear after it', () => {
|
||||
assert.equal(getVmConfig(['xo:load:balancer:ignore', 'xo:load:balancer:affinity=web']).ignored, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('affinity groups', () => {
|
||||
it('collects a single affinity group', () => {
|
||||
const config = getVmConfig(['xo:load:balancer:affinity=web'])
|
||||
assert.equal(config.ignored, false)
|
||||
if (!config.ignored) {
|
||||
assert.ok(config.affinityGroups.has('web'))
|
||||
assert.equal(config.affinityGroups.size, 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('collects multiple affinity groups', () => {
|
||||
const config = getVmConfig(['xo:load:balancer:affinity=web', 'xo:load:balancer:affinity=db'])
|
||||
assert.equal(config.ignored, false)
|
||||
if (!config.ignored) {
|
||||
assert.ok(config.affinityGroups.has('web'))
|
||||
assert.ok(config.affinityGroups.has('db'))
|
||||
assert.equal(config.affinityGroups.size, 2)
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates repeated affinity groups', () => {
|
||||
const config = getVmConfig(['xo:load:balancer:affinity=web', 'xo:load:balancer:affinity=web'])
|
||||
assert.equal(config.ignored, false)
|
||||
if (!config.ignored) {
|
||||
assert.equal(config.affinityGroups.size, 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('anti-affinity groups', () => {
|
||||
it('collects a single anti-affinity group', () => {
|
||||
const config = getVmConfig(['xo:load:balancer:anti-affinity=frontend'])
|
||||
assert.equal(config.ignored, false)
|
||||
if (!config.ignored) {
|
||||
assert.ok(config.antiAffinityGroups.has('frontend'))
|
||||
assert.equal(config.antiAffinityGroups.size, 1)
|
||||
}
|
||||
})
|
||||
|
||||
it('collects multiple anti-affinity groups', () => {
|
||||
const config = getVmConfig(['xo:load:balancer:anti-affinity=frontend', 'xo:load:balancer:anti-affinity=backend'])
|
||||
assert.equal(config.ignored, false)
|
||||
if (!config.ignored) {
|
||||
assert.ok(config.antiAffinityGroups.has('frontend'))
|
||||
assert.ok(config.antiAffinityGroups.has('backend'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('mixed tags', () => {
|
||||
it('collects both affinity and anti-affinity groups', () => {
|
||||
const config = getVmConfig([
|
||||
'xo:load:balancer:affinity=web',
|
||||
'xo:load:balancer:anti-affinity=db',
|
||||
'unrelated-tag',
|
||||
])
|
||||
assert.equal(config.ignored, false)
|
||||
if (!config.ignored) {
|
||||
assert.ok(config.affinityGroups.has('web'))
|
||||
assert.ok(config.antiAffinityGroups.has('db'))
|
||||
}
|
||||
})
|
||||
|
||||
it('silently ignores unknown LB sub-keys', () => {
|
||||
const config = getVmConfig(['xo:load:balancer:unknown=value'])
|
||||
assert.equal(config.ignored, false)
|
||||
if (!config.ignored) {
|
||||
assert.equal(config.affinityGroups.size, 0)
|
||||
assert.equal(config.antiAffinityGroups.size, 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
17
packages/xo-server-load-balancer-ng/tsconfig.json
Normal file
17
packages/xo-server-load-balancer-ng/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.mts", "src/**/*.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user