@celilo/cli 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +1 -1
- package/CELILO_SUBSYSTEMS.md +2 -0
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +0 -4
- package/src/ansible/dependencies.test.ts +233 -289
- package/src/ansible/dependencies.ts +151 -83
- package/src/cli/commands/alerts-sweep.ts +14 -3
- package/src/cli/commands/machine-add.ts +0 -1
- package/src/cli/commands/machine-list.ts +10 -4
- package/src/cli/commands/machine-remove.ts +13 -7
- package/src/cli/commands/machine-status.ts +9 -11
- package/src/db/schema.ts +6 -4
- package/src/hooks/capability-loader.ts +6 -0
- package/src/hooks/define-hook.test.ts +4 -0
- package/src/hooks/types.ts +2 -1
- package/src/infrastructure/property-extractor.test.ts +0 -2
- package/src/manifest/contracts/v1.ts +19 -0
- package/src/manifest/schema.ts +1 -0
- package/src/services/alerting/inbound.test.ts +66 -0
- package/src/services/alerting/inbound.ts +35 -2
- package/src/services/alerting/sweep-runner.test.ts +5 -1
- package/src/services/alerting/sweep-runner.ts +14 -8
- package/src/services/aspect-runner.test.ts +0 -1
- package/src/services/audit/machines-reachable.test.ts +67 -8
- package/src/services/audit/machines-reachable.ts +18 -4
- package/src/services/deployed-systems.ts +31 -0
- package/src/services/fleet-checks.test.ts +232 -0
- package/src/services/fleet-checks.ts +275 -3
- package/src/services/infrastructure-selector.test.ts +0 -7
- package/src/services/infrastructure-selector.ts +24 -25
- package/src/services/infrastructure-variable-resolver.test.ts +0 -6
- package/src/services/infrastructure-variable-resolver.ts +0 -3
- package/src/services/machine-pool.test.ts +53 -85
- package/src/services/machine-pool.ts +68 -84
- package/src/services/machine-probe.test.ts +3 -4
- package/src/services/machine-probe.ts +2 -3
- package/src/services/module-deploy.ts +17 -39
- package/src/services/module-operations.test.ts +72 -1
- package/src/services/module-operations.ts +49 -10
- package/src/services/ssh-key-manager.test.ts +0 -10
- package/src/types/infrastructure.ts +11 -1
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { type Bus, describeError } from '@celilo/event-bus';
|
|
21
21
|
import { inArray } from 'drizzle-orm';
|
|
22
|
+
import { ProxmoxClient, type ProxmoxCredentials } from '../api-clients/proxmox';
|
|
22
23
|
import { getModuleStoragePath } from '../config/paths';
|
|
23
24
|
import { type DbClient, findMigrationsFolder } from '../db/client';
|
|
24
25
|
import { getMigrationStatus } from '../db/migration-status';
|
|
@@ -27,9 +28,8 @@ import { findSchemaDrift } from '../db/schema-introspection';
|
|
|
27
28
|
import { loadControlPlaneSubnet, resolveFirewallNatIp } from '../hooks/capability-loader';
|
|
28
29
|
import type { ModuleManifest } from '../manifest/schema';
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import { getModuleSystems } from './deployed-systems';
|
|
31
|
+
import { getServiceCredentials, listContainerServices } from './container-service';
|
|
32
|
+
import { getModuleSystems, listAllModuleSystems } from './deployed-systems';
|
|
33
33
|
import { listDnsInternalRecords } from './dns-internal-records';
|
|
34
34
|
import {
|
|
35
35
|
SUPERVISOR_SCOPES,
|
|
@@ -38,9 +38,13 @@ import {
|
|
|
38
38
|
readInstalledUnit,
|
|
39
39
|
unitMainPid,
|
|
40
40
|
} from './events-daemon';
|
|
41
|
+
import { probeMachines } from './machine-probe';
|
|
41
42
|
import { describePausedModule, listPausedModules } from './module-pause';
|
|
42
43
|
import { resolveSubscription } from './module-subscriptions';
|
|
43
44
|
|
|
45
|
+
/** The module that IS celilo's control plane. */
|
|
46
|
+
const CONTROL_PLANE_MODULE = 'celilo-mgmt';
|
|
47
|
+
|
|
44
48
|
/**
|
|
45
49
|
* Zones reachable from the operator's LAN. A celilo placement zone other
|
|
46
50
|
* than `internal` is firewall-segmented — an unmanaged LAN device has no
|
|
@@ -786,6 +790,12 @@ export async function checkServiceDns(db: DbClient): Promise<FleetFinding> {
|
|
|
786
790
|
export interface RunFleetChecksOptions {
|
|
787
791
|
now?: number;
|
|
788
792
|
installedCodeMtimeMs?: number | null;
|
|
793
|
+
/**
|
|
794
|
+
* Where the host-liveness verdict gets its facts. Injected so the check can
|
|
795
|
+
* be exercised without SSH or a Proxmox credential; defaults to the live
|
|
796
|
+
* fleet (`collectHostLiveness`).
|
|
797
|
+
*/
|
|
798
|
+
hostLiveness?: () => Promise<HostLivenessInputs>;
|
|
789
799
|
}
|
|
790
800
|
|
|
791
801
|
/**
|
|
@@ -901,11 +911,272 @@ export function checkPausedModules(db: DbClient): FleetFinding {
|
|
|
901
911
|
};
|
|
902
912
|
}
|
|
903
913
|
|
|
914
|
+
/** One module deployment, and the host it landed on. */
|
|
915
|
+
export interface HostPlacement {
|
|
916
|
+
moduleId: string;
|
|
917
|
+
/** The host's user-facing name — a pool hostname, or the container's. */
|
|
918
|
+
hostname: string;
|
|
919
|
+
infraType: 'machine' | 'container_service';
|
|
920
|
+
/** Proxmox VMID for a celilo-provisioned container; null for a pool machine. */
|
|
921
|
+
vmid: number | null;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Everything the liveness verdict is computed from, injected so the check is a
|
|
926
|
+
* pure function over data and needs neither SSH nor a Proxmox credential to
|
|
927
|
+
* test.
|
|
928
|
+
*
|
|
929
|
+
* ⚠️ Every source here is ALLOWED TO BE ABSENT, and absent is not "fine".
|
|
930
|
+
* A machine missing from `machines` was not probed; a node missing from `nodes`
|
|
931
|
+
* was not reported. Neither means the host is up, and neither means it is down.
|
|
932
|
+
* Conflating "I could not look" with "I looked and it was healthy" is the
|
|
933
|
+
* failure this whole check exists to end — doctor said OK-with-warnings while a
|
|
934
|
+
* node hosting two modules was offline.
|
|
935
|
+
*/
|
|
936
|
+
export interface HostLivenessInputs {
|
|
937
|
+
placements: HostPlacement[];
|
|
938
|
+
/** Machine-pool SSH probe results. A hostname absent here was NOT probed. */
|
|
939
|
+
machines: Array<{ hostname: string; reachable: boolean }>;
|
|
940
|
+
/** Proxmox node status. Empty when no container service is configured. */
|
|
941
|
+
nodes: Array<{ node: string; online: boolean }>;
|
|
942
|
+
/** VMID → node name, from `/cluster/resources`. Empty when unqueried. */
|
|
943
|
+
guestNodes: Array<{ vmid: number; node: string }>;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
type HostState = 'up' | 'down' | 'unknown';
|
|
947
|
+
|
|
948
|
+
interface HostVerdict {
|
|
949
|
+
host: string;
|
|
950
|
+
state: HostState;
|
|
951
|
+
/** Why the state could not be determined. Set only when state is 'unknown'. */
|
|
952
|
+
reason?: string;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function resolveHostState(placement: HostPlacement, inputs: HostLivenessInputs): HostVerdict {
|
|
956
|
+
if (placement.infraType === 'machine') {
|
|
957
|
+
const probe = inputs.machines.find((m) => m.hostname === placement.hostname);
|
|
958
|
+
if (!probe) {
|
|
959
|
+
return {
|
|
960
|
+
host: placement.hostname,
|
|
961
|
+
state: 'unknown',
|
|
962
|
+
// Either the SSH probe did not run at all, or this hostname is no
|
|
963
|
+
// longer in the machine pool — a stale `module_systems` row, which is
|
|
964
|
+
// its own defect and worth surfacing rather than rounding off.
|
|
965
|
+
reason: 'no probe result for this machine',
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
return { host: placement.hostname, state: probe.reachable ? 'up' : 'down' };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// A container's liveness is its NODE's liveness. The guest being stopped is a
|
|
972
|
+
// different condition with a different owner (`module pause --stop-infra`
|
|
973
|
+
// stops guests deliberately), so this deliberately reads the node only.
|
|
974
|
+
if (placement.vmid === null) {
|
|
975
|
+
return {
|
|
976
|
+
host: placement.hostname,
|
|
977
|
+
state: 'unknown',
|
|
978
|
+
reason: 'no VMID recorded — celilo has no liveness source for this provider',
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
const guest = inputs.guestNodes.find((g) => g.vmid === placement.vmid);
|
|
982
|
+
if (!guest) {
|
|
983
|
+
return {
|
|
984
|
+
host: placement.hostname,
|
|
985
|
+
state: 'unknown',
|
|
986
|
+
reason: `VMID ${placement.vmid} not present in the cluster's resources`,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
const node = inputs.nodes.find((n) => n.node === guest.node);
|
|
990
|
+
if (!node) {
|
|
991
|
+
return { host: guest.node, state: 'unknown', reason: 'the cluster reported no such node' };
|
|
992
|
+
}
|
|
993
|
+
return { host: guest.node, state: node.online ? 'up' : 'down' };
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Are the hosts this fleet's modules actually run on alive? (celilo#728)
|
|
998
|
+
*
|
|
999
|
+
* Every other `Fleet runtime` check is a control-plane concern — the
|
|
1000
|
+
* dispatcher, bus subscribers, capability-derived config, internal DNS. None of
|
|
1001
|
+
* them asked the most basic data-plane question, so `system doctor` reported
|
|
1002
|
+
* "OK with warnings" while a Proxmox node was OFFLINE with `celilo-apt-repo`
|
|
1003
|
+
* and `lunacycle` on it. The information was already in `proxmox node list`;
|
|
1004
|
+
* doctor simply never consulted it. It surfaced only because a release run got
|
|
1005
|
+
* an HTTP 502 from the apt repo that happened to live there — had nothing tried
|
|
1006
|
+
* to publish, the node could have stayed down indefinitely.
|
|
1007
|
+
*
|
|
1008
|
+
* A down host is a FAILURE, not a warning: it is strictly worse than the
|
|
1009
|
+
* conditions already reported as failures here, and every module on it is down
|
|
1010
|
+
* with it.
|
|
1011
|
+
*
|
|
1012
|
+
* A host celilo TRIED to verify and could not is a WARNING, and the reason is
|
|
1013
|
+
* named per host. This is not the same as "quiet because it might be fine": a
|
|
1014
|
+
* cluster that will not answer its own API is not obviously healthier than one
|
|
1015
|
+
* reporting a node offline, and the failure to answer may BE the outage this
|
|
1016
|
+
* check exists to catch. Reporting it as ok-with-a-note would rebuild the
|
|
1017
|
+
* defect one level down — a report reading healthy over something unmeasured.
|
|
1018
|
+
*
|
|
1019
|
+
* ⚠️ The thing that makes the warning safe to have is that it is not
|
|
1020
|
+
* permanent. A machine-only fleet produces NO unverified hosts at all: every
|
|
1021
|
+
* placement takes the probe path and resolves. The one standing source would be
|
|
1022
|
+
* a provider celilo cannot interrogate — today a DigitalOcean droplet, whose
|
|
1023
|
+
* client can verify the token but never reads droplet status. That is a gap to
|
|
1024
|
+
* close (its own change), not a reason to soften the signal here. A warning
|
|
1025
|
+
* that fires forever is what trains an operator to skim the whole report
|
|
1026
|
+
* (celilo#723, whose false positive was competing for attention in the very
|
|
1027
|
+
* output that missed the offline node) — so if this one ever becomes standing,
|
|
1028
|
+
* the fix is to teach celilo the missing provider, not to quieten it.
|
|
1029
|
+
*/
|
|
1030
|
+
export function checkHostLiveness(inputs: HostLivenessInputs): FleetFinding {
|
|
1031
|
+
const base = {
|
|
1032
|
+
id: 'host-liveness',
|
|
1033
|
+
title: 'The hosts running deployed modules are alive',
|
|
1034
|
+
autoFixable: false,
|
|
1035
|
+
} as const;
|
|
1036
|
+
|
|
1037
|
+
if (inputs.placements.length === 0) {
|
|
1038
|
+
return {
|
|
1039
|
+
...base,
|
|
1040
|
+
status: 'ok',
|
|
1041
|
+
summary: 'no modules are deployed to a host yet',
|
|
1042
|
+
detail: [],
|
|
1043
|
+
remediation: null,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
interface HostEntry {
|
|
1048
|
+
state: HostState;
|
|
1049
|
+
modules: Set<string>;
|
|
1050
|
+
reason?: string;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const modulesByHost = new Map<string, HostEntry>();
|
|
1054
|
+
for (const placement of inputs.placements) {
|
|
1055
|
+
const { host, state, reason } = resolveHostState(placement, inputs);
|
|
1056
|
+
const entry = modulesByHost.get(host) ?? { state, modules: new Set<string>(), reason };
|
|
1057
|
+
// A host resolved 'down' by any placement stays down — one authoritative
|
|
1058
|
+
// negative outranks an unknown from a sibling placement.
|
|
1059
|
+
if (state === 'down' || entry.state === 'unknown') {
|
|
1060
|
+
entry.state = state;
|
|
1061
|
+
entry.reason = reason;
|
|
1062
|
+
}
|
|
1063
|
+
entry.modules.add(placement.moduleId);
|
|
1064
|
+
modulesByHost.set(host, entry);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const describe = (host: string, e: HostEntry) => `${host}: ${[...e.modules].sort().join(', ')}`;
|
|
1068
|
+
const describeUnverified = (host: string, e: HostEntry) =>
|
|
1069
|
+
`unverified — ${describe(host, e)}${e.reason ? ` (${e.reason})` : ''}`;
|
|
1070
|
+
|
|
1071
|
+
const down = [...modulesByHost].filter(([, e]) => e.state === 'down');
|
|
1072
|
+
const unknown = [...modulesByHost].filter(([, e]) => e.state === 'unknown');
|
|
1073
|
+
const up = [...modulesByHost].filter(([, e]) => e.state === 'up');
|
|
1074
|
+
|
|
1075
|
+
if (down.length > 0) {
|
|
1076
|
+
const affected = down.reduce((n, [, e]) => n + e.modules.size, 0);
|
|
1077
|
+
return {
|
|
1078
|
+
...base,
|
|
1079
|
+
status: 'fail',
|
|
1080
|
+
summary: `${down.length} host(s) down, ${affected} module(s) unreachable: ${down
|
|
1081
|
+
.map(([host]) => host)
|
|
1082
|
+
.join(', ')}`,
|
|
1083
|
+
detail: [
|
|
1084
|
+
...down.map(([host, e]) => `DOWN ${describe(host, e)}`),
|
|
1085
|
+
...unknown.map(([host, e]) => describeUnverified(host, e)),
|
|
1086
|
+
'every module listed against a down host is down with it, whatever its own status says',
|
|
1087
|
+
],
|
|
1088
|
+
remediation:
|
|
1089
|
+
'bring the host back, then confirm with "celilo proxmox node list" (container services) or "celilo machine status <hostname>" (pool machines)',
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (unknown.length > 0) {
|
|
1094
|
+
// WARN, not ok-with-a-note. celilo tried and could not find out, and the
|
|
1095
|
+
// reason it could not may be the outage itself — a cluster that will not
|
|
1096
|
+
// answer its own API is not evidence of health. Reporting this quietly
|
|
1097
|
+
// would rebuild #728 one level down.
|
|
1098
|
+
const affected = unknown.reduce((n, [, e]) => n + e.modules.size, 0);
|
|
1099
|
+
return {
|
|
1100
|
+
...base,
|
|
1101
|
+
status: 'warn',
|
|
1102
|
+
summary: `${up.length} host(s) up, ${unknown.length} could not be verified (${affected} module(s))`,
|
|
1103
|
+
// Named and reasoned, never counted: "1 not verified" tells an operator
|
|
1104
|
+
// neither which host nor what to do about it.
|
|
1105
|
+
detail: unknown.map(([host, e]) => describeUnverified(host, e)),
|
|
1106
|
+
remediation:
|
|
1107
|
+
'check the host directly — "celilo proxmox node list" for a container service, "celilo machine status <hostname>" for a pool machine; a host celilo cannot reach is not a host known to be healthy',
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return {
|
|
1112
|
+
...base,
|
|
1113
|
+
status: 'ok',
|
|
1114
|
+
summary: `${up.length} host(s) up`,
|
|
1115
|
+
detail: [],
|
|
1116
|
+
remediation: null,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Read the liveness facts off the live fleet.
|
|
1122
|
+
*
|
|
1123
|
+
* Every source degrades to ABSENT rather than to a cheerful default. A Proxmox
|
|
1124
|
+
* cluster that cannot be reached, or a fleet with no container service at all,
|
|
1125
|
+
* contributes no node rows — and `checkHostLiveness` reads that as unverified,
|
|
1126
|
+
* never as healthy. That distinction is the whole point of the check.
|
|
1127
|
+
*/
|
|
1128
|
+
export async function collectHostLiveness(db: DbClient): Promise<HostLivenessInputs> {
|
|
1129
|
+
const placements: HostPlacement[] = listAllModuleSystems(db).map((s) => ({
|
|
1130
|
+
moduleId: s.moduleId,
|
|
1131
|
+
hostname: s.hostname,
|
|
1132
|
+
infraType: s.infraType,
|
|
1133
|
+
vmid: s.vmid ?? null,
|
|
1134
|
+
}));
|
|
1135
|
+
|
|
1136
|
+
// Nothing deployed — skip the probes entirely rather than SSH a fleet of none.
|
|
1137
|
+
if (placements.length === 0) {
|
|
1138
|
+
return { placements, machines: [], nodes: [], guestNodes: [] };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
let machines: HostLivenessInputs['machines'] = [];
|
|
1142
|
+
try {
|
|
1143
|
+
machines = (await probeMachines()).map((m) => ({
|
|
1144
|
+
hostname: m.hostname,
|
|
1145
|
+
reachable: m.reachable,
|
|
1146
|
+
}));
|
|
1147
|
+
} catch {
|
|
1148
|
+
// Leave it empty: unprobed, which reports as unverified rather than up.
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const nodes: HostLivenessInputs['nodes'] = [];
|
|
1152
|
+
const guestNodes: HostLivenessInputs['guestNodes'] = [];
|
|
1153
|
+
try {
|
|
1154
|
+
for (const service of await listContainerServices()) {
|
|
1155
|
+
if (service.providerName !== 'proxmox') continue;
|
|
1156
|
+
const creds = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
|
|
1157
|
+
const result = await new ProxmoxClient(creds).clusterResources();
|
|
1158
|
+
if (!result.success) continue;
|
|
1159
|
+
for (const row of result.data) {
|
|
1160
|
+
if (row.type === 'node' && row.node) {
|
|
1161
|
+
nodes.push({ node: row.node, online: row.status === 'online' });
|
|
1162
|
+
} else if (typeof row.vmid === 'number' && row.node) {
|
|
1163
|
+
guestNodes.push({ vmid: row.vmid, node: row.node });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
} catch {
|
|
1168
|
+
// Same rule: unreachable is unverified, not healthy.
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return { placements, machines, nodes, guestNodes };
|
|
1172
|
+
}
|
|
1173
|
+
|
|
904
1174
|
export async function runFleetChecks(
|
|
905
1175
|
bus: Bus,
|
|
906
1176
|
db: DbClient,
|
|
907
1177
|
opts: RunFleetChecksOptions = {},
|
|
908
1178
|
): Promise<FleetFinding[]> {
|
|
1179
|
+
const hostLiveness = opts.hostLiveness ?? (() => collectHostLiveness(db));
|
|
909
1180
|
return [
|
|
910
1181
|
checkSchemaDrift(db),
|
|
911
1182
|
checkDispatcher(bus, { now: opts.now, installedCodeMtimeMs: opts.installedCodeMtimeMs }),
|
|
@@ -913,6 +1184,7 @@ export async function runFleetChecks(
|
|
|
913
1184
|
checkCapabilityProviders(db),
|
|
914
1185
|
checkControlPlaneNetwork(db),
|
|
915
1186
|
checkPausedModules(db),
|
|
1187
|
+
checkHostLiveness(await hostLiveness()),
|
|
916
1188
|
await checkServiceDns(db),
|
|
917
1189
|
];
|
|
918
1190
|
}
|
|
@@ -100,7 +100,6 @@ describe('infrastructure-selector', () => {
|
|
|
100
100
|
hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
|
|
101
101
|
role: 'host',
|
|
102
102
|
interfaces: [],
|
|
103
|
-
assignedModuleIds: [],
|
|
104
103
|
});
|
|
105
104
|
|
|
106
105
|
const result = await selectInfrastructure(module);
|
|
@@ -143,7 +142,6 @@ describe('infrastructure-selector', () => {
|
|
|
143
142
|
hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
|
|
144
143
|
role: 'host',
|
|
145
144
|
interfaces: [],
|
|
146
|
-
assignedModuleIds: [],
|
|
147
145
|
});
|
|
148
146
|
|
|
149
147
|
const result = await selectInfrastructure(module);
|
|
@@ -308,7 +306,6 @@ describe('infrastructure-selector', () => {
|
|
|
308
306
|
hardware: { cpu_cores: 1, memory_mb: 4096, disk_gb: 128 },
|
|
309
307
|
role: 'host',
|
|
310
308
|
interfaces: [],
|
|
311
|
-
assignedModuleIds: [],
|
|
312
309
|
});
|
|
313
310
|
|
|
314
311
|
// Should throw InfrastructureError with resource details
|
|
@@ -350,7 +347,6 @@ describe('infrastructure-selector', () => {
|
|
|
350
347
|
hardware: { cpu_cores: 4, memory_mb: 1024, disk_gb: 128 },
|
|
351
348
|
role: 'host',
|
|
352
349
|
interfaces: [],
|
|
353
|
-
assignedModuleIds: [],
|
|
354
350
|
});
|
|
355
351
|
|
|
356
352
|
await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
|
|
@@ -390,7 +386,6 @@ describe('infrastructure-selector', () => {
|
|
|
390
386
|
hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 10 },
|
|
391
387
|
role: 'host',
|
|
392
388
|
interfaces: [],
|
|
393
|
-
assignedModuleIds: [],
|
|
394
389
|
});
|
|
395
390
|
|
|
396
391
|
await expect(selectInfrastructure(module)).rejects.toThrow(InfrastructureError);
|
|
@@ -430,7 +425,6 @@ describe('infrastructure-selector', () => {
|
|
|
430
425
|
hardware: { cpu_cores: 8, memory_mb: 16384, disk_gb: 256 },
|
|
431
426
|
role: 'host',
|
|
432
427
|
interfaces: [],
|
|
433
|
-
assignedModuleIds: [],
|
|
434
428
|
});
|
|
435
429
|
|
|
436
430
|
const result = await selectInfrastructure(module);
|
|
@@ -473,7 +467,6 @@ describe('infrastructure-selector', () => {
|
|
|
473
467
|
hardware: { cpu_cores: 4, memory_mb: 4096, disk_gb: 128 },
|
|
474
468
|
role: 'host',
|
|
475
469
|
interfaces: [],
|
|
476
|
-
assignedModuleIds: [],
|
|
477
470
|
});
|
|
478
471
|
|
|
479
472
|
const result = await selectInfrastructure(module);
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
ResourceRequirements,
|
|
8
8
|
} from '../types/infrastructure';
|
|
9
9
|
import { listContainerServices } from './container-service';
|
|
10
|
-
import {
|
|
10
|
+
import { getModulesOnMachine, listMachines } from './machine-pool';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Infrastructure selection error
|
|
@@ -49,25 +49,24 @@ function getResourceRequirements(module: Module): ResourceRequirements {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
52
|
+
* Is this machine big enough for the module at all?
|
|
53
|
+
*
|
|
54
|
+
* Deliberately NOT multi-tenant capacity accounting (celilo#773). It used to
|
|
55
|
+
* subtract an "already allocated" figure that was hard-coded to zero behind a
|
|
56
|
+
* TODO, so the subtraction never changed an answer and the check has always
|
|
57
|
+
* been exactly this comparison. Saying so is the honest version; a gate that
|
|
58
|
+
* cannot fail reads as protection that is not there (Rule 7.6).
|
|
59
|
+
*
|
|
60
|
+
* Real accounting needs a decision nobody has made — whether a module's draw is
|
|
61
|
+
* its manifest MINIMUM (`requires.system`) or its deployed size, which on a
|
|
62
|
+
* pool machine celilo does not own. Until then the occupancy filter, which IS
|
|
63
|
+
* now correct, is what keeps two modules off one box.
|
|
53
64
|
*/
|
|
54
|
-
|
|
55
|
-
machine: Machine,
|
|
56
|
-
requirements: ResourceRequirements,
|
|
57
|
-
): Promise<boolean> {
|
|
58
|
-
// Get already-allocated resources
|
|
59
|
-
const allocated = await getModuleResourcesOnMachine(machine.id);
|
|
60
|
-
|
|
61
|
-
// Calculate remaining capacity
|
|
62
|
-
const remainingCpu = machine.hardware.cpu_cores - allocated.cpu;
|
|
63
|
-
const remainingMemory = machine.hardware.memory_mb - allocated.memory;
|
|
64
|
-
const remainingDisk = machine.hardware.disk_gb - allocated.disk;
|
|
65
|
-
|
|
66
|
-
// Check if machine has enough remaining resources
|
|
65
|
+
function machineHasCapacity(machine: Machine, requirements: ResourceRequirements): boolean {
|
|
67
66
|
return (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
machine.hardware.cpu_cores >= requirements.cpu &&
|
|
68
|
+
machine.hardware.memory_mb >= requirements.memory &&
|
|
69
|
+
machine.hardware.disk_gb >= requirements.disk
|
|
71
70
|
);
|
|
72
71
|
}
|
|
73
72
|
|
|
@@ -181,11 +180,12 @@ export async function selectInfrastructure(module: Module): Promise<Infrastructu
|
|
|
181
180
|
continue;
|
|
182
181
|
}
|
|
183
182
|
// Skip machines already assigned to other modules
|
|
184
|
-
|
|
183
|
+
const occupants = getModulesOnMachine(machine.id);
|
|
184
|
+
if (occupants.length > 0 && !occupants.includes(moduleId)) {
|
|
185
185
|
rejections.push({
|
|
186
186
|
hostname: machine.hostname,
|
|
187
187
|
ipAddress: machine.ipAddress,
|
|
188
|
-
reason: `already assigned to module(s): ${
|
|
188
|
+
reason: `already assigned to module(s): ${occupants.join(', ')}`,
|
|
189
189
|
});
|
|
190
190
|
continue;
|
|
191
191
|
}
|
|
@@ -208,14 +208,13 @@ export async function selectInfrastructure(module: Module): Promise<Infrastructu
|
|
|
208
208
|
});
|
|
209
209
|
continue;
|
|
210
210
|
}
|
|
211
|
-
if (
|
|
211
|
+
if (machineHasCapacity(machine, requirements)) {
|
|
212
212
|
availableMachines.push(machine);
|
|
213
213
|
} else {
|
|
214
|
-
const allocated = await getModuleResourcesOnMachine(machine.id);
|
|
215
214
|
const shortfalls: string[] = [];
|
|
216
|
-
const remainingCpu = machine.hardware.cpu_cores
|
|
217
|
-
const remainingMemory = machine.hardware.memory_mb
|
|
218
|
-
const remainingDisk = machine.hardware.disk_gb
|
|
215
|
+
const remainingCpu = machine.hardware.cpu_cores;
|
|
216
|
+
const remainingMemory = machine.hardware.memory_mb;
|
|
217
|
+
const remainingDisk = machine.hardware.disk_gb;
|
|
219
218
|
|
|
220
219
|
if (remainingCpu < requirements.cpu) {
|
|
221
220
|
shortfalls.push(`CPU: ${remainingCpu} available, ${requirements.cpu} required`);
|
|
@@ -96,7 +96,6 @@ describe('resolveInfrastructureVariables - Machine Infrastructure', () => {
|
|
|
96
96
|
sshKeyEncrypted: 'encrypted-key',
|
|
97
97
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
98
98
|
zone: 'external',
|
|
99
|
-
assignedModuleIds: [],
|
|
100
99
|
});
|
|
101
100
|
|
|
102
101
|
// Create module infrastructure selection
|
|
@@ -169,7 +168,6 @@ describe('resolveInfrastructureVariables - Machine Infrastructure', () => {
|
|
|
169
168
|
sshKeyEncrypted: 'encrypted',
|
|
170
169
|
hardware: { cpu_cores: 1, memory_mb: 1024, disk_gb: 10 },
|
|
171
170
|
zone: 'internal',
|
|
172
|
-
assignedModuleIds: [],
|
|
173
171
|
});
|
|
174
172
|
|
|
175
173
|
await db.insert(moduleInfrastructure).values({
|
|
@@ -468,7 +466,6 @@ describe('resolveInfrastructureVariables - User Override', () => {
|
|
|
468
466
|
sshKeyEncrypted: 'encrypted',
|
|
469
467
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
470
468
|
zone: 'external',
|
|
471
|
-
assignedModuleIds: [],
|
|
472
469
|
});
|
|
473
470
|
|
|
474
471
|
await db.insert(moduleInfrastructure).values({
|
|
@@ -526,7 +523,6 @@ describe('resolveInfrastructureVariables - Required vs Optional', () => {
|
|
|
526
523
|
sshKeyEncrypted: 'encrypted',
|
|
527
524
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
528
525
|
zone: 'internal',
|
|
529
|
-
assignedModuleIds: [],
|
|
530
526
|
});
|
|
531
527
|
|
|
532
528
|
await db.insert(moduleInfrastructure).values({
|
|
@@ -583,7 +579,6 @@ describe('resolveInfrastructureVariables - Required vs Optional', () => {
|
|
|
583
579
|
sshKeyEncrypted: 'encrypted',
|
|
584
580
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
585
581
|
zone: 'internal',
|
|
586
|
-
assignedModuleIds: [],
|
|
587
582
|
});
|
|
588
583
|
|
|
589
584
|
await db.insert(moduleInfrastructure).values({
|
|
@@ -694,7 +689,6 @@ describe('resolveInfrastructureVariables - Edge Cases', () => {
|
|
|
694
689
|
sshKeyEncrypted: 'encrypted',
|
|
695
690
|
hardware: { cpu_cores: 2, memory_mb: 2048, disk_gb: 20 },
|
|
696
691
|
zone: 'internal',
|
|
697
|
-
assignedModuleIds: [],
|
|
698
692
|
});
|
|
699
693
|
|
|
700
694
|
await db.insert(moduleInfrastructure).values({
|
|
@@ -90,9 +90,6 @@ export async function resolveInfrastructureVariables(
|
|
|
90
90
|
hardware: machineRow.hardware || { cpu_cores: 0, memory_mb: 0, disk_gb: 0 },
|
|
91
91
|
role: (machineRow.role as Machine['role']) || 'host',
|
|
92
92
|
interfaces: (machineRow.interfaces || []) as Machine['interfaces'],
|
|
93
|
-
assignedModuleIds: Array.isArray(machineRow.assignedModuleIds)
|
|
94
|
-
? machineRow.assignedModuleIds
|
|
95
|
-
: [],
|
|
96
93
|
earmarkedModule: machineRow.earmarkedModule ?? undefined,
|
|
97
94
|
createdAt: new Date(machineRow.createdAt),
|
|
98
95
|
updatedAt: new Date(machineRow.updatedAt),
|