9b762c146f7a44a3bdc783cdd202356fc163b8fb
braney
  Wed Sep 16 12:43:32 2026 -0700
docent: one targetConf.js for the target, the hg.conf, the central and the account, refs #37892

docent.js and tests/preflight.js had grown a second and then a third copy of the
same lookups.  That is exactly the pair that must not drift: preflight checks the
fixtures for the server the RUN will drive, so a run that resolved its target, its
hg.conf, its hgcentral or its account even slightly differently would be checked
against the wrong machine, and the mismatch would show up as a green preflight in
front of a red suite.

targetConf.js answers the four questions in order, each from the one before:

resolveTarget   a `target:` (or DOCENT_TARGET) -> the .../cgi-bin URL to drive
hgConfFor       that URL -> the hg.conf it reads, if the server is on this box
centralDbFor    that conf -> which hgcentral it uses
loginLookup     that central -> the account to sign in with

Nothing in it opens a browser or the network, which is why preflight can ask all
four in the seconds before a run.

loginLookup returns the facts plus, when the account cannot be used, ONE sentence
saying why.  That sentence is the substantive half and is now written once: the
step throws it with a `login:` prefix, preflight prints it on the MISSING line.
Each caller still phrases its own success line, since one logs and the other
prints a fixture row.  No password crosses that boundary to anything that prints.

248 lines net out of the two programs, 197 into the module.

docent.js is no longer a single file, and three places now say so: its own require,
the Run section of README.md, and docent.mk, whose mp4 rule gains targetConf.js as
a prerequisite -- a change there changes what a tour renders, so it has to rebuild
one.  Nothing in the tree or in ~/docentTours copies docent.js; they all reference
it where it sits, with targetConf.js beside it.

Measured before and after, with no other change: preflight resolves the same
account, central and conf for genome-test, hgwdev-braney, a ts park on 48087 and
hgwbeta (which correctly has no section); tests/ is 18 of 18 with the derive
baselines matching; tests/regress preflights 14 fixtures for 67 scripts.

diff --git src/hg/utils/docent/targetConf.js src/hg/utils/docent/targetConf.js
new file mode 100644
index 00000000000..3443057b4f0
--- /dev/null
+++ src/hg/utils/docent/targetConf.js
@@ -0,0 +1,197 @@
+/* targetConf.js -- where a Docent run is pointed, and what that server is configured like.
+ *
+ * Required by docent.js and by tests/preflight.js. It exists because those two have to
+ * agree: preflight checks the fixtures for the server the run will actually drive, and a
+ * run that resolved its target, its hg.conf or its account even slightly differently
+ * would be checked against the wrong machine. They used to carry a copy each.
+ *
+ * Four questions, in order, each answered from the one before:
+ *
+ *   resolveTarget   a `target:` (or DOCENT_TARGET) -> the .../cgi-bin URL to drive
+ *   hgConfFor       that URL -> the hg.conf it reads, when the server is on this machine
+ *   centralDbFor    that conf -> which hgcentral it uses
+ *   loginLookup     that central -> the account to sign in with
+ *
+ * Nothing here opens a browser or the network: it is file reading and string work, which
+ * is why preflight can ask all of it in the seconds before a run.
+ */
+'use strict';
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+// `target:` takes a shorthand from this table, a bare `hgwdev-<user>` sandbox name, or a
+// full https://.../cgi-bin URL. Default is genome-test, so a script that forgets to say
+// where it runs does not silently hit someone's sandbox.
+const SERVERS = {
+  'rr': 'https://genome.ucsc.edu/cgi-bin',
+  'genome-test': 'https://genome-test.gi.ucsc.edu/cgi-bin',
+  'hgwdev': 'https://hgwdev.gi.ucsc.edu/cgi-bin',
+  'hgwbeta': 'https://hgwbeta.soe.ucsc.edu/cgi-bin',
+};
+
+function resolveTarget(t) {
+  if (!t) return SERVERS['genome-test'];
+  if (SERVERS[t]) return SERVERS[t];
+  if (/^hgwdev-[a-z0-9._-]+$/i.test(t)) return `https://${t}.gi.ucsc.edu/cgi-bin`;  // sandbox
+  return t;                                                                         // full URL
+}
+
+// The server a run drives: DOCENT_TARGET wins over the script's own `target:`, so a suite
+// written against one server can be pointed at another without editing the scripts.
+function serverFor(target) {
+  return resolveTarget(process.env.DOCENT_TARGET || target).replace(/\/$/, '');
+}
+
+// Which hg.conf that server reads, when it runs on THIS machine. There is no way to ask a
+// browser over http what its hg.conf says, so this is a lookup by convention rather than a
+// measurement, and it answers null for anything off this host -- hgwbeta, the RR, a
+// colleague's machine.
+function hgConfFor(server) {
+  let u;
+  try { u = new URL(server); } catch (e) { return null; }
+  const host = u.hostname;                            // 127.0.0.1 keeps its dots
+  const name = host.split('.')[0];                    // hgwdev-braney out of the FQDN
+  if (name === 'hgwdev' || name === 'genome-test') return '/usr/local/apache/cgi-bin/hg.conf';
+  if (name.startsWith('hgwdev-'))                     // a sandbox or a demo browser
+    return `/usr/local/apache/cgi-bin-${name.slice('hgwdev-'.length)}/hg.conf`;
+  if ((host === '127.0.0.1' || host === 'localhost') && u.port) {
+    // A ticket park from `ts`: its port is in the registry, and the frozen hg.conf sits
+    // under the ticket's own directory. A park is the one target whose conf is NOT the
+    // live sandbox's, which is the whole reason for parking it.
+    const root = process.env.TS_ROOT || path.join(os.homedir(), 'ticketSandboxes');
+    let reg;
+    try { reg = fs.readFileSync(path.join(root, 'ports.tsv'), 'utf8'); } catch (e) { return null; }
+    for (const line of reg.split('\n')) {
+      const f = line.split('\t');
+      if (f[1] === u.port) return path.join(root, f[0], 'cgi-bin', 'hg.conf');
+    }
+  }
+  return null;
+}
+
+// hg.conf as hg/lib/hgConfig.c reads it: `include` pulls another file in relative to the
+// including one, `delete` drops a name, and a later assignment wins over an earlier one
+// (parseConfigLine hashAdds and cfgOption reads the most recently added). So the value
+// this returns is the EFFECTIVE one -- a sandbox conf that sets nothing still shows what
+// it inherits from the shared conf it includes.
+//
+// Callers print only the settings they name. The includes lead to hg.conf.private, which
+// holds database passwords.
+function readHgConf(file, out = new Map(), seen = new Set(), depth = 0) {
+  if (depth > 10 || seen.has(file)) return out;
+  seen.add(file);
+  let text;
+  try { text = fs.readFileSync(file, 'utf8'); } catch (e) { return out; }
+  for (const raw of text.split('\n')) {
+    const line = raw.trim();
+    if (!line || line.startsWith('#')) continue;
+    if (/^include\s/.test(line))
+      readHgConf(path.resolve(path.dirname(file), line.slice(7).trim()), out, seen, depth + 1);
+    else if (/^delete\s/.test(line))
+      for (const name of line.slice(6).trim().split(/\s+/)) out.delete(name);
+    else {
+      const eq = line.indexOf('=');
+      if (eq > 0) out.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
+    }
+  }
+  return out;
+}
+
+// Servers whose hg.conf is on another machine, so it cannot be read and has to be known.
+const CENTRAL_BY_HOST = {
+  'genome.ucsc.edu': 'hgcentral',
+  'genome-euro.ucsc.edu': 'hgcentral',         // its own database of the same name
+  'genome-asia.ucsc.edu': 'hgcentral',         // ... and so is this one
+  'hgwbeta.soe.ucsc.edu': 'hgcentralbeta',
+};
+
+// Which hgcentral a server reads. Read from its hg.conf wherever that is possible, because
+// a sandbox may say so for itself: of the personal confs on hgwdev today, 45 set
+// central.db to hgcentraltest and two do not (hgcentralgsid, hgcentralbeta).
+function centralDbFor(server) {
+  const file = hgConfFor(server);
+  if (file) {
+    const db = readHgConf(file).get('central.db');
+    if (db) return db;
+  }
+  try { return CENTRAL_BY_HOST[new URL(server).hostname] || null; } catch (e) { return null; }
+}
+
+// ---------- the account a `login:` step signs in with ----------
+// Keyed by hgcentral DATABASE, not by server: an account is a row in gbMembers in one of
+// them, the way a named session is. genome-test, hgwdev, every hgwdev-<name> sandbox and
+// every ticket park read hgcentraltest and share one account; hgwbeta reads hgcentralbeta
+// and the RR reads hgcentral.
+function loginFile() {
+  return process.env.DOCENT_LOGIN_FILE || path.join(os.homedir(), '.docentLogin');
+}
+
+// Parse the sectioned file into [{central, user, password}], in file order. Lines before
+// any [section] are ignored rather than treated as a default: an unsectioned file is one
+// written against the older single-account form, and silently using it everywhere is how
+// an hgcentraltest password would reach the RR.
+function loginSections(text) {
+  const out = [];
+  let cur = null;
+  for (const raw of text.split('\n')) {
+    const line = raw.trim();
+    if (!line || line.startsWith('#')) continue;
+    const sec = /^\[(.+)\]$/.exec(line);
+    if (sec) { cur = { central: sec[1].trim(), user: '', password: '' }; out.push(cur); continue; }
+    const eq = line.indexOf('=');
+    if (eq < 0 || !cur) continue;
+    const k = line.slice(0, eq).trim(), v = line.slice(eq + 1).trim();
+    if (k === 'user' || k === 'password') cur[k] = v;
+  }
+  return out;
+}
+
+// The one place that decides which account a server gets, so the run and the fixture check
+// cannot disagree about it. Returns the facts and, when it cannot be used, one sentence
+// saying why -- that sentence is the substantive half and is shared; each caller phrases
+// its own success line, since one logs and the other prints a fixture row.
+//
+//   { central, user, password, section, source, why }
+//
+// `section` is the heading it matched ('hgcentraltest', 'default'), `source` the file it
+// came from or 'the environment'. On failure user and password are null and `why` is set.
+// No password is returned to anything that prints, and none is ever logged.
+function loginLookup(server) {
+  const env = process.env;
+  const central = centralDbFor(server);
+  const where = central ? `central.db ${central}` : 'central.db unknown';
+  const fail = why => ({ central, user: null, password: null, section: null, source: null, why });
+  // A single-run override, for trying an account without writing it down. It applies to
+  // whatever server this run drives, which is why it wins over the file.
+  if (env.DOCENT_LOGIN_USER && env.DOCENT_LOGIN_PASSWORD)
+    return { central, user: env.DOCENT_LOGIN_USER, password: env.DOCENT_LOGIN_PASSWORD,
+             section: null, source: 'the environment', why: null };
+  const file = loginFile();
+  let text;
+  try { text = fs.readFileSync(file, 'utf8'); }
+  catch (e) {
+    return fail(`no ${file}, and no DOCENT_LOGIN_USER/DOCENT_LOGIN_PASSWORD. Write it with a `
+      + `[<hgcentral database>] section holding "user=" and "password=" lines (mode 0600)`);
+  }
+  // Refuse a file anyone else can read, the way hg/lib/hgConfig.c checkConfigPerms refuses
+  // a group- or world-readable hg.conf. A test that quietly used a readable password file
+  // would make one on every machine it ran on.
+  const perm = fs.statSync(file).mode & 0o777;
+  if (perm & 0o077)
+    return fail(`${file} is readable by group or other (mode ${perm.toString(8)}); chmod 600 it`);
+  const secs = loginSections(text);
+  const match = (central && secs.find(x => x.central === central))
+             || secs.find(x => x.central === 'default');
+  if (!match)
+    return fail(`${file} has no section for ${where}`
+      + (secs.length ? ` (it has ${secs.map(x => `[${x.central}]`).join(' ')})` : ' (it has no [section] at all)')
+      + '; add one, or a [default]');
+  if (!match.user || !match.password)
+    return fail(`[${match.central}] in ${file} needs a "user=" and a "password=" line`);
+  return { central, user: match.user, password: match.password,
+           section: match.central, source: file, why: null };
+}
+
+module.exports = { SERVERS, resolveTarget, serverFor, hgConfFor, readHgConf,
+                   CENTRAL_BY_HOST, centralDbFor, loginFile, loginSections, loginLookup };