GL-7 Part 2: install-command generator on the customer page
Replaces the static Option-1/2 Setup Command blocks with an interactive, client-side builder: mode (required radio), cores/memory (required for byo, optional for appliance), vmid, node, acl-storages (quote-wrapped), operator-pubkey-file, preserve-state-from, and --dry-run/--preflight-only/ --skip-provision/--allow-new-leaf checkboxes. genFlags()/genUpdate() assemble a live-updating download-then-run command (never curl|bash) + a local-run variant, enforcing the script's own rules client-side (mode required; byo requires caps → shows a warning + no runnable command; appliance hides the caps requirement; allow-new-leaf shows its leaf-regen warning). Emits ONLY real host-install v1.12.0 flags; the dangerous/operator-only set (--force/--rotate-recovery/--enable-oob/--remove-golden/--uninstall/ --adopt-pool/--rescope-acl) is never offered. Graceful static fallback: the server-rendered Option-1/2 commands keep --customer-id + a --mode placeholder when JS is off. No framework/CDN/network; ScriptVersion (const, in sync with SCRIPT_VERSION) drives the header. Render/structure test covers the control ids, version, fallback, and the excluded-flag absence. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -18,6 +18,12 @@ import (
|
||||
|
||||
var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`)
|
||||
|
||||
// hostInstallVersion is the felhom-host-install.sh version the customer page's install-command
|
||||
// generator targets. Kept in sync with scripts/felhom-host-install.sh SCRIPT_VERSION — the generator
|
||||
// only ever emits flags this version parses. Display-only (the Option-1 command downloads the served
|
||||
// script, which is always current); bump when the generator's flag surface follows a new script.
|
||||
const hostInstallVersion = "1.12.0"
|
||||
|
||||
// validSemver matches a bare X.Y.Z controller version (the floor format). Empty is also accepted by
|
||||
// the floor handlers (clears the override).
|
||||
var validSemver = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
@@ -307,6 +313,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
ActiveNav string
|
||||
CSRFField template.HTML
|
||||
CSRFToken string
|
||||
|
||||
// ScriptVersion drives the install-command generator's header (GL-7). Display-only.
|
||||
ScriptVersion string
|
||||
}
|
||||
|
||||
// DR recipe presence — show the secret-free reconstruction recipe panel + download link when
|
||||
@@ -364,6 +373,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
||||
ActiveNav: "configs",
|
||||
CSRFField: s.csrfField(r),
|
||||
CSRFToken: s.csrfToken(r),
|
||||
|
||||
ScriptVersion: hostInstallVersion,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -174,3 +174,57 @@ func TestTemplates_PassphraseHardened(t *testing.T) {
|
||||
t.Errorf("raw secret is the retrieval-pw node's visible default text (must be masked)")
|
||||
}
|
||||
}
|
||||
|
||||
// GL-7 Part 2/3: the install-command generator renders its control surface, targets the right
|
||||
// script version, keeps a JS-off static fallback command, and NEVER offers the dangerous/operator-
|
||||
// only flags as controls.
|
||||
func TestTemplates_InstallGenerator(t *testing.T) {
|
||||
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("store.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: "peti-felhom", CustomerName: "Peti", Domain: "sajatfelhom.hu",
|
||||
RetrievalPassword: "pw", APIKey: "k", Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveCustomerConfig: %v", err)
|
||||
}
|
||||
s := New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0))
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/configs/peti-felhom", nil), "peti-felhom")
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
html := rr.Body.String()
|
||||
|
||||
// control surface present (curated subset — all real v1.12.0 flags)
|
||||
for _, id := range []string{
|
||||
`name="gen-mode" value="appliance"`, `name="gen-mode" value="byo"`,
|
||||
`id="gen-cores"`, `id="gen-memory"`, `id="gen-vmid"`, `id="gen-node"`, `id="gen-acl"`,
|
||||
`id="gen-pubkey"`, `id="gen-preserve"`, `id="gen-dry"`, `id="gen-preflight"`,
|
||||
`id="gen-skip"`, `id="gen-leaf"`,
|
||||
} {
|
||||
if !strings.Contains(html, id) {
|
||||
t.Errorf("generator control missing: %s", id)
|
||||
}
|
||||
}
|
||||
// targets the right script version + carries the client-side customer id
|
||||
if !strings.Contains(html, hostInstallVersion) {
|
||||
t.Errorf("ScriptVersion %s not rendered", hostInstallVersion)
|
||||
}
|
||||
if !strings.Contains(html, `data-customer-id="peti-felhom"`) {
|
||||
t.Errorf("generator missing data-customer-id")
|
||||
}
|
||||
// JS-off static fallback: the Option-1/2 commands still show --customer-id + a mode placeholder
|
||||
if !strings.Contains(html, "--customer-id peti-felhom --mode") {
|
||||
t.Errorf("static fallback command missing customer-id + mode")
|
||||
}
|
||||
// the dangerous/operator-only flags are NEVER offered as generator controls
|
||||
for _, f := range []string{"--force", "--rotate-recovery", "--enable-oob", "--remove-golden",
|
||||
"--uninstall", "--adopt-pool", "--rescope-acl"} {
|
||||
if strings.Contains(html, f) {
|
||||
t.Errorf("excluded flag %s must not appear on the customer page", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,27 +387,83 @@
|
||||
<section class="card">
|
||||
<h2>Setup Command</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
|
||||
Day-0 host bootstrap. Run on a freshly-PVE-installed Proxmox <strong>host</strong> as root
|
||||
(create the customer in the hub first). It enrolls the host, installs + verifies the agent,
|
||||
and provisions the guest; the in-guest controller then pulls its own <code>controller.yaml</code>.
|
||||
The retrieval passphrase is entered at the no-echo prompt — never on the command line.
|
||||
Day-0 host bootstrap for host-install <strong>{{.ScriptVersion}}</strong>. Run on a
|
||||
freshly-PVE-installed Proxmox <strong>host</strong> as root (create the customer in the
|
||||
hub first). It enrolls the host, installs + verifies the agent, and provisions the guest;
|
||||
the in-guest controller then pulls its own <code>controller.yaml</code>. The retrieval
|
||||
passphrase is entered at the no-echo prompt — never on the command line.
|
||||
</p>
|
||||
|
||||
<h3>Option 1: Online install (recommended)</h3>
|
||||
<!-- Generator controls (client-side only; nothing is submitted) -->
|
||||
<div class="gen-controls" id="gen-controls" data-customer-id="{{.CustomerID}}">
|
||||
<div class="form-group">
|
||||
<label>Mode <span style="color:var(--red,#E5534B)">*</span></label>
|
||||
<div class="gen-radios">
|
||||
<label class="gen-radio"><input type="radio" name="gen-mode" value="appliance" onchange="genUpdate()"> appliance <span class="form-hint">(Felhom-owned box)</span></label>
|
||||
<label class="gen-radio"><input type="radio" name="gen-mode" value="byo" onchange="genUpdate()"> byo <span class="form-hint">(a host you do not own)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid" style="margin-top:0.75rem;">
|
||||
<div class="form-group" id="gen-cores-grp">
|
||||
<label>Cores <span class="gen-req" style="display:none;color:var(--red,#E5534B)">*</span></label>
|
||||
<input type="number" id="gen-cores" min="1" placeholder="e.g. 12" oninput="genUpdate()">
|
||||
<span class="form-hint">byo: required — a conservative slice of the host</span>
|
||||
</div>
|
||||
<div class="form-group" id="gen-memory-grp">
|
||||
<label>Memory (MiB) <span class="gen-req" style="display:none;color:var(--red,#E5534B)">*</span></label>
|
||||
<input type="number" id="gen-memory" min="256" placeholder="e.g. 32768 (= 32 GB)" oninput="genUpdate()">
|
||||
<span class="form-hint">byo: required — MiB (32 GB → 32768)</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>VMID <span class="form-hint">(optional)</span></label>
|
||||
<input type="number" id="gen-vmid" min="100" placeholder="e.g. 9201" oninput="genUpdate()">
|
||||
<span class="form-hint">pick from <code>pct list</code>+<code>qm list</code>; blank = default/auto</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Node <span class="form-hint">(optional)</span></label>
|
||||
<input type="text" id="gen-node" placeholder="e.g. pve1" oninput="genUpdate()">
|
||||
<span class="form-hint">required only on a multi-node cluster</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>ACL storages <span class="form-hint">(optional)</span></label>
|
||||
<input type="text" id="gen-acl" placeholder='e.g. local local-lvm' oninput="genUpdate()">
|
||||
<span class="form-hint">grant the token write access on exactly these storages</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Operator pubkey file <span class="form-hint">(optional)</span></label>
|
||||
<input type="text" id="gen-pubkey" placeholder="/path/to/operator-keys" oninput="genUpdate()">
|
||||
<span class="form-hint">arm self-update from day-0</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Preserve state from <span class="form-hint">(optional)</span></label>
|
||||
<input type="text" id="gen-preserve" placeholder="/var/lib/felhom-agent.old" oninput="genUpdate()">
|
||||
<span class="form-hint">reinstall keeping the leaf pin stable</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gen-checks">
|
||||
<label class="gen-check"><input type="checkbox" id="gen-dry" onchange="genUpdate()"> <code>--dry-run</code> <span class="form-hint">print, don't execute</span></label>
|
||||
<label class="gen-check"><input type="checkbox" id="gen-preflight" onchange="genUpdate()"> <code>--preflight-only</code> <span class="form-hint">checks + verdict, no install</span></label>
|
||||
<label class="gen-check"><input type="checkbox" id="gen-skip" onchange="genUpdate()"> <code>--skip-provision</code> <span class="form-hint">agent only, no guest</span></label>
|
||||
<label class="gen-check"><input type="checkbox" id="gen-leaf" onchange="genUpdate()"> <code>--allow-new-leaf</code> <span class="form-hint" id="gen-leaf-warn"></span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="gen-msg" id="gen-msg" style="display:none;"></p>
|
||||
|
||||
<h3 style="margin-top:1rem;">Option 1: Online install (recommended)</h3>
|
||||
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">
|
||||
Fetches the script over TLS from felhom.eu, then runs it. Download-then-run (not
|
||||
<code>curl | sudo bash</code>) so you can inspect the script before executing it — the
|
||||
right default for a sovereignty product. The passphrase is entered at the no-echo prompt.
|
||||
Download-then-run (not <code>curl | sudo bash</code>) so you can inspect the script first —
|
||||
the right default for a sovereignty product. The passphrase is entered at the no-echo prompt.
|
||||
</p>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-online">curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \
|
||||
&& sudo bash felhom-host-install.sh --customer-id {{.CustomerID}}</code>
|
||||
&& sudo bash felhom-host-install.sh --customer-id {{.CustomerID}} --mode <appliance|byo></code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-online')" title="Copy">⎘</button>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 1rem;">Option 2: Local install (script already on host)</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-setup">sudo ./felhom-host-install.sh --customer-id {{.CustomerID}}</code>
|
||||
<code id="cmd-setup">sudo ./felhom-host-install.sh --customer-id {{.CustomerID}} --mode <appliance|byo></code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
||||
</div>
|
||||
|
||||
@@ -722,6 +778,75 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Install-command generator (GL-7) — client-side only; assembles a command from the controls
|
||||
// and only ever emits real host-install flags. Nothing is submitted.
|
||||
function genFlags() {
|
||||
// returns {flags: "<assembled>", err: "<message or empty>"}
|
||||
var mode = (document.querySelector('input[name="gen-mode"]:checked') || {}).value || '';
|
||||
if (!mode) return { flags: '', err: 'Pick a mode (appliance or byo) to generate the command.' };
|
||||
var f = ['--mode ' + mode];
|
||||
var cores = (document.getElementById('gen-cores').value || '').trim();
|
||||
var memory = (document.getElementById('gen-memory').value || '').trim();
|
||||
var isInt = function (v) { return v !== '' && /^[0-9]+$/.test(v) && parseInt(v, 10) > 0; };
|
||||
if (mode === 'byo') {
|
||||
if (!isInt(cores) || !isInt(memory)) {
|
||||
return { flags: '', err: 'byo mode requires --cores and --memory (noisy-neighbor caps on a host you do not own).' };
|
||||
}
|
||||
f.push('--cores ' + cores, '--memory ' + memory);
|
||||
} else {
|
||||
// appliance: caps optional; emit only if both are valid positive integers
|
||||
if (isInt(cores)) f.push('--cores ' + cores);
|
||||
if (isInt(memory)) f.push('--memory ' + memory);
|
||||
}
|
||||
var vmid = (document.getElementById('gen-vmid').value || '').trim();
|
||||
if (vmid !== '' && isInt(vmid)) f.push('--vmid ' + vmid);
|
||||
var node = (document.getElementById('gen-node').value || '').trim();
|
||||
if (node) f.push('--node ' + node);
|
||||
var acl = (document.getElementById('gen-acl').value || '').trim().replace(/\s+/g, ' ');
|
||||
if (acl) f.push('--acl-storages "' + acl + '"');
|
||||
var pub = (document.getElementById('gen-pubkey').value || '').trim();
|
||||
if (pub) f.push('--operator-pubkey-file ' + pub);
|
||||
var pre = (document.getElementById('gen-preserve').value || '').trim();
|
||||
if (pre) f.push('--preserve-state-from ' + pre);
|
||||
if (document.getElementById('gen-skip').checked) f.push('--skip-provision');
|
||||
if (document.getElementById('gen-dry').checked) f.push('--dry-run');
|
||||
if (document.getElementById('gen-preflight').checked) f.push('--preflight-only');
|
||||
if (document.getElementById('gen-leaf').checked) f.push('--allow-new-leaf');
|
||||
return { flags: f.join(' '), err: '' };
|
||||
}
|
||||
|
||||
function genUpdate() {
|
||||
var ctrl = document.getElementById('gen-controls');
|
||||
if (!ctrl) return;
|
||||
var cid = ctrl.getAttribute('data-customer-id') || '';
|
||||
var mode = (document.querySelector('input[name="gen-mode"]:checked') || {}).value || '';
|
||||
// byo requires caps → show the required markers only in byo
|
||||
var reqOn = (mode === 'byo');
|
||||
document.querySelectorAll('.gen-req').forEach(function (e) { e.style.display = reqOn ? 'inline' : 'none'; });
|
||||
// allow-new-leaf inline warning
|
||||
var lw = document.getElementById('gen-leaf-warn');
|
||||
lw.textContent = document.getElementById('gen-leaf').checked
|
||||
? '⚠ regenerates the agent leaf — every provisioned guest must then be re-bootstrapped' : '';
|
||||
|
||||
var r = genFlags();
|
||||
var msg = document.getElementById('gen-msg');
|
||||
var online = document.getElementById('cmd-online');
|
||||
var local = document.getElementById('cmd-setup');
|
||||
var base = 'felhom-host-install.sh --customer-id ' + cid;
|
||||
if (r.err) {
|
||||
msg.textContent = r.err; msg.style.display = 'block';
|
||||
// Do NOT emit a runnable command — show the incomplete shape with a placeholder.
|
||||
online.textContent = 'curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \\\n && sudo bash ' + base + ' --mode <appliance|byo>';
|
||||
local.textContent = 'sudo ./' + base + ' --mode <appliance|byo>';
|
||||
return;
|
||||
}
|
||||
msg.style.display = 'none';
|
||||
online.textContent = 'curl -fsSL https://felhom.eu/scripts/felhom-host-install.sh -o felhom-host-install.sh \\\n && sudo bash ' + base + ' ' + r.flags;
|
||||
local.textContent = 'sudo ./' + base + ' ' + r.flags;
|
||||
}
|
||||
// initialize on load (also gives JS-enabled users the "pick a mode" prompt state)
|
||||
if (document.getElementById('gen-controls')) { genUpdate(); }
|
||||
|
||||
function disableGeo(customerID) {
|
||||
if (!confirm('Összes geo-korlátozás eltávolítása?\n\nEz közvetlenül törli a Cloudflare WAF szabályokat és értesíti a controllert.')) return;
|
||||
var btn = document.getElementById('btn-geo-disable');
|
||||
|
||||
@@ -535,6 +535,64 @@ code {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* Install-command generator (GL-7) */
|
||||
.gen-controls {
|
||||
background: var(--bg-0);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.gen-radios {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gen-radio, .gen-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-1);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gen-radio input, .gen-check input {
|
||||
accent-color: var(--blue-bright);
|
||||
cursor: pointer;
|
||||
}
|
||||
.gen-checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line-soft);
|
||||
}
|
||||
.gen-check code, .gen-radio code { color: var(--blue-bright); font-size: 0.8rem; }
|
||||
.gen-controls input[type="number"] {
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--text-1);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.gen-controls input[type="number"]:focus,
|
||||
.gen-controls input[type="text"]:focus { outline: none; border-color: var(--blue-bright); }
|
||||
.gen-msg {
|
||||
color: #E5534B;
|
||||
font-size: 0.82rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(229,83,75,.10);
|
||||
border: 1px solid rgba(229,83,75,.35);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Credentials */
|
||||
.credential-row {
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
Reference in New Issue
Block a user