156 lines
13 KiB
JavaScript
156 lines
13 KiB
JavaScript
/** Arithmetic preflight only. No Docker/host access, changes or workload admission.
|
|
* Roles are operator-supplied labels, not authenticated service identities.
|
|
* Even a fitting budget cannot prove runtime <=2GB or permission to change limits. */
|
|
const MiB = 1024 * 1024
|
|
const roles = new Set(['backend', 'postgres', 'frontend', 'portal', 'proxy', 'agent', 'other'])
|
|
const singleton = new Set(['backend', 'postgres', 'frontend', 'portal'])
|
|
const integer = value => Number.isSafeInteger(value) && value >= 0
|
|
const exact = (value, fields) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
&& Object.keys(value).sort().join('|') === [...fields].sort().join('|')
|
|
const id = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
|
|
|
|
export function evaluateInstallationMemoryBudget(input) {
|
|
if (!exact(input, ['hostMemTotalBytes', 'osReserveBytes', 'observedContainerIds', 'containers'])
|
|
|| !integer(input.hostMemTotalBytes) || !integer(input.osReserveBytes)
|
|
|| !Array.isArray(input.observedContainerIds) || !Array.isArray(input.containers)
|
|
|| input.observedContainerIds.length > 128 || input.containers.length > 128) throw Error('Invalid bounded installation inventory')
|
|
if (input.observedContainerIds.some(value => !id(value)) || new Set(input.observedContainerIds).size !== input.observedContainerIds.length) throw Error('Invalid observed container set')
|
|
const seen = new Set(), counts = new Map(), reasons = new Set()
|
|
let configuredBytes = 0
|
|
for (const row of input.containers) {
|
|
if (!exact(row, ['id', 'role', 'configuredLimitBytes']) || !id(row.id) || !roles.has(row.role)
|
|
|| !integer(row.configuredLimitBytes) || seen.has(row.id)) throw Error('Invalid container budget')
|
|
seen.add(row.id); counts.set(row.role, (counts.get(row.role) ?? 0) + 1)
|
|
if (row.configuredLimitBytes === 0) reasons.add('UNBOUNDED_CONTAINER')
|
|
if (row.configuredLimitBytes > input.hostMemTotalBytes) reasons.add('CONTAINER_LIMIT_EXCEEDS_HOST')
|
|
if (!Number.isSafeInteger(configuredBytes + row.configuredLimitBytes)) throw Error('Container budget overflow')
|
|
configuredBytes += row.configuredLimitBytes
|
|
}
|
|
if (seen.size !== input.observedContainerIds.length || input.observedContainerIds.some(value => !seen.has(value))) reasons.add('INCOMPLETE_HOST_CONTAINER_INVENTORY')
|
|
for (const role of ['backend', 'postgres', 'frontend']) if (!counts.has(role)) reasons.add('REQUIRED_SERVICE_MISSING')
|
|
for (const role of singleton) if ((counts.get(role) ?? 0) > 1) reasons.add('DUPLICATE_APPLICATION_STACK')
|
|
if (input.hostMemTotalBytes === 0 || input.hostMemTotalBytes > 2 * 1024 * MiB) reasons.add('HOST_OUTSIDE_2GIB_TARGET')
|
|
if (input.osReserveBytes < 512 * MiB) reasons.add('OS_RESERVE_BELOW_CANDIDATE_FLOOR')
|
|
if (!Number.isSafeInteger(configuredBytes + input.osReserveBytes)) throw Error('Installation budget overflow')
|
|
const plannedBytes = configuredBytes + input.osReserveBytes
|
|
if (plannedBytes > input.hostMemTotalBytes) reasons.add('INSTALLATION_BUDGET_EXCEEDS_HOST')
|
|
return Object.freeze({ scope: 'arithmetic-preflight-only', budgetFits: reasons.size === 0,
|
|
wholeInstallation2GbAccepted: false, runtimeAcceptanceClaimed: false, changesAuthorized: false,
|
|
configuredContainerBytes: configuredBytes, osReserveBytes: input.osReserveBytes,
|
|
plannedBytes, remainingBytes: input.hostMemTotalBytes - plannedBytes,
|
|
observedContainers: input.observedContainerIds.length, reasons: Object.freeze([...reasons].sort()) })
|
|
}
|
|
|
|
/** Project the whitelist-only read-only inventory. Never substitute idle RSS or
|
|
* Docker's reported host ceiling for HostConfig.Memory. Labels/freshness are
|
|
* not authenticated here; this result must not be used as mutation authority. */
|
|
export function evaluateObservedInstallationBudget(inventory, roleById, osReserveBytes = 512 * MiB) {
|
|
if (!inventory || inventory.readOnly !== true || inventory.containerSetStable !== true
|
|
|| inventory.environmentRead !== false || inventory.serviceChanges !== false || inventory.newFixtureStarted !== false
|
|
|| !Array.isArray(inventory.containers) || inventory.containers.length > 128
|
|
|| !roleById || typeof roleById !== 'object' || Array.isArray(roleById)) throw Error('Invalid read-only installation inventory')
|
|
const containers = inventory.containers.map(row => {
|
|
if (!row || !id(row.id) || row.running !== true || !integer(row.memoryLimitBytes)
|
|
|| !/^sha256:[a-f0-9]{64}$/.test(row.imageId) || !Number.isFinite(Date.parse(row.startedAt))) throw Error('Incomplete configured-limit observation')
|
|
const label = Object.getOwnPropertyDescriptor(roleById, row.id)
|
|
if (!label || typeof label.value !== 'string' || !roles.has(label.value)) throw Error('Explicit container role missing')
|
|
return { id: row.id, role: label.value, configuredLimitBytes: row.memoryLimitBytes }
|
|
})
|
|
const observedContainerIds = containers.map(row => row.id)
|
|
if (Object.keys(roleById).length !== observedContainerIds.length || Object.keys(roleById).some(key => !observedContainerIds.includes(key))) throw Error('Foreign or incomplete role inventory')
|
|
const budget = evaluateInstallationMemoryBudget({ hostMemTotalBytes: inventory.hostMemTotalBytes, osReserveBytes, observedContainerIds, containers })
|
|
return Object.freeze({ ...budget, configuredLimitsSource: 'observed-HostConfig.Memory', roleLabelsAuthenticated: false, inventoryFreshnessProven: false })
|
|
}
|
|
|
|
/** NEW pure raw-text bridge for measure-memory schema2/3. This is NOT the
|
|
* separate GET-only inventory contract consumed by evaluateObserved above.
|
|
* No file/container/socket access or mutation authority. Parse raw text only:
|
|
* precision lost by a caller's previous JSON.parse cannot be recovered. */
|
|
export function evaluateMeasuredInstallationBudget(rawText, roleById, osReserveBytes = 512 * MiB) {
|
|
if (typeof rawText !== 'string') throw Error('Raw measurement JSON text required; preparsed precision cannot be recovered')
|
|
if (Buffer.byteLength(rawText, 'utf8') > 32 * 1024 * 1024) throw Error('Measurement text bound exceeded')
|
|
// A narrow numeric lexical gate BEFORE JSON.parse, not a JSON parser. The
|
|
// Python producer emits only integer JSON numbers (PSI averages are strings).
|
|
// Reject decimal/exponent/unsafe integers even where JSON.parse would round
|
|
// a fractional lexeme into a seemingly safe integer. JSON.parse checks syntax.
|
|
const number = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/y
|
|
let quoted = false, escaped = false
|
|
for (let index = 0; index < rawText.length; index++) {
|
|
const char = rawText[index]
|
|
if (quoted) { if (escaped) escaped = false; else if (char === '\\') escaped = true; else if (char === '"') quoted = false; continue }
|
|
if (char === '"') { quoted = true; continue }
|
|
if (char === '-' || char >= '0' && char <= '9') {
|
|
number.lastIndex = index; const token = number.exec(rawText)?.[0]
|
|
if (!token || token === '-0' || !/^-?(?:0|[1-9][0-9]*)$/.test(token) || token.replace('-', '').length > 16) throw Error('Unsafe or noninteger measurement JSON number')
|
|
const value = BigInt(token)
|
|
if (value < -9007199254740991n || value > 9007199254740991n) throw Error('Unsafe measurement JSON integer')
|
|
index = number.lastIndex - 1
|
|
}
|
|
}
|
|
const data = JSON.parse(rawText)
|
|
if (!data || ![2, 3].includes(data.schemaVersion) || data.readOnlyDockerAndProc !== true || data.serviceChanges !== 0
|
|
|| data.summary?.acceptancePassed !== false || !Array.isArray(data.observations) || !data.observations.length || data.observations.length > 300
|
|
|| !roleById || typeof roleById !== 'object' || Array.isArray(roleById)) throw Error('Unsupported or incomplete memory measurement')
|
|
const nonnegativeSafe = value => Number.isSafeInteger(value) && value >= 0
|
|
const limit = value => value === 'max' || nonnegativeSafe(value) ? value : null
|
|
const swap = row => row.configuredMemorySwapStatus === 'available' && Number.isSafeInteger(row.configuredMemorySwapBytes) && row.configuredMemorySwapBytes >= -1 ? row.configuredMemorySwapBytes : null
|
|
const cpu = value => value && ['nr_periods', 'nr_throttled', 'throttled_usec'].every(key => nonnegativeSafe(value[key]))
|
|
? Object.fromEntries(['nr_periods', 'nr_throttled', 'throttled_usec'].map(key => [key, value[key]])) : null
|
|
const pressure = host => {
|
|
if (host.memoryPressure?.status !== 'available') return null
|
|
const result = {}
|
|
for (const key of ['some', 'full']) {
|
|
const value = host.memoryPressure.metrics?.[key]
|
|
if (!value || !nonnegativeSafe(value.totalMicros)) return null
|
|
for (const avg of ['avg10Percent', 'avg60Percent', 'avg300Percent']) {
|
|
if (typeof value[avg] !== 'string' || !/^[0-9]{1,3}\.[0-9]{2}$/.test(value[avg]) || Number(value[avg]) > 100) return null
|
|
}
|
|
result[key] = Object.fromEntries(['avg10Percent', 'avg60Percent', 'avg300Percent', 'totalMicros'].map(name => [name, value[name]]))
|
|
}
|
|
return result
|
|
}
|
|
let baseline, hostMemory, priorElapsed = -1, complete = data.schemaVersion === 3
|
|
const extendedObservations = []
|
|
for (const observation of data.observations) {
|
|
if (!observation?.host || !nonnegativeSafe(observation.elapsedMs) || observation.elapsedMs < priorElapsed || observation.elapsedMs > 360000
|
|
|| !nonnegativeSafe(observation.host.MemTotalBytes) || !observation.host.MemTotalBytes
|
|
|| !Array.isArray(observation.containers) || !observation.containers.length || observation.containers.length > 16) throw Error('Incomplete bounded measurement sample')
|
|
priorElapsed = observation.elapsedMs
|
|
hostMemory ??= observation.host.MemTotalBytes
|
|
if (hostMemory !== observation.host.MemTotalBytes) throw Error('Measurement host memory changed')
|
|
const ids = new Set(), identities = [], extended = []
|
|
for (const row of observation.containers) {
|
|
if (!row || !id(row.id) || ids.has(row.id) || !/^sha256:[a-f0-9]{64}$/.test(row.imageId) || (typeof row.startedAt !== 'string' || !Number.isFinite(Date.parse(row.startedAt)))
|
|
|| row.running !== true || row.runningAfter !== true || row.identityStableDuringSample !== true || row.oomKilled !== false || row.oomKilledAfter !== false
|
|
|| !nonnegativeSafe(row.restartCount) || !Number.isSafeInteger(row.pid) || row.pid < 1 || row.pid > 2147483647 || !nonnegativeSafe(row.configuredLimitBytes)) throw Error('Unavailable measurement container identity or configured cap')
|
|
ids.add(row.id); identities.push([row.id, row.imageId, row.startedAt, row.pid, row.restartCount, row.configuredLimitBytes, data.schemaVersion === 3 ? swap(row) : null])
|
|
if (data.schemaVersion === 3) {
|
|
const metrics = row.cgroupV2?.metrics, memoryMax = limit(metrics?.['memory.max']), swapMax = limit(metrics?.['memory.swap.max'])
|
|
const cpuThrottling = cpu(metrics?.['cpu.stat']), configuredMemorySwapBytes = swap(row)
|
|
const available = row.cgroupV2?.status === 'available' && row.resourceLimitsStableDuringSample === true
|
|
&& memoryMax !== null && swapMax !== null && cpuThrottling !== null && configuredMemorySwapBytes !== null
|
|
complete &&= available
|
|
extended.push({id: row.id, memoryMax, swapMax, configuredMemorySwapBytes, cpuThrottling, available})
|
|
} else extended.push({id: row.id, memoryMax: null, swapMax: null, configuredMemorySwapBytes: null, cpuThrottling: null, available: false})
|
|
}
|
|
identities.sort((a, b) => a[0].localeCompare(b[0]))
|
|
const encoded = JSON.stringify(identities)
|
|
baseline ??= encoded
|
|
if (encoded !== baseline) throw Error('Measurement container set, identity or memory cap changed')
|
|
const memoryPressure = data.schemaVersion === 3 ? pressure(observation.host) : null
|
|
complete &&= memoryPressure !== null
|
|
extendedObservations.push({elapsedMs: observation.elapsedMs, memoryPressure, containers: extended})
|
|
}
|
|
const captured = JSON.parse(baseline)
|
|
if (Object.keys(roleById).length !== captured.length || Object.keys(roleById).some(key => !captured.some(row => row[0] === key))) throw Error('Incomplete or foreign measured role inventory')
|
|
const containers = captured.map(([identifier, _image, _start, _pid, _restart, configuredLimitBytes]) => {
|
|
const descriptor = Object.getOwnPropertyDescriptor(roleById, identifier)
|
|
if (!descriptor || typeof descriptor.value !== 'string' || !roles.has(descriptor.value)) throw Error('Explicit measured container role missing')
|
|
return {id: identifier, role: descriptor.value, configuredLimitBytes}
|
|
})
|
|
const budget = evaluateInstallationMemoryBudget({hostMemTotalBytes: hostMemory, osReserveBytes, observedContainerIds: containers.map(row => row.id), containers})
|
|
return Object.freeze({...budget, measurementSchemaVersion: data.schemaVersion, measurementSamples: data.observations.length,
|
|
extendedMetricsComplete: complete, extendedObservations: Object.freeze(extendedObservations),
|
|
roleLabelsAuthenticated: false, inventoryFreshnessProven: false, hostContainerInventoryComplete: false, measurementSelectedContainersOnly: true, noSwapAccepted: false, sourceGenerationAuthenticated: false})
|
|
}
|