feat: Implement load balancing and health check features for service groups, including DNS-based load balancing modes and health check configurations, enhancing service reliability and performance monitoring
Build, Test, and Push CFDM Docker Image / test (push) Successful in 4m0s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Successful in 2m13s
Build, Test, and Push CFDM Docker Image / update-wiki (push) Failing after 6s
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped

This commit is contained in:
Denozordec
2026-06-25 18:24:00 +07:00
parent e5dc483d43
commit 6a1498bf80
35 changed files with 5312 additions and 281 deletions
+1177 -5
View File
File diff suppressed because it is too large Load Diff
+251 -26
View File
@@ -30,6 +30,8 @@ var services = sqliteTable("services", {
subdomain: text("subdomain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
sort_order: integer("sort_order").notNull().default(0),
lb_weight: integer("lb_weight").notNull().default(1),
lb_priority: integer("lb_priority").notNull().default(1),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -40,6 +42,14 @@ var serviceGroups = sqliteTable("service_groups", {
icon: text("icon"),
domain: text("domain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -91,6 +101,14 @@ var serviceBindings = sqliteTable("service_bindings", {
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
onDelete: "set null"
}),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" }).notNull().default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -112,7 +130,9 @@ var serviceBindingIps = sqliteTable(
"service_binding_ips",
{
binding_id: integer("binding_id").notNull().references(() => serviceBindings.id, { onDelete: "cascade" }),
ip: text("ip").notNull()
ip: text("ip").notNull(),
weight: integer("weight").notNull().default(1),
priority: integer("priority").notNull().default(1)
},
(t) => [primaryKey({ columns: [t.binding_id, t.ip] })]
);
@@ -148,6 +168,22 @@ var syncJobs = sqliteTable("sync_jobs", {
created_at: text("created_at").notNull().default(sql`datetime('now')`),
finished_at: text("finished_at")
});
var ipHealthStatus = sqliteTable(
"ip_health_status",
{
scope: text("scope").notNull(),
ref_id: integer("ref_id").notNull(),
ip: text("ip").notNull(),
status: text("status").notNull().default("unknown"),
latency_ms: integer("latency_ms"),
consecutive_failures: integer("consecutive_failures").notNull().default(0),
last_checked_at: text("last_checked_at"),
last_error: text("last_error"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
},
(t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })]
);
var schema = {
groups,
services,
@@ -161,7 +197,8 @@ var schema = {
serviceBindingIps,
serviceGroupDnsRecords,
certificates,
syncJobs
syncJobs,
ipHealthStatus
};
// src/client.ts
@@ -243,6 +280,8 @@ __export(repos_exports, {
deleteDnsRecord: () => deleteDnsRecord,
deleteDomain: () => deleteDomain,
deleteGroup: () => deleteGroup,
deleteIpHealthStatusForIp: () => deleteIpHealthStatusForIp,
deleteIpHealthStatusForRef: () => deleteIpHealthStatusForRef,
deleteService: () => deleteService,
deleteServiceGroup: () => deleteServiceGroup,
deleteSubdomain: () => deleteSubdomain,
@@ -258,6 +297,7 @@ __export(repos_exports, {
getDomain: () => getDomain,
getGroup: () => getGroup,
getGroupWithStats: () => getGroupWithStats,
getIpHealthStatusRow: () => getIpHealthStatusRow,
getService: () => getService,
getServiceGroup: () => getServiceGroup,
getSubdomain: () => getSubdomain,
@@ -270,6 +310,7 @@ __export(repos_exports, {
listAllDomains: () => listAllDomains,
listAllSubdomains: () => listAllSubdomains,
listBindingIps: () => listBindingIps,
listBindingIpsWithMeta: () => listBindingIpsWithMeta,
listBindingsByDomain: () => listBindingsByDomain,
listBindingsByService: () => listBindingsByService,
listCertificates: () => listCertificates,
@@ -279,6 +320,8 @@ __export(repos_exports, {
listDomainsEnriched: () => listDomainsEnriched,
listGroupDnsRecords: () => listGroupDnsRecords,
listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets,
listIpHealthStatus: () => listIpHealthStatus,
listRecordsForBinding: () => listRecordsForBinding,
listServiceGroups: () => listServiceGroups,
listServiceIps: () => listServiceIps,
@@ -289,6 +332,7 @@ __export(repos_exports, {
markDnsPendingDelete: () => markDnsPendingDelete,
reorderServices: () => reorderServices,
replaceBindingIps: () => replaceBindingIps,
replaceBindingIpsWithMeta: () => replaceBindingIpsWithMeta,
replaceServiceIps: () => replaceServiceIps,
setBindingCnameTarget: () => setBindingCnameTarget,
setBindingDnsRecordId: () => setBindingDnsRecordId,
@@ -297,9 +341,11 @@ __export(repos_exports, {
setServiceEnabled: () => setServiceEnabled,
setServiceGroup: () => setServiceGroup,
setServiceGroupEnabled: () => setServiceGroupEnabled,
setServiceLb: () => setServiceLb,
unlinkBindingRecord: () => unlinkBindingRecord,
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
updateBindingFields: () => updateBindingFields,
updateBindingLbConfig: () => updateBindingLbConfig,
updateDnsFields: () => updateDnsFields,
updateDomain: () => updateDomain,
updateGroup: () => updateGroup,
@@ -307,6 +353,7 @@ __export(repos_exports, {
updateServiceGroup: () => updateServiceGroup,
updateSubdomain: () => updateSubdomain,
upsertCertificateCheck: () => upsertCertificateCheck,
upsertIpHealthStatus: () => upsertIpHealthStatus,
upsertSubdomain: () => upsertSubdomain
});
import { dnsRecordNamesMatch } from "@cfdm/shared";
@@ -580,6 +627,13 @@ function setServiceEnabled(db, id, enabled) {
db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq(services.id, id)).run();
return getService(db, id);
}
function setServiceLb(db, id, weight, priority) {
db.update(services).set({
lb_weight: weight,
lb_priority: priority,
updated_at: sql2`datetime('now')`
}).where(eq(services.id, id)).run();
}
function setServiceGroup(db, id, groupId) {
const sortOrder = maxSortOrderInGroup(db, groupId) + 1;
db.update(services).set({
@@ -619,6 +673,14 @@ function mapServiceGroup(row) {
icon: row.icon,
domain: row.domain,
enabled: row.enabled,
lb_mode: row.lb_mode,
health_check_enabled: row.health_check_enabled,
health_check_type: row.health_check_type,
health_check_port: row.health_check_port,
health_check_path: row.health_check_path,
health_check_expected_status: row.health_check_expected_status,
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
created_at: row.created_at,
updated_at: row.updated_at
};
@@ -631,18 +693,49 @@ function getServiceGroup(db, id) {
if (!row) throw new NotFoundError(`service group ${id}`);
return mapServiceGroup(row);
}
function createServiceGroup(db, name, groupType, icon, domain) {
const id = db.insert(serviceGroups).values({ name, type: groupType, icon, domain }).returning({ id: serviceGroups.id }).get().id;
function createServiceGroup(db, name, groupType, icon, domain, lbPatch) {
const id = db.insert(serviceGroups).values({
name,
type: groupType,
icon,
domain,
lb_mode: lbPatch?.lb_mode ?? "round_robin",
health_check_enabled: lbPatch?.health_check_enabled ?? false,
health_check_type: lbPatch?.health_check_type ?? "tcp",
health_check_port: lbPatch?.health_check_port ?? null,
health_check_path: lbPatch?.health_check_path ?? null,
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3
}).returning({ id: serviceGroups.id }).get().id;
return getServiceGroup(db, id);
}
function updateServiceGroup(db, id, name, groupType, icon, domain) {
const result = db.update(serviceGroups).set({
function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
const update = {
name,
type: groupType,
icon,
domain,
updated_at: sql2`datetime('now')`
}).where(eq(serviceGroups.id, id)).run();
};
if (lbPatch) {
if (lbPatch.lb_mode !== void 0) update.lb_mode = lbPatch.lb_mode;
if (lbPatch.health_check_enabled !== void 0)
update.health_check_enabled = lbPatch.health_check_enabled;
if (lbPatch.health_check_type !== void 0)
update.health_check_type = lbPatch.health_check_type;
if (lbPatch.health_check_port !== void 0)
update.health_check_port = lbPatch.health_check_port;
if (lbPatch.health_check_path !== void 0)
update.health_check_path = lbPatch.health_check_path;
if (lbPatch.health_check_expected_status !== void 0)
update.health_check_expected_status = lbPatch.health_check_expected_status;
if (lbPatch.health_check_interval_sec !== void 0)
update.health_check_interval_sec = lbPatch.health_check_interval_sec;
if (lbPatch.health_check_timeout_ms !== void 0)
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
}
const result = db.update(serviceGroups).set(update).where(eq(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
return getServiceGroup(db, id);
}
@@ -667,12 +760,52 @@ function replaceServiceIps(db, serviceId, ips) {
function listBindingIps(db, bindingId) {
return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip);
}
function listBindingIpsWithMeta(db, bindingId) {
return db.select({
ip: serviceBindingIps.ip,
weight: serviceBindingIps.weight,
priority: serviceBindingIps.priority
}).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all();
}
function replaceBindingIps(db, bindingId, ips) {
replaceBindingIpsWithMeta(
db,
bindingId,
ips.map((ip) => ({ ip, weight: 1, priority: 1 }))
);
}
function replaceBindingIpsWithMeta(db, bindingId, entries) {
db.delete(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).run();
for (const ip of ips) {
db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run();
for (const entry of entries) {
db.insert(serviceBindingIps).values({
binding_id: bindingId,
ip: entry.ip,
weight: entry.weight,
priority: entry.priority
}).run();
}
}
function updateBindingLbConfig(db, bindingId, patch) {
const update = {
updated_at: sql2`datetime('now')`
};
if (patch.lb_mode !== void 0) update.lb_mode = patch.lb_mode;
if (patch.health_check_enabled !== void 0)
update.health_check_enabled = patch.health_check_enabled;
if (patch.health_check_type !== void 0)
update.health_check_type = patch.health_check_type;
if (patch.health_check_port !== void 0)
update.health_check_port = patch.health_check_port;
if (patch.health_check_path !== void 0)
update.health_check_path = patch.health_check_path;
if (patch.health_check_expected_status !== void 0)
update.health_check_expected_status = patch.health_check_expected_status;
if (patch.health_check_interval_sec !== void 0)
update.health_check_interval_sec = patch.health_check_interval_sec;
if (patch.health_check_timeout_ms !== void 0)
update.health_check_timeout_ms = patch.health_check_timeout_ms;
db.update(serviceBindings).set(update).where(eq(serviceBindings.id, bindingId)).run();
}
function setBindingCnameTarget(db, bindingId, target) {
db.update(serviceBindings).set({
cname_target: target,
@@ -726,13 +859,22 @@ function unlinkGroupDnsRecord(db, groupId, dnsRecordId) {
function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
return dnsRecordNamesMatch(recordName, hostname, zoneName);
}
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.cname_target,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at`;
function enrichServiceBindingView(db, row) {
const configured = listBindingIps(db, row.id);
const configured = listBindingIpsWithMeta(db, row.id);
const configuredIps = configured.map((c) => c.ip);
const linkedRecords = listRecordsForBinding(db, row.id);
const linkedIps = linkedRecords.filter((record) => record.record_type.toUpperCase() === "A").map((record) => record.content);
const target_ips = [
.../* @__PURE__ */ new Set([
...configured,
...configuredIps,
...linkedIps,
...row.target_ip ? [row.target_ip] : []
])
@@ -749,21 +891,29 @@ function enrichServiceBindingView(db, row) {
}
target_ips.sort();
}
const target_ip_weights = {};
const target_ip_priorities = {};
for (const entry of configured) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of target_ips) {
if (target_ip_weights[ip] === void 0) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === void 0) target_ip_priorities[ip] = 1;
}
const sync_status = row.sync_status ?? linkedRecords.find((record) => record.sync_status)?.sync_status ?? null;
return {
...row,
target_ips,
target_ip: target_ips[0] ?? null,
target_ip_weights,
target_ip_priorities,
sync_status
};
}
function listAllBindings(db) {
return db.all(sql2`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -774,11 +924,7 @@ function listAllBindings(db) {
}
function listBindingsByDomain(db, domainId) {
return db.all(sql2`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -802,11 +948,7 @@ function getBinding(db, id) {
}
function getBindingView(db, id) {
const rows = db.all(sql2`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
SELECT ${sql2.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -932,6 +1074,88 @@ function finishSyncJob(db, id, status, message) {
finished_at: sql2`datetime('now')`
}).where(eq(syncJobs.id, id)).run();
}
function listIpHealthStatus(db, scope, refId) {
return db.all(sql2`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId}
`);
}
function getIpHealthStatusRow(db, scope, refId, ip) {
const rows = db.all(sql2`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
LIMIT 1
`);
return rows[0] ?? null;
}
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError) {
db.run(sql2`
INSERT INTO ip_health_status
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error, created_at, updated_at)
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
datetime('now'), ${lastError}, datetime('now'), datetime('now'))
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
status = excluded.status,
latency_ms = excluded.latency_ms,
consecutive_failures = excluded.consecutive_failures,
last_checked_at = excluded.last_checked_at,
last_error = excluded.last_error,
updated_at = datetime('now')
`);
}
function deleteIpHealthStatusForRef(db, scope, refId) {
db.delete(ipHealthStatus).where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId)
)
).run();
}
function deleteIpHealthStatusForIp(db, scope, refId, ip) {
db.delete(ipHealthStatus).where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId),
eq(ipHealthStatus.ip, ip)
)
).run();
}
function listHealthCheckTargets(db) {
const bindingTargets = db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
sb.hostname AS hostname,
sb.health_check_type AS type,
sb.health_check_port AS port,
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
WHERE sb.health_check_enabled = 1
`);
const groupTargets = db.all(sql2`
SELECT 'group' AS scope, sg.id AS ref_id, sip.ip,
sg.domain AS hostname,
sg.health_check_type AS type,
sg.health_check_port AS port,
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms
FROM services s
JOIN service_ips sip ON sip.service_id = s.id
JOIN service_groups sg ON sg.id = s.service_group_id
WHERE sg.health_check_enabled = 1
AND sg.domain IS NOT NULL
AND s.enabled = 1
AND (sg.enabled = 1)
`);
return [...bindingTargets, ...groupTargets];
}
export {
ConflictError,
NotFoundError,
@@ -942,6 +1166,7 @@ export {
domains,
groups,
healthCheck,
ipHealthStatus,
repos_exports as repos,
resolveDatabasePath,
runMigrations,
@@ -0,0 +1,39 @@
ALTER TABLE services ADD COLUMN lb_weight INTEGER NOT NULL DEFAULT 1;
ALTER TABLE services ADD COLUMN lb_priority INTEGER NOT NULL DEFAULT 1;
ALTER TABLE service_groups ADD COLUMN lb_mode TEXT NOT NULL DEFAULT 'round_robin';
ALTER TABLE service_groups ADD COLUMN health_check_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE service_groups ADD COLUMN health_check_type TEXT NOT NULL DEFAULT 'tcp';
ALTER TABLE service_groups ADD COLUMN health_check_port INTEGER;
ALTER TABLE service_groups ADD COLUMN health_check_path TEXT;
ALTER TABLE service_groups ADD COLUMN health_check_expected_status INTEGER;
ALTER TABLE service_groups ADD COLUMN health_check_interval_sec INTEGER NOT NULL DEFAULT 30;
ALTER TABLE service_groups ADD COLUMN health_check_timeout_ms INTEGER NOT NULL DEFAULT 3000;
ALTER TABLE service_bindings ADD COLUMN lb_mode TEXT NOT NULL DEFAULT 'round_robin';
ALTER TABLE service_bindings ADD COLUMN health_check_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE service_bindings ADD COLUMN health_check_type TEXT NOT NULL DEFAULT 'tcp';
ALTER TABLE service_bindings ADD COLUMN health_check_port INTEGER;
ALTER TABLE service_bindings ADD COLUMN health_check_path TEXT;
ALTER TABLE service_bindings ADD COLUMN health_check_expected_status INTEGER;
ALTER TABLE service_bindings ADD COLUMN health_check_interval_sec INTEGER NOT NULL DEFAULT 30;
ALTER TABLE service_bindings ADD COLUMN health_check_timeout_ms INTEGER NOT NULL DEFAULT 3000;
ALTER TABLE service_binding_ips ADD COLUMN weight INTEGER NOT NULL DEFAULT 1;
ALTER TABLE service_binding_ips ADD COLUMN priority INTEGER NOT NULL DEFAULT 1;
CREATE TABLE ip_health_status (
scope TEXT NOT NULL,
ref_id INTEGER NOT NULL,
ip TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'unknown',
latency_ms INTEGER,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_checked_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (scope, ref_id, ip)
);
CREATE INDEX idx_ip_health_status_scope_ref ON ip_health_status(scope, ref_id);
+324 -31
View File
@@ -5,6 +5,11 @@ import type {
DomainListItem,
Group,
GroupWithStats,
HealthCheckScope,
HealthCheckTarget,
HealthCheckType,
IpHealthStatus,
LbMode,
Service,
ServiceBinding,
ServiceBindingView,
@@ -21,6 +26,7 @@ import {
dnsRecords,
domains,
groups,
ipHealthStatus,
serviceBindingIps,
serviceBindingRecords,
serviceBindings,
@@ -578,6 +584,22 @@ export function setServiceEnabled(
return getService(db, id);
}
export function setServiceLb(
db: Db,
id: number,
weight: number,
priority: number,
): void {
db.update(services)
.set({
lb_weight: weight,
lb_priority: priority,
updated_at: sql`datetime('now')`,
})
.where(eq(services.id, id))
.run();
}
export function setServiceGroup(
db: Db,
id: number,
@@ -648,6 +670,14 @@ function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
icon: row.icon,
domain: row.domain,
enabled: row.enabled,
lb_mode: row.lb_mode as LbMode,
health_check_enabled: row.health_check_enabled,
health_check_type: row.health_check_type as HealthCheckType,
health_check_port: row.health_check_port,
health_check_path: row.health_check_path,
health_check_expected_status: row.health_check_expected_status,
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
created_at: row.created_at,
updated_at: row.updated_at,
};
@@ -672,16 +702,41 @@ export function getServiceGroup(db: Db, id: number): ServiceGroup {
return mapServiceGroup(row);
}
export interface ServiceGroupLbPatch {
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export function createServiceGroup(
db: Db,
name: string,
groupType: string,
icon: string | null,
domain: string | null,
lbPatch?: ServiceGroupLbPatch,
): ServiceGroup {
const id = db
.insert(serviceGroups)
.values({ name, type: groupType, icon, domain })
.values({
name,
type: groupType,
icon,
domain,
lb_mode: lbPatch?.lb_mode ?? "round_robin",
health_check_enabled: lbPatch?.health_check_enabled ?? false,
health_check_type: lbPatch?.health_check_type ?? "tcp",
health_check_port: lbPatch?.health_check_port ?? null,
health_check_path: lbPatch?.health_check_path ?? null,
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000,
})
.returning({ id: serviceGroups.id })
.get()!.id;
return getServiceGroup(db, id);
@@ -694,16 +749,35 @@ export function updateServiceGroup(
groupType: string,
icon: string | null,
domain: string | null,
lbPatch?: ServiceGroupLbPatch,
): ServiceGroup {
const update: Record<string, unknown> = {
name,
type: groupType,
icon,
domain,
updated_at: sql`datetime('now')`,
};
if (lbPatch) {
if (lbPatch.lb_mode !== undefined) update.lb_mode = lbPatch.lb_mode;
if (lbPatch.health_check_enabled !== undefined)
update.health_check_enabled = lbPatch.health_check_enabled;
if (lbPatch.health_check_type !== undefined)
update.health_check_type = lbPatch.health_check_type;
if (lbPatch.health_check_port !== undefined)
update.health_check_port = lbPatch.health_check_port;
if (lbPatch.health_check_path !== undefined)
update.health_check_path = lbPatch.health_check_path;
if (lbPatch.health_check_expected_status !== undefined)
update.health_check_expected_status = lbPatch.health_check_expected_status;
if (lbPatch.health_check_interval_sec !== undefined)
update.health_check_interval_sec = lbPatch.health_check_interval_sec;
if (lbPatch.health_check_timeout_ms !== undefined)
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
}
const result = db
.update(serviceGroups)
.set({
name,
type: groupType,
icon,
domain,
updated_at: sql`datetime('now')`,
})
.set(update)
.where(eq(serviceGroups.id, id))
.run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
@@ -753,6 +827,12 @@ export function replaceServiceIps(
// --- Service Binding IPs ---
export interface BindingIpMeta {
ip: string;
weight: number;
priority: number;
}
export function listBindingIps(db: Db, bindingId: number): string[] {
return db
.select({ ip: serviceBindingIps.ip })
@@ -762,19 +842,93 @@ export function listBindingIps(db: Db, bindingId: number): string[] {
.map((r) => r.ip);
}
export function listBindingIpsWithMeta(
db: Db,
bindingId: number,
): BindingIpMeta[] {
return db
.select({
ip: serviceBindingIps.ip,
weight: serviceBindingIps.weight,
priority: serviceBindingIps.priority,
})
.from(serviceBindingIps)
.where(eq(serviceBindingIps.binding_id, bindingId))
.all();
}
export function replaceBindingIps(
db: Db,
bindingId: number,
ips: string[],
): void {
replaceBindingIpsWithMeta(
db,
bindingId,
ips.map((ip) => ({ ip, weight: 1, priority: 1 })),
);
}
export function replaceBindingIpsWithMeta(
db: Db,
bindingId: number,
entries: BindingIpMeta[],
): void {
db.delete(serviceBindingIps)
.where(eq(serviceBindingIps.binding_id, bindingId))
.run();
for (const ip of ips) {
db.insert(serviceBindingIps).values({ binding_id: bindingId, ip }).run();
for (const entry of entries) {
db.insert(serviceBindingIps)
.values({
binding_id: bindingId,
ip: entry.ip,
weight: entry.weight,
priority: entry.priority,
})
.run();
}
}
export interface BindingLbPatch {
lb_mode?: LbMode;
health_check_enabled?: boolean;
health_check_type?: HealthCheckType;
health_check_port?: number | null;
health_check_path?: string | null;
health_check_expected_status?: number | null;
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
}
export function updateBindingLbConfig(
db: Db,
bindingId: number,
patch: BindingLbPatch,
): void {
const update: Record<string, unknown> = {
updated_at: sql`datetime('now')`,
};
if (patch.lb_mode !== undefined) update.lb_mode = patch.lb_mode;
if (patch.health_check_enabled !== undefined)
update.health_check_enabled = patch.health_check_enabled;
if (patch.health_check_type !== undefined)
update.health_check_type = patch.health_check_type;
if (patch.health_check_port !== undefined)
update.health_check_port = patch.health_check_port;
if (patch.health_check_path !== undefined)
update.health_check_path = patch.health_check_path;
if (patch.health_check_expected_status !== undefined)
update.health_check_expected_status = patch.health_check_expected_status;
if (patch.health_check_interval_sec !== undefined)
update.health_check_interval_sec = patch.health_check_interval_sec;
if (patch.health_check_timeout_ms !== undefined)
update.health_check_timeout_ms = patch.health_check_timeout_ms;
db.update(serviceBindings)
.set(update)
.where(eq(serviceBindings.id, bindingId))
.run();
}
export function setBindingCnameTarget(
db: Db,
bindingId: number,
@@ -873,11 +1027,23 @@ function dnsRecordMatchesHostname(
return dnsRecordNamesMatch(recordName, hostname, zoneName);
}
const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.cname_target,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at`;
function enrichServiceBindingView(
db: Db,
row: Omit<ServiceBindingView, "target_ips"> & { target_ips?: string[] },
row: Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities"> & {
target_ips?: string[];
},
): ServiceBindingView {
const configured = listBindingIps(db, row.id);
const configured = listBindingIpsWithMeta(db, row.id);
const configuredIps = configured.map((c) => c.ip);
const linkedRecords = listRecordsForBinding(db, row.id);
const linkedIps = linkedRecords
.filter((record) => record.record_type.toUpperCase() === "A")
@@ -885,7 +1051,7 @@ function enrichServiceBindingView(
const target_ips = [
...new Set([
...configured,
...configuredIps,
...linkedIps,
...(row.target_ip ? [row.target_ip] : []),
]),
@@ -904,6 +1070,17 @@ function enrichServiceBindingView(
target_ips.sort();
}
const target_ip_weights: Record<string, number> = {};
const target_ip_priorities: Record<string, number> = {};
for (const entry of configured) {
target_ip_weights[entry.ip] = entry.weight;
target_ip_priorities[entry.ip] = entry.priority;
}
for (const ip of target_ips) {
if (target_ip_weights[ip] === undefined) target_ip_weights[ip] = 1;
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
}
const sync_status =
row.sync_status ??
linkedRecords.find((record) => record.sync_status)?.sync_status ??
@@ -913,18 +1090,16 @@ function enrichServiceBindingView(
...row,
target_ips,
target_ip: target_ips[0] ?? null,
target_ip_weights,
target_ip_priorities,
sync_status,
};
}
export function listAllBindings(db: Db): ServiceBindingView[] {
return db
.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -937,12 +1112,8 @@ export function listAllBindings(db: Db): ServiceBindingView[] {
export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingView[] {
return db
.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -973,12 +1144,8 @@ export function getBinding(db: Db, id: number): ServiceBinding {
}
export function getBindingView(db: Db, id: number): ServiceBindingView {
const rows = db.all<Omit<ServiceBindingView, "target_ips">>(sql`
SELECT sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
sb.created_at, sb.updated_at
const rows = db.all<Omit<ServiceBindingView, "target_ips" | "target_ip_weights" | "target_ip_priorities">>(sql`
SELECT ${sql.raw(SERVICE_BINDING_SELECT_COLUMNS)}
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
LEFT JOIN groups g ON g.id = d.group_id
@@ -1225,3 +1392,129 @@ export function finishSyncJob(
.where(eq(syncJobs.id, id))
.run();
}
// --- IP Health Status ---
export function listIpHealthStatus(
db: Db,
scope: HealthCheckScope,
refId: number,
): IpHealthStatus[] {
return db
.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId}
`);
}
export function getIpHealthStatusRow(
db: Db,
scope: HealthCheckScope,
refId: number,
ip: string,
): IpHealthStatus | null {
const rows = db.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
LIMIT 1
`);
return rows[0] ?? null;
}
export function upsertIpHealthStatus(
db: Db,
scope: HealthCheckScope,
refId: number,
ip: string,
status: string,
latencyMs: number | null,
consecutiveFailures: number,
lastError: string | null,
): void {
db.run(sql`
INSERT INTO ip_health_status
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error, created_at, updated_at)
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
datetime('now'), ${lastError}, datetime('now'), datetime('now'))
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
status = excluded.status,
latency_ms = excluded.latency_ms,
consecutive_failures = excluded.consecutive_failures,
last_checked_at = excluded.last_checked_at,
last_error = excluded.last_error,
updated_at = datetime('now')
`);
}
export function deleteIpHealthStatusForRef(
db: Db,
scope: HealthCheckScope,
refId: number,
): void {
db.delete(ipHealthStatus)
.where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId),
),
)
.run();
}
export function deleteIpHealthStatusForIp(
db: Db,
scope: HealthCheckScope,
refId: number,
ip: string,
): void {
db.delete(ipHealthStatus)
.where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId),
eq(ipHealthStatus.ip, ip),
),
)
.run();
}
// --- Health Check Targets ---
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
const bindingTargets = db.all<HealthCheckTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
sb.hostname AS hostname,
sb.health_check_type AS type,
sb.health_check_port AS port,
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
WHERE sb.health_check_enabled = 1
`);
const groupTargets = db.all<HealthCheckTarget>(sql`
SELECT 'group' AS scope, sg.id AS ref_id, sip.ip,
sg.domain AS hostname,
sg.health_check_type AS type,
sg.health_check_port AS port,
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms
FROM services s
JOIN service_ips sip ON sip.service_id = s.id
JOIN service_groups sg ON sg.id = s.service_group_id
WHERE sg.health_check_enabled = 1
AND sg.domain IS NOT NULL
AND s.enabled = 1
AND (sg.enabled = 1)
`);
return [...bindingTargets, ...groupTargets];
}
+56
View File
@@ -29,6 +29,8 @@ export const services = sqliteTable("services", {
subdomain: text("subdomain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(false),
sort_order: integer("sort_order").notNull().default(0),
lb_weight: integer("lb_weight").notNull().default(1),
lb_priority: integer("lb_priority").notNull().default(1),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -44,6 +46,20 @@ export const serviceGroups = sqliteTable("service_groups", {
icon: text("icon"),
domain: text("domain"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" })
.notNull()
.default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec")
.notNull()
.default(30),
health_check_timeout_ms: integer("health_check_timeout_ms")
.notNull()
.default(3000),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -123,6 +139,20 @@ export const serviceBindings = sqliteTable("service_bindings", {
dns_record_id: integer("dns_record_id").references(() => dnsRecords.id, {
onDelete: "set null",
}),
lb_mode: text("lb_mode").notNull().default("round_robin"),
health_check_enabled: integer("health_check_enabled", { mode: "boolean" })
.notNull()
.default(false),
health_check_type: text("health_check_type").notNull().default("tcp"),
health_check_port: integer("health_check_port"),
health_check_path: text("health_check_path"),
health_check_expected_status: integer("health_check_expected_status"),
health_check_interval_sec: integer("health_check_interval_sec")
.notNull()
.default(30),
health_check_timeout_ms: integer("health_check_timeout_ms")
.notNull()
.default(3000),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -162,6 +192,8 @@ export const serviceBindingIps = sqliteTable(
.notNull()
.references(() => serviceBindings.id, { onDelete: "cascade" }),
ip: text("ip").notNull(),
weight: integer("weight").notNull().default(1),
priority: integer("priority").notNull().default(1),
},
(t) => [primaryKey({ columns: [t.binding_id, t.ip] })],
);
@@ -213,6 +245,29 @@ export const syncJobs = sqliteTable("sync_jobs", {
finished_at: text("finished_at"),
});
export const ipHealthStatus = sqliteTable(
"ip_health_status",
{
scope: text("scope").notNull(),
ref_id: integer("ref_id").notNull(),
ip: text("ip").notNull(),
status: text("status").notNull().default("unknown"),
latency_ms: integer("latency_ms"),
consecutive_failures: integer("consecutive_failures")
.notNull()
.default(0),
last_checked_at: text("last_checked_at"),
last_error: text("last_error"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
},
(t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })],
);
export const schema = {
groups,
services,
@@ -227,4 +282,5 @@ export const schema = {
serviceGroupDnsRecords,
certificates,
syncJobs,
ipHealthStatus,
};