b55fc17d82
The guard owed since Friday morning. Agent v0.128.0 was published as a package and never git-tagged; it was vouched here; and because felhom-host-install.sh fetches an agent's configs from raw/tag/v<version>/configs/, every fresh install and reinstall died at step 5 of 8, as root, on a virgin machine, for most of a day. handleSetArtifacts is the sole UI path to SetArtifactManifest, so the check belongs here and nowhere else. TWO LEGS, because both failed inside two days: the TAG (missing, R-273) and the PACKAGE (pruned from under a still-tagged version, R-287). Either alone catches one of them. It asserts configs/felhom-mkfs-guarded.sh -- the FIRST of the installer's sixteen fetch_raw calls and literally the file whose 404 broke Friday. A test pins the constant, because probing a path that merely exists is how it stayed invisible. The golden gets the package leg only: it has no config tree, so a tag probe would assert something the installer never does. "Could not verify" refuses too, with its own message. No override -- the registry is the operator's own server, so if it is unreachable the vouch can wait. ORDERING IS LOAD-BEARING AND A FAILING TEST FOUND IT. The probes run before resolveArtifactSHA, whose flash conflates "missing", "unreachable" and "bad sha". Probing first means an unreachable registry is reported as unreachable. Five scenarios each naming the wrong outcome; three red-proofs, mutations asserted applied and reverted. With the tag check removed, scenario A reports artifacts_set -- Friday's exact defect returns.
293 lines
21 KiB
HTML
293 lines
21 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Configuration — Felhom Hub</title>
|
|
<link rel="stylesheet" href="/style.css?v={{hubVersion}}">
|
|
</head>
|
|
<body>
|
|
{{template "icon_sprite"}}
|
|
<div class="container">
|
|
<header>
|
|
<h1>Felhom <span>Hub</span></h1>
|
|
<nav class="nav-links">
|
|
<a href="/" class="nav-link">Dashboard</a>
|
|
<a href="/configs" class="nav-link">Customers</a>
|
|
<a href="/apps" class="nav-link">Apps</a>
|
|
<a href="/hosts" class="nav-link">Hosts</a>
|
|
<a href="/offsite" class="nav-link">Offsite</a>
|
|
<a href="/configuration" class="nav-link active">Configuration</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<h2 style="margin-bottom: 1rem;">Configuration</h2>
|
|
|
|
{{if eq .Flash "assets_refreshed"}}
|
|
<div class="flash flash-success">Assets refreshed successfully from image seed.</div>
|
|
{{end}}
|
|
{{if eq .Flash "assets_error"}}
|
|
<div class="flash flash-error">Asset refresh failed. Check server logs for details.</div>
|
|
{{end}}
|
|
{{if eq .Flash "assets_not_configured"}}
|
|
<div class="flash flash-error">Asset manager is not configured.</div>
|
|
{{end}}
|
|
{{if eq .Flash "floor_set"}}
|
|
<div class="flash flash-success">Controller-version floor saved.</div>
|
|
{{end}}
|
|
{{if eq .Flash "floor_invalid"}}
|
|
<div class="flash flash-error">Invalid version — use X.Y.Z (or blank to clear).</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifacts_set"}}
|
|
<div class="flash flash-success">Artifact manifest saved.</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_ver_invalid"}}
|
|
<div class="flash flash-error">Invalid artifact version — use X.Y.Z (or blank to clear).</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_sha_invalid"}}
|
|
<div class="flash flash-error">Couldn't set the checksum — the Gitea sha lookup failed (version missing / Gitea unreachable) or the manually-entered sha is invalid. Manifest unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "golden_behind_fleet"}}
|
|
<div class="flash flash-error"><strong>Refused: that golden is older than the controller the fleet already runs.</strong> A fresh install would land on stale application code — which is R-120, where new boxes shipped a controller that told customers the wrong thing about a missing backup drive. Manifest unchanged. Re-bake the golden on the current controller, publish it, then vouch it here.</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_tag_missing"}}
|
|
<div class="flash flash-error"><strong>Refused: that version has no usable git tag.</strong> The installer fetches an agent's config files from <code>raw/tag/v<version>/configs/</code>, so a version published without its tag makes <em>every</em> fresh install and reinstall fail at step 5 of 8 — as root, on a virgin machine. That is exactly what happened on 2026-08-09. Manifest unchanged. Fix it by pushing the tag for that release: <code>git tag -a v<version> <released-commit> && git push origin v<version></code>, then vouch it here again.</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_pkg_missing"}}
|
|
<div class="flash flash-error"><strong>Refused: that version's artifact is not downloadable.</strong> The version is tagged but its package is not in the registry, so a box would 404 fetching the binary itself. Manifest unchanged. Publish it — <code>bash scripts/release-agent.sh <version></code> for the agent, or re-bake and publish the golden — then vouch it here again.</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_unverifiable"}}
|
|
<div class="flash flash-error"><strong>Refused: could not verify — this does not mean anything is missing.</strong> The registry did not answer, so the hub cannot tell whether that version is installable. It refuses rather than saving with a warning, because a warning beside a success reads as a success. Manifest unchanged. Check that Gitea is up, then try again. There is deliberately no override: the registry is on your own server, so if it is unreachable the vouch can wait.</div>
|
|
{{end}}
|
|
{{if eq .Flash "artifact_sha_missing"}}
|
|
<div class="flash flash-error"><strong>Refused: no checksum for a chosen version.</strong> The hub records the sha256 it reads from Gitea, and that lookup came back empty — so saving would vouch bytes nobody verified. Manifest unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_changed"}}
|
|
<div class="flash flash-success">Login password changed. It is already in effect — use it next time you sign in. Existing sessions stay logged in.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_current_wrong"}}
|
|
<div class="flash flash-error">Current password is incorrect — password unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_too_short"}}
|
|
<div class="flash flash-error">New password is too short (minimum 8 characters) — password unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_too_long"}}
|
|
<div class="flash flash-error">New password is too long (maximum 72 characters) — password unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_mismatch"}}
|
|
<div class="flash flash-error">New password and confirmation don't match — password unchanged.</div>
|
|
{{end}}
|
|
{{if eq .Flash "pw_unchanged"}}
|
|
<div class="flash flash-error">New password is the same as the current one — nothing changed.</div>
|
|
{{end}}
|
|
|
|
<!-- Phase 2 managed updates: global controller-version floor. ITS OWN card, separate from the
|
|
Day-0 artifact manifest below (a manifest save must NEVER touch the live floor — the
|
|
publish-train 0.81/0.113 incident). Saving acts IMMEDIATELY, so it is behind a
|
|
type-to-confirm dialog that states the live below-floor blast radius first. -->
|
|
<section class="card">
|
|
<h3 style="margin-top: 0;">Managed updates — global floor</h3>
|
|
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
|
The minimum controller version every box auto-updates to (unless a per-customer override is set).
|
|
<strong>Saving takes effect immediately</strong> — boxes below the floor update on their next
|
|
report, no customer action. Blank = no global floor. This setting is independent of the Day-0
|
|
artifact manifest below.
|
|
</p>
|
|
<!-- Effective-floor-and-source line: makes the DB-override-vs-env-fallback precedence
|
|
permanently visible (the 9-minute-skew incident's root cause). -->
|
|
<p style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
|
Effective floor:
|
|
{{if .FloorRes.Effective}}<code>v{{.FloorRes.Effective}}</code>{{else}}<span class="text-muted">none</span>{{end}}
|
|
{{if eq .FloorRes.Source "db"}}
|
|
<span style="color: #cbd5e1;">— source: <strong>DB (hub_settings)</strong>{{if .FloorRes.EnvValue}}; env fallback would be <code>v{{.FloorRes.EnvValue}}</code>{{end}}</span>
|
|
{{else if eq .FloorRes.Source "env"}}
|
|
<span style="color: #cbd5e1;">— source: <strong>env fallback (DEFAULT_MIN_CONTROLLER_VERSION)</strong>; no DB override set</span>
|
|
{{else}}
|
|
<span class="text-muted">— no floor from either source</span>
|
|
{{end}}
|
|
</p>
|
|
<form id="global-floor-form" method="POST" action="/configuration/global-floor" style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
|
{{.CSRFField}}
|
|
<input type="text" id="global-floor-input" name="min_controller_version" value="{{.FloorRes.DBValue}}" placeholder="e.g. 0.86.0 (blank = clear DB override)" style="padding: 0.3em 0.5em; width: 16em;">
|
|
<button class="btn btn-sm" type="button" onclick="confirmGlobalFloor()">Save global floor…</button>
|
|
<span style="font-size: 0.85em; color: #cbd5e1;">DB override: {{if .FloorRes.DBValue}}<code>v{{.FloorRes.DBValue}}</code>{{else}}<span class="text-muted">none</span>{{end}}</span>
|
|
</form>
|
|
<div id="global-floor-confirm" style="display: none; margin-top: 0.75rem; padding: 0.75rem; border: 1px solid #7c3f00; background: #241a0a; border-radius: 6px; max-width: 44em;">
|
|
<p id="global-floor-impact" style="margin: 0 0 0.5rem; font-size: 0.9em;">…</p>
|
|
<p style="margin: 0 0 0.5rem; font-size: 0.85em; color: #cbd5e1;">Type the version again to confirm (or <code>CLEAR</code> to remove the DB override):</p>
|
|
<input type="text" id="global-floor-confirm-input" placeholder="retype the version…" style="padding: 0.3em 0.5em; width: 16em;">
|
|
<button class="btn btn-sm" type="button" onclick="submitGlobalFloor()">Confirm & apply</button>
|
|
<button class="btn btn-sm btn-ghost" type="button" onclick="document.getElementById('global-floor-confirm').style.display='none';">Cancel</button>
|
|
<p id="global-floor-confirm-err" style="margin: 0.4em 0 0; font-size: 0.8em; color: #f87171;"></p>
|
|
</div>
|
|
<script>
|
|
function confirmGlobalFloor() {
|
|
var v = document.getElementById('global-floor-input').value.trim();
|
|
var box = document.getElementById('global-floor-confirm');
|
|
var impact = document.getElementById('global-floor-impact');
|
|
document.getElementById('global-floor-confirm-input').value = '';
|
|
document.getElementById('global-floor-confirm-err').textContent = '';
|
|
box.style.display = 'block';
|
|
if (v === '') {
|
|
impact.textContent = 'This will CLEAR the DB floor override (the box falls back to the env default). Type CLEAR to confirm.';
|
|
return;
|
|
}
|
|
impact.textContent = 'Checking blast radius…';
|
|
fetch('/configuration/global-floor/impact?v=' + encodeURIComponent(v))
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(d){
|
|
if (!d.valid) { impact.textContent = 'Invalid version — use X.Y.Z.'; return; }
|
|
impact.textContent = 'Saving the minimum version v' + d.version +
|
|
' takes effect immediately — currently ' + d.below +
|
|
' box(es) are below this version and would update on their next report.';
|
|
})
|
|
.catch(function(){ impact.textContent = 'Could not compute the blast radius; proceed with caution.'; });
|
|
}
|
|
function submitGlobalFloor() {
|
|
var v = document.getElementById('global-floor-input').value.trim();
|
|
var typed = document.getElementById('global-floor-confirm-input').value.trim();
|
|
var err = document.getElementById('global-floor-confirm-err');
|
|
var expected = (v === '') ? 'CLEAR' : v;
|
|
if (typed !== expected) { err.textContent = 'Confirmation does not match (' + expected + ').'; return; }
|
|
document.getElementById('global-floor-form').submit();
|
|
}
|
|
</script>
|
|
</section>
|
|
|
|
<!-- BUNDLE slice: Day-0 artifact manifest (agent binary + golden archive). The hub is the
|
|
checksum TRUST ROOT — the host-bootstrap script verifies Gitea-fetched artifacts against
|
|
these sha256s before installing them. Record the version + sha256 printed by
|
|
publish-agent.sh / build-golden.sh. -->
|
|
<section class="card">
|
|
<h3 style="margin-top: 0;">Day-0 artifacts — agent & golden</h3>
|
|
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
|
The current agent binary + golden archive the host-bootstrap script fetches from Gitea and
|
|
verifies (sha256) before installing. The hub vouches for these checksums (a different trust
|
|
root than Gitea). Pick a version — the sha256 is read from Gitea automatically (no manual
|
|
copy). Choose <em>— none —</em> to clear an artifact.
|
|
</p>
|
|
<form method="POST" action="/configuration/artifacts" style="display: grid; grid-template-columns: auto 12em 1fr; gap: 0.5rem; align-items: center; max-width: 56em;">
|
|
{{.CSRFField}}
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Agent</label>
|
|
{{if .AgentChoices}}
|
|
<select name="agent_version" id="agent_version" onchange="syncArtifactSha('agent')" style="padding: 0.3em 0.5em;">
|
|
<option value="" data-sha="">— none —</option>
|
|
{{range .AgentChoices}}
|
|
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.AgentVersion}}selected{{end}}>{{.Version}}</option>
|
|
{{end}}
|
|
</select>
|
|
{{else}}
|
|
<input type="text" name="agent_version" value="{{.Artifacts.AgentVersion}}" placeholder="0.52.0" style="padding: 0.3em 0.5em;">
|
|
{{end}}
|
|
<input type="text" name="agent_sha256" id="agent_sha256" value="{{.Artifacts.AgentSHA256}}" {{if .AgentChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .AgentChoices}}opacity: 0.7;{{end}}">
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Golden</label>
|
|
{{if .GoldenChoices}}
|
|
<select name="golden_version" id="golden_version" onchange="syncArtifactSha('golden')" style="padding: 0.3em 0.5em;">
|
|
<option value="" data-sha="">— none —</option>
|
|
{{range .GoldenChoices}}
|
|
<option value="{{.Version}}" data-sha="{{.SHA256}}" {{if eq .Version $.Artifacts.GoldenVersion}}selected{{end}}>{{.Version}}</option>
|
|
{{end}}
|
|
</select>
|
|
{{else}}
|
|
<input type="text" name="golden_version" value="{{.Artifacts.GoldenVersion}}" placeholder="0.85.1" style="padding: 0.3em 0.5em;">
|
|
{{end}}
|
|
<input type="text" name="golden_sha256" id="golden_sha256" value="{{.Artifacts.GoldenSHA256}}" {{if .GoldenChoices}}readonly{{end}} placeholder="64-hex sha256 (blank = none)" style="padding: 0.3em 0.5em; font-family: monospace; {{if .GoldenChoices}}opacity: 0.7;{{end}}">
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Min agent</label>
|
|
<input type="text" name="min_agent" value="{{.Artifacts.MinAgent}}" placeholder="e.g. 0.81.0 (blank = uncoupled)" style="padding: 0.3em 0.5em;">
|
|
<span style="font-size: 0.8em; color: #94a6bf;">The golden's controller CHANGELOG <code>MinAgent:</code>. The hub HOLDS the floor for any box whose agent is below this — blank = uncoupled release, no gating.</span>
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">PBS wrapper</label>
|
|
<input type="text" name="wrapper_sha256" value="{{.Artifacts.WrapperSHA256}}" placeholder="64-hex sha256 (blank = not vouched)" style="grid-column: 2 / 4; padding: 0.3em 0.5em; font-family: monospace;">
|
|
<span></span>
|
|
<span style="grid-column: 2 / 4; font-size: 0.8em; color: #94a6bf;">sha256 of <code>configs/felhom-pbs-apply</code> (R-50b). Unlike the agent and golden this root-owned wrapper is installed from <code>raw/branch/main</code> — unversioned and unpinned. Recording it here does not fix the channel; it makes host drift <em>visible</em>: agents report the installed file's hash and a mismatch is surfaced on the host page.</span>
|
|
<span></span><span></span>
|
|
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
|
|
</form>
|
|
<script>
|
|
// When a version is picked, mirror that option's Gitea-resolved sha256 into the read-only
|
|
// display field. The hub re-derives the sha authoritatively on save regardless of this value.
|
|
function syncArtifactSha(kind) {
|
|
var sel = document.getElementById(kind + '_version');
|
|
var sha = document.getElementById(kind + '_sha256');
|
|
if (!sel || !sha) return;
|
|
var opt = sel.options[sel.selectedIndex];
|
|
sha.value = (opt && opt.getAttribute('data-sha')) || '';
|
|
}
|
|
</script>
|
|
</section>
|
|
|
|
<!-- Operator login password (v0.54.0). Changing it here writes a hub_settings DB override that
|
|
WINS over the hub.yaml ConfigMap seed (auth.password_hash); the ConfigMap stays the
|
|
break-glass fallback (blank the DB row / edit the manifest to reset a lost password).
|
|
Requires the current password. Existing sessions are intentionally kept valid. -->
|
|
<section class="card">
|
|
<h3 style="margin-top: 0;">Login password</h3>
|
|
<p class="text-muted" style="margin: 0 0 0.75rem; font-size: 0.85em;">
|
|
The password for signing in to this hub UI. <strong>Changing it takes effect immediately</strong>
|
|
for the next sign-in — your current session stays logged in. Enter your current password to confirm.
|
|
If you ever lose it, the deployment ConfigMap (<code>auth.password_hash</code>) remains the reset path.
|
|
</p>
|
|
<form method="POST" action="/configuration/password" style="display: grid; grid-template-columns: auto 20em; gap: 0.5rem; align-items: center; max-width: 40em;"
|
|
onsubmit="return felhomCheckNewPw(this);">
|
|
{{.CSRFField}}
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Current password</label>
|
|
<input type="password" name="current_password" autocomplete="current-password" required style="padding: 0.3em 0.5em;">
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">New password</label>
|
|
<input type="password" id="new_password" name="new_password" autocomplete="new-password" minlength="8" maxlength="72" required style="padding: 0.3em 0.5em;">
|
|
<label style="font-size: 0.9em; color: #cbd5e1;">Confirm new password</label>
|
|
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" minlength="8" maxlength="72" required style="padding: 0.3em 0.5em;">
|
|
<span></span>
|
|
<span>
|
|
<button class="btn btn-sm" type="submit">Change password</button>
|
|
<span id="pw-client-err" style="margin-left: 0.6em; font-size: 0.8em; color: #f87171;"></span>
|
|
</span>
|
|
</form>
|
|
<script>
|
|
// Client-side pre-check only (the server re-validates authoritatively): catch the
|
|
// mismatch before a round-trip so the operator sees it inline.
|
|
function felhomCheckNewPw(form) {
|
|
var a = form.new_password.value;
|
|
var b = form.confirm_password.value;
|
|
var err = document.getElementById('pw-client-err');
|
|
err.textContent = '';
|
|
if (a.length < 8) { err.textContent = 'New password must be at least 8 characters.'; return false; }
|
|
if (a !== b) { err.textContent = 'New password and confirmation do not match.'; return false; }
|
|
return true;
|
|
}
|
|
</script>
|
|
</section>
|
|
|
|
<!-- Assets section -->
|
|
<section class="card">
|
|
<h3 style="margin-top: 0;">Assets</h3>
|
|
<p class="text-muted" style="margin-bottom: 1rem;">
|
|
App logos and screenshots served to controllers. Assets are seeded from the Docker image
|
|
and synced to controllers daily via the asset manifest API.
|
|
</p>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<span class="label">Files in manifest</span>
|
|
<span class="value">{{.AssetCount}}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<span class="label">Manifest generated</span>
|
|
<span class="value" style="font-family: var(--font-mono); font-size: 0.85em;">{{if .AssetLastSync}}{{.AssetLastSync}}{{else}}—{{end}}</span>
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="/configuration" style="margin-top: 1rem;">
|
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
|
<input type="hidden" name="action" value="refresh_assets">
|
|
<button type="submit" class="btn" onclick="this.disabled=true;this.textContent='Refreshing…';this.form.submit();">Refresh Assets from Image</button>
|
|
</form>
|
|
<p class="text-muted" style="margin-top: 0.75rem; font-size: 0.8rem;">
|
|
Re-reads the baked-in asset seed directory and updates changed files.
|
|
Controllers will pick up changes on their next daily sync or manual trigger.
|
|
</p>
|
|
</section>
|
|
|
|
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
|
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>
|