Skip to content

Commit bdf5adc

Browse files
committed
Fix proxy backend port reconciliation
1 parent 506aef0 commit bdf5adc

2 files changed

Lines changed: 321 additions & 31 deletions

File tree

scripts/terrarium-traefik-sync.ts

Lines changed: 203 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,17 @@ type LxcNetwork = {
4747
addresses?: LxcAddress[];
4848
};
4949

50-
type LxcInstance = {
50+
type LxdDevice = {
51+
type?: string;
52+
listen?: string;
53+
connect?: string;
54+
};
55+
56+
export type LxcInstance = {
5157
name?: string;
5258
config?: Record<string, string>;
59+
devices?: Record<string, LxdDevice>;
60+
expanded_devices?: Record<string, LxdDevice>;
5361
state?: {
5462
network?: Record<string, LxcNetwork>;
5563
};
@@ -84,18 +92,25 @@ type TransportProxyItem = { kind: "tcp" | "udp"; hostPort: number; containerPort
8492

8593
type ProxyBackendProtocol = "tcp" | "udp";
8694

87-
type ProxyBackendSpec = {
95+
export type ProxyBackendSpec = {
8896
key: string;
8997
containerName: string;
9098
proto: ProxyBackendProtocol;
9199
targetPort: number;
92100
deviceName: string;
93101
};
94102

95-
type ProxyBackendStateEntry = ProxyBackendSpec & {
103+
export type ProxyBackendStateEntry = ProxyBackendSpec & {
96104
hostPort: number;
97105
};
98106

107+
export type ExistingProxyBackendDevice = {
108+
containerName: string;
109+
deviceName: string;
110+
listen?: string;
111+
hostPort?: number;
112+
};
113+
99114
export type ProxyBackendTarget = {
100115
address: string;
101116
port: number;
@@ -797,7 +812,7 @@ function proxyBackendDeviceName(key: string, proto: ProxyBackendProtocol, target
797812
return `${PROXY_BACKEND_DEVICE_PREFIX}-${proto}-${targetPort}-${hash}`;
798813
}
799814

800-
function collectDesiredProxyBackendSpecs(containers: LxcInstance[]): ProxyBackendSpec[] {
815+
export function collectDesiredProxyBackendSpecs(containers: LxcInstance[]): ProxyBackendSpec[] {
801816
const specs = new Map<string, ProxyBackendSpec>();
802817

803818
for (const container of containersWithProxyLabels(containers)) {
@@ -829,6 +844,166 @@ function collectDesiredProxyBackendSpecs(containers: LxcInstance[]): ProxyBacken
829844
return [...specs.values()].sort((left, right) => left.key.localeCompare(right.key));
830845
}
831846

847+
function validProxyBackendHostPort(port: number | undefined): port is number {
848+
return (
849+
typeof port === "number" &&
850+
Number.isInteger(port) &&
851+
port >= PROXY_BACKEND_BASE_PORT &&
852+
port <= PROXY_BACKEND_MAX_PORT
853+
);
854+
}
855+
856+
export function parseProxyListenPort(listen: unknown): number | null {
857+
if (typeof listen !== "string") {
858+
return null;
859+
}
860+
861+
const match = listen.match(/^(?:tcp|udp):.+:(\d+)$/);
862+
if (!match) {
863+
return null;
864+
}
865+
866+
const port = Number(match[1]);
867+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
868+
}
869+
870+
export function collectExistingProxyBackendDevices(containers: LxcInstance[]): ExistingProxyBackendDevice[] {
871+
const devices = new Map<string, ExistingProxyBackendDevice>();
872+
873+
for (const container of containers) {
874+
if (!container.name) {
875+
continue;
876+
}
877+
878+
for (const deviceMap of [container.devices, container.expanded_devices]) {
879+
if (!deviceMap) {
880+
continue;
881+
}
882+
883+
for (const [deviceName, device] of Object.entries(deviceMap)) {
884+
if (!deviceName.startsWith(`${PROXY_BACKEND_DEVICE_PREFIX}-`) || device?.type !== "proxy") {
885+
continue;
886+
}
887+
888+
const listenPort = parseProxyListenPort(device.listen);
889+
devices.set(`${container.name}\0${deviceName}`, {
890+
containerName: container.name,
891+
deviceName,
892+
listen: device.listen,
893+
...(listenPort === null ? {} : { hostPort: listenPort })
894+
});
895+
}
896+
}
897+
}
898+
899+
return [...devices.values()].sort(
900+
(left, right) => left.containerName.localeCompare(right.containerName) || left.deviceName.localeCompare(right.deviceName)
901+
);
902+
}
903+
904+
function proxyBackendDeviceNamesByContainer(specs: ProxyBackendSpec[]): Map<string, Set<string>> {
905+
const names = new Map<string, Set<string>>();
906+
907+
for (const spec of specs) {
908+
let containerNames = names.get(spec.containerName);
909+
if (!containerNames) {
910+
containerNames = new Set<string>();
911+
names.set(spec.containerName, containerNames);
912+
}
913+
containerNames.add(spec.deviceName);
914+
}
915+
916+
return names;
917+
}
918+
919+
export function findStaleExistingProxyBackendDevices(
920+
existingDevices: ExistingProxyBackendDevice[],
921+
specs: ProxyBackendSpec[]
922+
): ExistingProxyBackendDevice[] {
923+
const desiredNamesByContainer = proxyBackendDeviceNamesByContainer(specs);
924+
return existingDevices.filter((device) => {
925+
const desiredNames = desiredNamesByContainer.get(device.containerName);
926+
return desiredNames !== undefined && !desiredNames.has(device.deviceName);
927+
});
928+
}
929+
930+
function proxyBackendDeviceIdentity(device: { containerName: string; deviceName: string }): string {
931+
return `${device.containerName}\0${device.deviceName}`;
932+
}
933+
934+
export function planProxyBackendEntries(
935+
specs: ProxyBackendSpec[],
936+
previous: ProxyBackendStateEntry[],
937+
keptExistingDevices: ExistingProxyBackendDevice[]
938+
): { entries: ProxyBackendStateEntry[]; errors: string[] } {
939+
const previousByKey = new Map<string, ProxyBackendStateEntry>();
940+
const existingByDesiredKey = new Map<string, ExistingProxyBackendDevice>();
941+
const desiredKeyByDevice = new Map(specs.map((spec) => [proxyBackendDeviceIdentity(spec), spec.key]));
942+
const keptDevicesByPort = new Map<number, ExistingProxyBackendDevice[]>();
943+
const usedPorts = new Set<number>();
944+
945+
for (const entry of previous) {
946+
if (!previousByKey.has(entry.key)) {
947+
previousByKey.set(entry.key, entry);
948+
}
949+
if (validProxyBackendHostPort(entry.hostPort)) {
950+
usedPorts.add(entry.hostPort);
951+
}
952+
}
953+
954+
for (const device of keptExistingDevices) {
955+
if (validProxyBackendHostPort(device.hostPort)) {
956+
usedPorts.add(device.hostPort);
957+
const devices = keptDevicesByPort.get(device.hostPort) ?? [];
958+
devices.push(device);
959+
keptDevicesByPort.set(device.hostPort, devices);
960+
}
961+
962+
const desiredKey = desiredKeyByDevice.get(proxyBackendDeviceIdentity(device));
963+
if (desiredKey && !existingByDesiredKey.has(desiredKey)) {
964+
existingByDesiredKey.set(desiredKey, device);
965+
}
966+
}
967+
968+
const entries: ProxyBackendStateEntry[] = [];
969+
const errors: string[] = [];
970+
971+
const portAvailableForSpec = (port: number, spec: ProxyBackendSpec): boolean => {
972+
const devices = keptDevicesByPort.get(port) ?? [];
973+
return devices.every((device) => device.containerName === spec.containerName && device.deviceName === spec.deviceName);
974+
};
975+
976+
for (const spec of specs) {
977+
const previousEntry = previousByKey.get(spec.key);
978+
const existingDevice = existingByDesiredKey.get(spec.key);
979+
let hostPort =
980+
previousEntry?.hostPort &&
981+
validProxyBackendHostPort(previousEntry.hostPort) &&
982+
portAvailableForSpec(previousEntry.hostPort, spec) &&
983+
!entries.some((entry) => entry.hostPort === previousEntry.hostPort)
984+
? previousEntry.hostPort
985+
: undefined;
986+
987+
if (!hostPort && validProxyBackendHostPort(existingDevice?.hostPort) && !entries.some((entry) => entry.hostPort === existingDevice.hostPort)) {
988+
hostPort = existingDevice.hostPort;
989+
}
990+
991+
if (!hostPort) {
992+
try {
993+
hostPort = allocateProxyBackendPort(usedPorts);
994+
} catch (error) {
995+
errors.push(`${spec.containerName}: ${String(error).replace(/^Error: /, "")}`);
996+
continue;
997+
}
998+
}
999+
1000+
usedPorts.add(hostPort);
1001+
entries.push({ ...spec, hostPort });
1002+
}
1003+
1004+
return { entries, errors };
1005+
}
1006+
8321007
function isProxyBackendStateEntry(value: unknown): value is ProxyBackendStateEntry {
8331008
if (!value || typeof value !== "object") {
8341009
return false;
@@ -879,7 +1054,7 @@ async function readLxdProxyDeviceValue(containerName: string, deviceName: string
8791054
return result.stdout.trim();
8801055
}
8811056

882-
async function removeLxdProxyDevice(entry: ProxyBackendStateEntry): Promise<string | null> {
1057+
async function removeLxdProxyDevice(entry: { containerName: string; deviceName: string }): Promise<string | null> {
8831058
const result = await runAllowFailure([
8841059
"timeout",
8851060
"30s",
@@ -940,6 +1115,19 @@ async function syncLxdProxyBackends(containers: LxcInstance[]): Promise<{ target
9401115
const previousByKey = new Map<string, ProxyBackendStateEntry>();
9411116
const errors: string[] = [];
9421117

1118+
const existingDevices = collectExistingProxyBackendDevices(containers);
1119+
const staleExistingDevices = findStaleExistingProxyBackendDevices(existingDevices, specs);
1120+
const staleDeviceIdentities = new Set(staleExistingDevices.map(proxyBackendDeviceIdentity));
1121+
const failedStaleDeviceRemovals = new Set<string>();
1122+
1123+
for (const device of staleExistingDevices) {
1124+
const removeError = await removeLxdProxyDevice(device);
1125+
if (removeError) {
1126+
errors.push(removeError);
1127+
failedStaleDeviceRemovals.add(proxyBackendDeviceIdentity(device));
1128+
}
1129+
}
1130+
9431131
for (const entry of previous) {
9441132
if (!previousByKey.has(entry.key)) {
9451133
previousByKey.set(entry.key, entry);
@@ -952,41 +1140,26 @@ async function syncLxdProxyBackends(containers: LxcInstance[]): Promise<{ target
9521140
}
9531141
}
9541142

955-
const usedPorts = new Set(
956-
previous
957-
.map((entry) => entry.hostPort)
958-
.filter((port) => Number.isInteger(port) && port >= PROXY_BACKEND_BASE_PORT && port <= PROXY_BACKEND_MAX_PORT)
959-
);
1143+
const keptExistingDevices = existingDevices.filter((device) => {
1144+
const identity = proxyBackendDeviceIdentity(device);
1145+
return !staleDeviceIdentities.has(identity) || failedStaleDeviceRemovals.has(identity);
1146+
});
1147+
const { entries: plannedEntries, errors: allocationErrors } = planProxyBackendEntries(specs, previous, keptExistingDevices);
1148+
errors.push(...allocationErrors);
1149+
9601150
const next: ProxyBackendStateEntry[] = [];
9611151

962-
for (const spec of specs) {
963-
const previousEntry = previousByKey.get(spec.key);
964-
let hostPort =
965-
previousEntry?.hostPort &&
966-
previousEntry.hostPort >= PROXY_BACKEND_BASE_PORT &&
967-
previousEntry.hostPort <= PROXY_BACKEND_MAX_PORT &&
968-
!next.some((entry) => entry.hostPort === previousEntry.hostPort)
969-
? previousEntry.hostPort
970-
: undefined;
971-
if (!hostPort) {
972-
try {
973-
hostPort = allocateProxyBackendPort(usedPorts);
974-
} catch (error) {
975-
errors.push(`${spec.containerName}: ${String(error).replace(/^Error: /, "")}`);
976-
continue;
977-
}
978-
}
979-
usedPorts.add(hostPort);
1152+
for (const entry of plannedEntries) {
1153+
const previousEntry = previousByKey.get(entry.key);
9801154

981-
if (previousEntry && previousEntry.deviceName !== spec.deviceName) {
1155+
if (previousEntry && previousEntry.deviceName !== entry.deviceName) {
9821156
const removeError = await removeLxdProxyDevice(previousEntry);
9831157
if (removeError) {
9841158
errors.push(removeError);
9851159
continue;
9861160
}
9871161
}
9881162

989-
const entry: ProxyBackendStateEntry = { ...spec, hostPort };
9901163
const deviceError = await ensureLxdProxyDevice(entry);
9911164
if (deviceError) {
9921165
errors.push(deviceError);

0 commit comments

Comments
 (0)