feat(services): крутить weighted как доли времени на одном IP
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 55s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 1m36s
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / web (push) Successful in 55s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 1m36s
Веса задают долю слотов на общем FQDN (1 к 3 = ¼ и ¾ цикла), TTL 60. В селекте режима показывать текстовое имя, не ID. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -38,6 +38,10 @@ import {
|
|||||||
healthEngineFallbacksFromConfig,
|
healthEngineFallbacksFromConfig,
|
||||||
scheduleHealthCheckJob,
|
scheduleHealthCheckJob,
|
||||||
} from "./services/health-check-scheduler.js";
|
} from "./services/health-check-scheduler.js";
|
||||||
|
import {
|
||||||
|
createWeightedDnsTask,
|
||||||
|
scheduleWeightedDnsJob,
|
||||||
|
} from "./services/weighted-dns-scheduler.js";
|
||||||
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
|
||||||
@@ -133,6 +137,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
app.decorate("reloadHealthCheckJob", () => {
|
app.decorate("reloadHealthCheckJob", () => {
|
||||||
scheduleHealthCheckJob(app, config, healthTask);
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
});
|
});
|
||||||
|
scheduleWeightedDnsJob(app, createWeightedDnsTask(app));
|
||||||
if (config.cloudflareApiToken) {
|
if (config.cloudflareApiToken) {
|
||||||
fireEnsureHealthWorker(
|
fireEnsureHealthWorker(
|
||||||
app.db,
|
app.db,
|
||||||
|
|||||||
@@ -2,25 +2,30 @@ import type { LbMode } from "@cfdm/shared";
|
|||||||
import { failoverDesired } from "./failover.js";
|
import { failoverDesired } from "./failover.js";
|
||||||
import { roundRobinDesired } from "./round-robin.js";
|
import { roundRobinDesired } from "./round-robin.js";
|
||||||
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||||
|
import { weightedDesired } from "./weighted.js";
|
||||||
|
|
||||||
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||||
export { isHealthy } from "./health.js";
|
export { isHealthy } from "./health.js";
|
||||||
export { withBindingLock } from "./binding-lock.js";
|
export { withBindingLock } from "./binding-lock.js";
|
||||||
|
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
|
||||||
|
|
||||||
export function selectActiveIpsByMode(
|
export function selectActiveIpsByMode(
|
||||||
config: LbTargetConfig,
|
config: LbTargetConfig,
|
||||||
rows: LbIpRow[],
|
rows: LbIpRow[],
|
||||||
|
nowMs = Date.now(),
|
||||||
): string[] {
|
): string[] {
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
if (config.lb_mode === "failover") {
|
if (config.lb_mode === "failover") {
|
||||||
return failoverDesired(rows);
|
return failoverDesired(rows);
|
||||||
}
|
}
|
||||||
// weighted = round_robin on DNS (one A per IP)
|
if (config.lb_mode === "weighted") {
|
||||||
|
return weightedDesired(rows, nowMs);
|
||||||
|
}
|
||||||
return roundRobinDesired(rows);
|
return roundRobinDesired(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function strategyLabel(mode: LbMode): string {
|
export function strategyLabel(mode: LbMode): string {
|
||||||
if (mode === "failover") return "Failover";
|
if (mode === "failover") return "Failover";
|
||||||
if (mode === "weighted") return "Round Robin (weighted alias)";
|
if (mode === "weighted") return "Weighted";
|
||||||
return "Round Robin";
|
return "Round Robin";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { LbIpRow } from "./types.js";
|
||||||
|
import { isHealthy } from "./health.js";
|
||||||
|
|
||||||
|
/** Slot length for time-sliced weighted DNS (one A at a time). */
|
||||||
|
export const WEIGHTED_SLOT_MS = 60_000;
|
||||||
|
|
||||||
|
/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */
|
||||||
|
export const WEIGHTED_DNS_TTL = 60;
|
||||||
|
|
||||||
|
export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||||
|
const pool = healthy.length > 0 ? healthy : rows;
|
||||||
|
if (pool.length === 1) return [pool[0]!.ip];
|
||||||
|
|
||||||
|
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
|
||||||
|
const cycle: string[] = [];
|
||||||
|
for (const row of sorted) {
|
||||||
|
const weight = Math.max(1, Math.round(row.weight));
|
||||||
|
for (let i = 0; i < weight; i++) cycle.push(row.ip);
|
||||||
|
}
|
||||||
|
const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;
|
||||||
|
return [cycle[slot]!];
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
isHealthy,
|
isHealthy,
|
||||||
selectActiveIpsByMode,
|
selectActiveIpsByMode,
|
||||||
withBindingLock,
|
withBindingLock,
|
||||||
|
WEIGHTED_DNS_TTL,
|
||||||
type LbIpRow,
|
type LbIpRow,
|
||||||
type LbTargetConfig,
|
type LbTargetConfig,
|
||||||
} from "./routing/index.js";
|
} from "./routing/index.js";
|
||||||
@@ -39,6 +40,12 @@ import {
|
|||||||
export type { LbIpRow, LbTargetConfig };
|
export type { LbIpRow, LbTargetConfig };
|
||||||
export { selectActiveIpsByMode };
|
export { selectActiveIpsByMode };
|
||||||
|
|
||||||
|
const AUTO_DNS_TTL = 1;
|
||||||
|
|
||||||
|
function ttlForLbMode(mode: LbMode): number {
|
||||||
|
return mode === "weighted" ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
export function failoverARecordDiff(
|
export function failoverARecordDiff(
|
||||||
existingA: readonly string[],
|
existingA: readonly string[],
|
||||||
desiredIps: readonly string[],
|
desiredIps: readonly string[],
|
||||||
@@ -276,6 +283,23 @@ function computeActiveIps(
|
|||||||
return selectActiveIpsByMode(state.config, state.rows);
|
return selectActiveIpsByMode(state.config, state.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function desiredAIps(
|
||||||
|
db: Db,
|
||||||
|
scope: HealthCheckScope,
|
||||||
|
refId: number,
|
||||||
|
fallbackIps: string[],
|
||||||
|
): string[] {
|
||||||
|
const config =
|
||||||
|
scope === "binding"
|
||||||
|
? getBindingLbState(db, refId).config
|
||||||
|
: getGroupLbState(db, refId).config;
|
||||||
|
if (config.lb_mode === "weighted" || config.health_check_enabled) {
|
||||||
|
const activeIps = computeActiveIps(db, scope, refId);
|
||||||
|
if (activeIps.length > 0) return activeIps;
|
||||||
|
}
|
||||||
|
return fallbackIps;
|
||||||
|
}
|
||||||
|
|
||||||
async function collectKnownZones(
|
async function collectKnownZones(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
@@ -613,7 +637,16 @@ async function syncBindingDns(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
await syncBindingADns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
bindingId,
|
||||||
|
domainId,
|
||||||
|
hostname,
|
||||||
|
desiredIps,
|
||||||
|
ttlForLbMode(binding.lb_mode),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncBindingCnameDns(
|
async function syncBindingCnameDns(
|
||||||
@@ -700,6 +733,7 @@ async function syncBindingADns(
|
|||||||
domainId: number,
|
domainId: number,
|
||||||
hostname: string,
|
hostname: string,
|
||||||
desiredIps: string[],
|
desiredIps: string[],
|
||||||
|
ttl: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
@@ -735,13 +769,15 @@ async function syncBindingADns(
|
|||||||
|
|
||||||
for (const ip of desiredIps) {
|
for (const ip of desiredIps) {
|
||||||
const existing = refreshed.find((r) => r.content === ip);
|
const existing = refreshed.find((r) => r.content === ip);
|
||||||
|
const recordName = dnsNameForBinding(hostname, zoneName);
|
||||||
let recordId: number;
|
let recordId: number;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
||||||
await dnsService.update(db, cf, domainId, existing.id, {
|
await dnsService.update(db, cf, domainId, existing.id, {
|
||||||
record_type: "A",
|
record_type: "A",
|
||||||
name: dnsNameForBinding(hostname, zoneName),
|
name: recordName,
|
||||||
content: ip,
|
content: ip,
|
||||||
|
ttl,
|
||||||
proxied: false,
|
proxied: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -759,12 +795,24 @@ async function syncBindingADns(
|
|||||||
if (adopted) {
|
if (adopted) {
|
||||||
repos.linkBindingRecord(db, bindingId, adopted.id);
|
repos.linkBindingRecord(db, bindingId, adopted.id);
|
||||||
recordId = adopted.id;
|
recordId = adopted.id;
|
||||||
|
if (
|
||||||
|
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
|
||||||
|
adopted.ttl !== ttl
|
||||||
|
) {
|
||||||
|
await dnsService.update(db, cf, domainId, adopted.id, {
|
||||||
|
record_type: "A",
|
||||||
|
name: recordName,
|
||||||
|
content: ip,
|
||||||
|
ttl,
|
||||||
|
proxied: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const record = await dnsService.create(db, cf, domainId, {
|
const record = await dnsService.create(db, cf, domainId, {
|
||||||
record_type: "A",
|
record_type: "A",
|
||||||
name: dnsNameForBinding(hostname, zoneName),
|
name: recordName,
|
||||||
content: ip,
|
content: ip,
|
||||||
ttl: 1,
|
ttl,
|
||||||
proxied: false,
|
proxied: false,
|
||||||
});
|
});
|
||||||
repos.linkBindingRecord(db, bindingId, record.id);
|
repos.linkBindingRecord(db, bindingId, record.id);
|
||||||
@@ -1001,12 +1049,7 @@ async function syncServiceBindingsToDns(
|
|||||||
}
|
}
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
|
|
||||||
if (binding.health_check_enabled) {
|
const desiredIps = desiredAIps(db, "binding", binding.id, targetIps);
|
||||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
|
||||||
if (activeIps.length > 0) {
|
|
||||||
targetIps = activeIps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncBindingDns(
|
await syncBindingDns(
|
||||||
db,
|
db,
|
||||||
@@ -1014,7 +1057,7 @@ async function syncServiceBindingsToDns(
|
|||||||
binding.id,
|
binding.id,
|
||||||
binding.domain_id,
|
binding.domain_id,
|
||||||
binding.hostname,
|
binding.hostname,
|
||||||
targetIps,
|
desiredIps,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1046,6 +1089,7 @@ async function syncGroupDomainDnsRecords(
|
|||||||
domainId: number,
|
domainId: number,
|
||||||
hostname: string,
|
hostname: string,
|
||||||
desiredIps: string[],
|
desiredIps: string[],
|
||||||
|
ttl: number = AUTO_DNS_TTL,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const domain = repos.getDomain(db, domainId);
|
const domain = repos.getDomain(db, domainId);
|
||||||
const zoneName = domain.zone_name;
|
const zoneName = domain.zone_name;
|
||||||
@@ -1061,14 +1105,16 @@ async function syncGroupDomainDnsRecords(
|
|||||||
if (desiredIps.length === 0) return;
|
if (desiredIps.length === 0) return;
|
||||||
|
|
||||||
const refreshed = repos.listGroupDnsRecords(db, groupId);
|
const refreshed = repos.listGroupDnsRecords(db, groupId);
|
||||||
|
const recordName = dnsNameForBinding(hostname, zoneName);
|
||||||
for (const ip of desiredIps) {
|
for (const ip of desiredIps) {
|
||||||
const existing = refreshed.find((r) => r.content === ip);
|
const existing = refreshed.find((r) => r.content === ip);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
||||||
await dnsService.update(db, cf, domainId, existing.id, {
|
await dnsService.update(db, cf, domainId, existing.id, {
|
||||||
record_type: "A",
|
record_type: "A",
|
||||||
name: dnsNameForBinding(hostname, zoneName),
|
name: recordName,
|
||||||
content: ip,
|
content: ip,
|
||||||
|
ttl,
|
||||||
proxied: false,
|
proxied: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1084,13 +1130,25 @@ async function syncGroupDomainDnsRecords(
|
|||||||
);
|
);
|
||||||
if (adopted) {
|
if (adopted) {
|
||||||
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
||||||
|
if (
|
||||||
|
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
|
||||||
|
adopted.ttl !== ttl
|
||||||
|
) {
|
||||||
|
await dnsService.update(db, cf, domainId, adopted.id, {
|
||||||
|
record_type: "A",
|
||||||
|
name: recordName,
|
||||||
|
content: ip,
|
||||||
|
ttl,
|
||||||
|
proxied: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const record = await dnsService.create(db, cf, domainId, {
|
const record = await dnsService.create(db, cf, domainId, {
|
||||||
record_type: "A",
|
record_type: "A",
|
||||||
name: dnsNameForBinding(hostname, zoneName),
|
name: recordName,
|
||||||
content: ip,
|
content: ip,
|
||||||
ttl: 1,
|
ttl,
|
||||||
proxied: false,
|
proxied: false,
|
||||||
});
|
});
|
||||||
repos.linkGroupDnsRecord(db, groupId, record.id);
|
repos.linkGroupDnsRecord(db, groupId, record.id);
|
||||||
@@ -1141,9 +1199,8 @@ async function syncGroupDomainDns(
|
|||||||
const knownZones = await collectKnownZones(db, cf);
|
const knownZones = await collectKnownZones(db, cf);
|
||||||
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
||||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||||
const desiredIps = group.health_check_enabled
|
const fallbackIps = await collectGroupDnsIps(db, groupId);
|
||||||
? computeActiveIps(db, "group", groupId)
|
const desiredIps = desiredAIps(db, "group", groupId, fallbackIps);
|
||||||
: await collectGroupDnsIps(db, groupId);
|
|
||||||
await syncGroupDomainDnsRecords(
|
await syncGroupDomainDnsRecords(
|
||||||
db,
|
db,
|
||||||
cf,
|
cf,
|
||||||
@@ -1151,6 +1208,7 @@ async function syncGroupDomainDns(
|
|||||||
domainId,
|
domainId,
|
||||||
hostname,
|
hostname,
|
||||||
desiredIps,
|
desiredIps,
|
||||||
|
ttlForLbMode(group.lb_mode),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1331,14 +1389,7 @@ export async function updateConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pushDns) {
|
if (pushDns) {
|
||||||
let effectiveIps = targetIps;
|
const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps);
|
||||||
const refreshedBinding = repos.getBinding(db, binding.id);
|
|
||||||
if (refreshedBinding.health_check_enabled) {
|
|
||||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
|
||||||
if (activeIps.length > 0) {
|
|
||||||
effectiveIps = activeIps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await syncBindingDns(
|
await syncBindingDns(
|
||||||
db,
|
db,
|
||||||
cf,
|
cf,
|
||||||
@@ -1644,7 +1695,7 @@ export async function reconcileDnsForTarget(
|
|||||||
if (scope === "binding") {
|
if (scope === "binding") {
|
||||||
await withBindingLock(refId, async () => {
|
await withBindingLock(refId, async () => {
|
||||||
const binding = repos.getBinding(db, refId);
|
const binding = repos.getBinding(db, refId);
|
||||||
if (!binding.health_check_enabled) return;
|
if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return;
|
||||||
const service = repos.getService(db, binding.service_id);
|
const service = repos.getService(db, binding.service_id);
|
||||||
if (!shouldPushDns(db, service)) return;
|
if (!shouldPushDns(db, service)) return;
|
||||||
const cnameTarget = binding.cname_target?.trim() || null;
|
const cnameTarget = binding.cname_target?.trim() || null;
|
||||||
@@ -1652,8 +1703,7 @@ export async function reconcileDnsForTarget(
|
|||||||
const ips = repos.listServiceIps(db, service.id);
|
const ips = repos.listServiceIps(db, service.id);
|
||||||
const targetIps = repos.listBindingIps(db, binding.id);
|
const targetIps = repos.listBindingIps(db, binding.id);
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
const activeIps = computeActiveIps(db, "binding", refId);
|
const desiredIps = desiredAIps(db, "binding", refId, targetIps);
|
||||||
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
|
||||||
await syncBindingDns(
|
await syncBindingDns(
|
||||||
db,
|
db,
|
||||||
cf,
|
cf,
|
||||||
@@ -1668,8 +1718,59 @@ export async function reconcileDnsForTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const group = repos.getServiceGroup(db, refId);
|
const group = repos.getServiceGroup(db, refId);
|
||||||
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
|
if (!group.enabled || !group.domain?.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!group.health_check_enabled && group.lb_mode !== "weighted") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await syncGroupDomainDns(db, cf, refId);
|
await syncGroupDomainDns(db, cf, refId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reconcileWeightedDns(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
): Promise<number> {
|
||||||
|
let n = 0;
|
||||||
|
for (const binding of repos.listAllBindings(db)) {
|
||||||
|
if (binding.lb_mode !== "weighted") continue;
|
||||||
|
if (binding.cname_target?.trim()) continue;
|
||||||
|
try {
|
||||||
|
await withBindingLock(binding.id, async () => {
|
||||||
|
const latest = repos.getBinding(db, binding.id);
|
||||||
|
if (latest.lb_mode !== "weighted") return;
|
||||||
|
if (latest.cname_target?.trim()) return;
|
||||||
|
const service = repos.getService(db, latest.service_id);
|
||||||
|
if (!shouldPushDns(db, service)) return;
|
||||||
|
const targetIps = repos.listBindingIps(db, latest.id);
|
||||||
|
if (targetIps.length === 0) return;
|
||||||
|
const ips = repos.listServiceIps(db, service.id);
|
||||||
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
|
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
|
||||||
|
await syncBindingDns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
latest.id,
|
||||||
|
latest.domain_id,
|
||||||
|
latest.hostname,
|
||||||
|
desiredIps,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
n += 1;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const group of repos.listServiceGroups(db)) {
|
||||||
|
if (group.lb_mode !== "weighted") continue;
|
||||||
|
if (!group.enabled || !group.domain?.trim()) continue;
|
||||||
|
try {
|
||||||
|
await syncGroupDomainDns(db, cf, group.id);
|
||||||
|
n += 1;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { AsyncTask, SimpleIntervalJob } from "toad-scheduler";
|
||||||
|
import * as serviceConfigService from "./service-config-service.js";
|
||||||
|
import { WEIGHTED_SLOT_MS } from "./routing/weighted.js";
|
||||||
|
|
||||||
|
export const WEIGHTED_DNS_JOB_ID = "weighted-dns";
|
||||||
|
|
||||||
|
export function createWeightedDnsTask(app: FastifyInstance): AsyncTask {
|
||||||
|
return new AsyncTask(
|
||||||
|
WEIGHTED_DNS_JOB_ID,
|
||||||
|
async () => {
|
||||||
|
const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf);
|
||||||
|
if (n > 0) {
|
||||||
|
app.log.info({ reconciled: n }, "weighted dns rotated");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
app.log.warn({ err }, "weighted dns rotate failed");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleWeightedDnsJob(
|
||||||
|
app: FastifyInstance,
|
||||||
|
task: AsyncTask,
|
||||||
|
): void {
|
||||||
|
const scheduler = app.scheduler;
|
||||||
|
if (!scheduler) return;
|
||||||
|
if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {
|
||||||
|
scheduler.removeById(WEIGHTED_DNS_JOB_ID);
|
||||||
|
}
|
||||||
|
scheduler.addSimpleIntervalJob(
|
||||||
|
new SimpleIntervalJob(
|
||||||
|
{ seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true },
|
||||||
|
task,
|
||||||
|
{ id: WEIGHTED_DNS_JOB_ID, preventOverrun: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type LbIpRow,
|
type LbIpRow,
|
||||||
type LbTargetConfig,
|
type LbTargetConfig,
|
||||||
} from "../src/services/service-config-service.js";
|
} from "../src/services/service-config-service.js";
|
||||||
|
import { WEIGHTED_SLOT_MS } from "../src/services/routing/weighted.js";
|
||||||
|
|
||||||
function row(
|
function row(
|
||||||
ip: string,
|
ip: string,
|
||||||
@@ -17,6 +18,11 @@ function row(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const weightedConfig: LbTargetConfig = {
|
||||||
|
lb_mode: "weighted",
|
||||||
|
health_check_enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
describe("selectActiveIpsByMode", () => {
|
describe("selectActiveIpsByMode", () => {
|
||||||
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
|
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
|
||||||
const config: LbTargetConfig = {
|
const config: LbTargetConfig = {
|
||||||
@@ -75,22 +81,49 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
|
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => {
|
it("weighted 1:3 picks the lighter ip on slot 0 and the heavier on slot 1", () => {
|
||||||
const config: LbTargetConfig = {
|
|
||||||
lb_mode: "weighted",
|
|
||||||
health_check_enabled: true,
|
|
||||||
};
|
|
||||||
const rows = [
|
const rows = [
|
||||||
row("1.1.1.1", { weight: 3, health: "up" }),
|
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||||
row("2.2.2.2", { weight: 1, health: "up" }),
|
row("2.2.2.2", { weight: 3, health: "up" }),
|
||||||
row("3.3.3.3", { weight: 2, health: "down" }),
|
|
||||||
];
|
];
|
||||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||||
"1.1.1.1",
|
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
|
||||||
"2.2.2.2",
|
"2.2.2.2",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("weighted excludes down ips from the cycle", () => {
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||||
|
row("2.2.2.2", { weight: 3, health: "down" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||||
|
expect(
|
||||||
|
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
|
||||||
|
).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weighted with one ip always returns that ip", () => {
|
||||||
|
expect(
|
||||||
|
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
|
||||||
|
).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weighted with all unknown rotates across every ip", () => {
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { weight: 1, health: "unknown" }),
|
||||||
|
row("2.2.2.2", { weight: 3, health: "unknown" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
|
||||||
|
"2.2.2.2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weighted returns empty array for no rows", () => {
|
||||||
|
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("round_robin excludes unknown when another ip is up", () => {
|
it("round_robin excludes unknown when another ip is up", () => {
|
||||||
const config: LbTargetConfig = {
|
const config: LbTargetConfig = {
|
||||||
lb_mode: "round_robin",
|
lb_mode: "round_robin",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
import { AppInput } from '@/components/app-input'
|
import { AppInput } from '@/components/app-input'
|
||||||
import { SettingRow } from '@/components/setting-row'
|
import { SettingRow } from '@/components/setting-row'
|
||||||
|
import { SelectField } from '@/components/select-field'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
NumberField,
|
NumberField,
|
||||||
@@ -9,13 +10,6 @@ import {
|
|||||||
NumberFieldIncrement,
|
NumberFieldIncrement,
|
||||||
NumberFieldInput,
|
NumberFieldInput,
|
||||||
} from '@/components/reui/number-field'
|
} from '@/components/reui/number-field'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@cfdm/ui/components/select'
|
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
@@ -59,7 +53,7 @@ export interface LbAndHealthConfig extends HealthCheckConfig {
|
|||||||
const defaultLbModeOptions = [
|
const defaultLbModeOptions = [
|
||||||
{ value: 'round_robin', label: 'Round Robin' },
|
{ value: 'round_robin', label: 'Round Robin' },
|
||||||
{ value: 'failover', label: 'Failover (приоритет)' },
|
{ value: 'failover', label: 'Failover (приоритет)' },
|
||||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
{ value: 'weighted', label: 'Веса (подмена IP)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface LbPoolMetaChange {
|
export interface LbPoolMetaChange {
|
||||||
@@ -126,7 +120,7 @@ function PoolLbMetaFields({
|
|||||||
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
|
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
|
||||||
description={
|
description={
|
||||||
isWeighted
|
isWeighted
|
||||||
? 'Больше — чаще в пуле'
|
? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)'
|
||||||
: '1 — основной, больше — запасной'
|
: '1 — основной, больше — запасной'
|
||||||
}
|
}
|
||||||
compact
|
compact
|
||||||
@@ -230,22 +224,14 @@ export function HealthCheckConfigFields({
|
|||||||
compact
|
compact
|
||||||
className={rowClass}
|
className={rowClass}
|
||||||
>
|
>
|
||||||
<Select
|
<SelectField
|
||||||
modal={false}
|
modal={false}
|
||||||
value={value.lb_mode}
|
value={value.lb_mode}
|
||||||
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
||||||
>
|
triggerId={`${idPrefix}-lb-mode`}
|
||||||
<SelectTrigger id={`${idPrefix}-lb-mode`} className="w-full">
|
placeholder="Выберите режим"
|
||||||
<SelectValue placeholder="Выберите режим" />
|
options={lbModeOptions}
|
||||||
</SelectTrigger>
|
/>
|
||||||
<SelectContent>
|
|
||||||
{lbModeOptions.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const LB_MODE_META: Record<
|
|||||||
weighted: {
|
weighted: {
|
||||||
icon: ScaleIcon,
|
icon: ScaleIcon,
|
||||||
className: 'text-info',
|
className: 'text-info',
|
||||||
label: 'Weighted (веса)',
|
label: 'Веса (подмена IP)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user