Files
XC_VM/tools/ci/lb_settings_keys.php
T
obscuremind 5087631b70 fix(cluster): address the ReplicaStage and settings-miss review
SettingsAudit: a root process made the day directory with a recursive
mkdir and handed only the leaf to xc_vm. On an LB, which ships no
storage/, storage/ and storage/cluster/ then stayed root's 0750, and
xc_vm's hourly cron:cleanup rewrote audit.json from days it could not
read, erasing root's misses. It now makes each missing level itself and
hands every level and file it makes to the owner of the agent's
directory (xc_vm), `service` makes storage/cluster/ xc_vm's at boot, and
a publish from days the process cannot read leaves audit.json alone. A
merge rewrites audit.json only when it adds a key to the day or the
report is a minute old, so PHP-FPM requests do not queue on it.

The allowlist scan read only the first ADD of an ALTER TABLE `settings`.
Migration 016 adds three columns in one statement, so update_channel_bin
and update_channel_fanout were missing from the settings section and
counted as misses. Regenerated with both.

NodeAudit measured a heartbeat's audit with PHP's default encoding, which
escapes slashes, so an audit.json the agent may send (16384 bytes) could
be dropped. It now measures the shortest encoding.

ReplicaApply applies the whole sections before the blocklist, and from
disk no longer resolves RTMP publishers' names: at boot during a MAIN
outage DNS may not answer, and service's 15 s timeout would stop the
apply before the settings and servers caches exist. ReplicaRecords checks
the blocklist record's ETag format too.

Tests: ReplicaBootTest runs a crontab stand-in instead of a marker, so a
replica-booted process is seen writing the crontab from the replica's
jobs and --from-disk leaving it alone, and checks the web API's
WHEN_READY stage and its database boot before an apply. ReplicaFixture
builds records with ReplicaBuilder::record and FakeClusterCrypto, in
XcVm\Tests\Support. New cases cover the delta order and sorting, the
ETag, another section's record as the blocklist, unresolved RTMP names
from disk, the eighth day kept by prune, root-made levels and a publish
as another user, cron:cleanup's node-side calls before its MAIN gate,
and the audit bound.

The plan and ADR 0004 no longer claim a node rebooted during a MAIN
outage serves streams: it rebuilds its R1 caches, and stream definitions
wait for R2, now listed as not built with DbConnectRefusalTest. The
fifth and sixth increments' limits are in the past tense, and the
agent's audit contract states the 16 KiB bound is on audit.json's bytes.
2026-09-27 02:26:56 +00:00

131 lines
5.2 KiB
PHP

<?php
/**
* The settings a load balancer may receive in its replica (cluster plan,
* section 9, R1 `settings`): every settings column the LB build reads, and
* never a secret. Run by tools/ci/lb-settings-keys.sh with the LB build's PHP
* files (paths under src/) on stdin.
*
* --write regenerate src/Core/Cluster/lb_settings_keys.php
* (none) check it is current; exit 1 when it is not, or on an
* unannotated dynamic read
*
* What counts as a read, in a shipped file:
* - SettingsManager::get('key') (and getBool/getInt/getString);
* - any ['key'] index whose key is a settings column. Over-inclusive on
* purpose: a server row's column that shares a settings name costs a key,
* never a missing one.
* - a dynamic read (SettingsManager::get($x), getAll()[$x], $rSettings[$x],
* $settings[$x]) must carry `lb-settings: key, key` on its line or one of
* the two before it, naming the keys it can read.
*
* Secrets (SECRETS) are never in the list, whatever reads them: those the
* node needs travel in the sealed R1 `secrets` section instead.
*/
const SECRETS = [
'api_pass', 'dropbox_token', 'license', 'live_streaming_pass', 'maxmind_license_key',
'platform_api_key', 'recaptcha_v2_secret_key', 'redis_password', 'tmdb_api_key',
];
/** The accessors themselves, whose dynamic reads are their callers'. */
const ACCESSORS = ['Core/Config/SettingsManager.php', 'Core/Config/ConfigReader.php'];
$rRoot = dirname(__DIR__, 2);
$rWrite = in_array('--write', $argv, true);
$rTarget = $rRoot . '/src/Core/Cluster/lb_settings_keys.php';
// Every settings column: the install schema, and what migrations add.
$rColumns = [];
$rSql = (string) file_get_contents($rRoot . '/src/bin/install/database.sql');
if (preg_match('/CREATE TABLE IF NOT EXISTS `settings` \((.*?)\) ENGINE=/s', $rSql, $rM)) {
preg_match_all('/^\s*`([a-z0-9_]+)`/m', $rM[1], $rCols);
$rColumns = $rCols[1];
}
// Every ADD of each ALTER TABLE `settings` statement: one statement may add
// several columns.
foreach (glob($rRoot . '/src/migrations/database/up/*.sql') ?: [] as $rFile) {
preg_match_all('/ALTER TABLE `settings`\s(.*?);/is', (string) file_get_contents($rFile), $rAlters);
foreach ($rAlters[1] as $rAlter) {
preg_match_all('/\bADD(?:\s+COLUMN)?(?:\s+IF NOT EXISTS)?\s+`([a-z0-9_]+)`/i', $rAlter, $rCols);
$rColumns = array_merge($rColumns, $rCols[1]);
}
}
$rColumns = array_flip(array_unique($rColumns));
if (count($rColumns) < 100) {
fwrite(STDERR, "lb-settings-keys: could not read the settings columns from src/bin/install/database.sql\n");
exit(2);
}
$rFound = [];
$rErrors = [];
foreach (array_filter(array_map('trim', explode("\n", (string) stream_get_contents(STDIN)))) as $rRel) {
if (!str_ends_with($rRel, '.php') || str_starts_with($rRel, 'vendor/')) {
continue;
}
$rLines = file($rRoot . '/src/' . $rRel) ?: [];
foreach ($rLines as $i => $rLine) {
preg_match_all('/SettingsManager::get(?:Bool|Int|String)?\(\s*[\'"]([a-z0-9_]+)[\'"]/', $rLine, $rM);
preg_match_all('/\[\s*[\'"]([a-z0-9_]+)[\'"]\s*\]/', $rLine, $rN);
foreach (array_merge($rM[1], $rN[1]) as $rKey) {
if (isset($rColumns[$rKey])) {
$rFound[$rKey] = true;
}
}
if (in_array($rRel, ACCESSORS, true) || !preg_match('/SettingsManager::get(?:Bool|Int|String)?\(\s*\$|SettingsManager::getAll\(\)\[\s*\$|\$r?[Ss]ettings\[\s*\$/', $rLine)) {
continue;
}
$rNote = null;
for ($j = $i; $j >= max(0, $i - 2); $j--) {
if (preg_match('/lb-settings:\s*([a-z0-9_, ]+)/', $rLines[$j], $rA)) {
$rNote = $rA[1];
break;
}
}
if ($rNote === null) {
$rErrors[] = sprintf('%s:%d: dynamic settings read without an `lb-settings: key, …` note', $rRel, $i + 1);
continue;
}
foreach (array_filter(array_map('trim', explode(',', $rNote))) as $rKey) {
if (!isset($rColumns[$rKey])) {
$rErrors[] = sprintf('%s:%d: lb-settings names `%s`, which is not a settings column', $rRel, $i + 1, $rKey);
}
$rFound[$rKey] = true;
}
}
}
$rKeys = array_keys($rFound);
sort($rKeys);
$rWithheld = array_values(array_intersect($rKeys, SECRETS));
$rKeys = array_values(array_diff($rKeys, SECRETS));
$rBody = "<?php\n\n// Generated by tools/ci/lb-settings-keys.sh --write: do not edit by hand.\n"
. "// The settings the LB build reads, which a node's replica may carry\n"
. "// (cluster plan, section 9, R1 `settings`); `withheld` are secrets it reads\n"
. "// that never travel in this section.\n\nreturn [\n\t'keys' => [\n";
foreach ($rKeys as $rKey) {
$rBody .= "\t\t'" . $rKey . "',\n";
}
$rBody .= "\t],\n\t'withheld' => [\n";
foreach ($rWithheld as $rKey) {
$rBody .= "\t\t'" . $rKey . "',\n";
}
$rBody .= "\t],\n];\n";
foreach ($rErrors as $rError) {
fwrite(STDERR, 'lb-settings-keys: ' . $rError . "\n");
}
if ($rWrite) {
file_put_contents($rTarget, $rBody);
echo 'lb-settings-keys: wrote ' . count($rKeys) . ' keys (' . count($rWithheld) . " withheld)\n";
exit($rErrors === [] ? 0 : 1);
}
if ((string) @file_get_contents($rTarget) !== $rBody) {
fwrite(STDERR, "lb-settings-keys: src/Core/Cluster/lb_settings_keys.php is stale; run `bash tools/ci/lb-settings-keys.sh --write`\n");
exit(1);
}
if ($rErrors !== []) {
exit(1);
}
echo 'OK: ' . count($rKeys) . " LB settings keys, allowlist current, every dynamic read annotated.\n";