hub v0.42.0: remote "Debug mód" toggle on the customer config editor
Adds a form-level debug-mode checkbox to the customer config editor so an
operator can flip the controller's Logging.Level=debug (/debug menu + verbose
log) remotely, without SSH. Form field (not raw-JSON injection) because
handleConfigUpdate rebuilds ConfigJSON from the form on every save; the
config-version bump makes the controller re-pull + self-restart next cycle.
- buildConfigJSON: debug_mode checked -> "logging":{"level":"debug"};
unchecked -> logging key omitted.
- config_form.html: "Hibakeresési mód (fejlesztői)" section + render state.
- configs_debug_test.go: form->JSON both ways; full-path survival test
(debug lands, offsite descriptor unchanged, foreign-key red-proof); render
state; red-proof exercised.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -955,6 +955,15 @@ func buildConfigJSON(r *http.Request) string {
|
||||
overrides["git"] = git
|
||||
}
|
||||
|
||||
// Logging (remote debug-mode toggle). The controller's /debug menu + verbose log key off
|
||||
// Logging.Level=="debug" (controller isDebug()). This lives in the FORM — not raw-JSON injection —
|
||||
// on purpose: handleConfigUpdate rebuilds ConfigJSON from the form on every save (buildConfigJSON),
|
||||
// so a foreign key would be dropped on the next save. Checked → logging.level=debug; unchecked → the
|
||||
// logging key is OMITTED entirely (the generated controller.yaml default stands — no needless "info").
|
||||
if r.FormValue("debug_mode") != "" {
|
||||
overrides["logging"] = map[string]interface{}{"level": "debug"}
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(overrides)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// postForm builds a POST request whose urlencoded body is parsed by r.FormValue/r.ParseForm,
|
||||
// exactly like the real edit submission.
|
||||
func postForm(target, body string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, target, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
|
||||
// The debug-mode toggle is a FORM field (not raw-JSON injection) so it survives the on-save rebuild.
|
||||
// This asserts the form→JSON leg: checked writes logging.level=debug; unchecked omits the key entirely
|
||||
// (so the generated controller.yaml default stands — no needless "info").
|
||||
func TestBuildConfigJSON_DebugMode(t *testing.T) {
|
||||
// Checked → logging.level=debug present.
|
||||
got := buildConfigJSON(postForm("/configs/x/edit", "debug_mode=on"))
|
||||
var on map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(got), &on); err != nil {
|
||||
t.Fatalf("checked: bad JSON %q: %v", got, err)
|
||||
}
|
||||
logging, ok := on["logging"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("checked: expected a logging object, got %q", got)
|
||||
}
|
||||
if logging["level"] != "debug" {
|
||||
t.Fatalf("checked: expected logging.level=debug, got %v (%q)", logging["level"], got)
|
||||
}
|
||||
|
||||
// Unchecked → NO logging key at all (not level=info).
|
||||
got = buildConfigJSON(postForm("/configs/x/edit", "customer_name=Kov%C3%A1cs"))
|
||||
var off map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(got), &off); err != nil {
|
||||
t.Fatalf("unchecked: bad JSON %q: %v", got, err)
|
||||
}
|
||||
if _, present := off["logging"]; present {
|
||||
t.Fatalf("unchecked: logging key must be OMITTED, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the form-level toggle: it survives the on-save ConfigJSON REBUILD, and coexists
|
||||
// with the offsite descriptor (which survives via the separate provision-merge). Companion RED-PROOF:
|
||||
// a foreign key injected straight into the stored ConfigJSON is GONE after one save — which is exactly
|
||||
// why the switch had to be a form field, not raw-JSON injection.
|
||||
func TestConfigUpdate_DebugSurvivesRebuild_OffsiteUntouched(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
s.SetOffsiteProvisioner(&offsite.Provisioner{
|
||||
API: hetznerapi.NewFake(), Store: st, Scanner: webTestScanner{},
|
||||
PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0),
|
||||
})
|
||||
|
||||
const id = "cust-dbg"
|
||||
if err := st.SaveCustomerConfig(&store.CustomerConfig{
|
||||
CustomerID: id, CustomerName: "Kovács", Domain: "kovacs.felhom.eu", ConfigJSON: "{}",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed customer: %v", err)
|
||||
}
|
||||
|
||||
// 1) First save WITH offsite enabled → provisions + merges the descriptor. No debug yet.
|
||||
const offsiteForm = "customer_name=Kov%C3%A1cs&domain=kovacs.felhom.eu&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50"
|
||||
w := httptest.NewRecorder()
|
||||
s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm), id)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("first save: expected 303, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
cfg, _ := st.GetCustomerConfig(id)
|
||||
before := offsiteOf(t, cfg.ConfigJSON)
|
||||
if before["host"] == nil || before["host"] == "" {
|
||||
t.Fatalf("first save should have merged an offsite descriptor, got %q", cfg.ConfigJSON)
|
||||
}
|
||||
|
||||
// 2) Inject a foreign key directly into the stored ConfigJSON — the raw-JSON path the task ruled out.
|
||||
var obj map[string]json.RawMessage
|
||||
json.Unmarshal([]byte(cfg.ConfigJSON), &obj)
|
||||
obj["foo"] = json.RawMessage(`"bar"`)
|
||||
inj, _ := json.Marshal(obj)
|
||||
cfg.ConfigJSON = string(inj)
|
||||
if err := st.SaveCustomerConfig(cfg); err != nil {
|
||||
t.Fatalf("inject foreign key: %v", err)
|
||||
}
|
||||
|
||||
// 3) Second save WITH offsite still enabled AND debug checked.
|
||||
w = httptest.NewRecorder()
|
||||
s.handleConfigUpdate(w, postForm("/configs/"+id+"/edit", offsiteForm+"&debug_mode=on"), id)
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("second save: expected 303, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
cfg, _ = st.GetCustomerConfig(id)
|
||||
|
||||
var final map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(cfg.ConfigJSON), &final); err != nil {
|
||||
t.Fatalf("final ConfigJSON invalid: %v", err)
|
||||
}
|
||||
|
||||
// (a) debug logging key made it in.
|
||||
logging, _ := final["logging"].(map[string]interface{})
|
||||
if logging == nil || logging["level"] != "debug" {
|
||||
t.Fatalf("debug toggle did not survive the save: logging=%v (%q)", final["logging"], cfg.ConfigJSON)
|
||||
}
|
||||
|
||||
// (b) offsite descriptor UNCHANGED across the save+re-provision (idempotent label lookup).
|
||||
after := offsiteOf(t, cfg.ConfigJSON)
|
||||
for _, k := range []string{"host", "user", "repo_path", "host_fingerprint", "quota_gb"} {
|
||||
if before[k] != after[k] {
|
||||
t.Fatalf("offsite.%s changed across save: %v -> %v", k, before[k], after[k])
|
||||
}
|
||||
}
|
||||
|
||||
// RED-PROOF: the injected foreign key is GONE — buildConfigJSON rebuilt ConfigJSON from the form,
|
||||
// so anything not represented as a form field is dropped. This is why the switch is a form field.
|
||||
if _, present := final["foo"]; present {
|
||||
t.Fatalf("foreign key survived the rebuild — the on-save rebuild premise is wrong: %q", cfg.ConfigJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// offsiteOf extracts the offsite sub-object as a generic map for field-by-field comparison.
|
||||
func offsiteOf(t *testing.T, configJSON string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(configJSON), &m); err != nil {
|
||||
t.Fatalf("parse ConfigJSON: %v", err)
|
||||
}
|
||||
o, _ := m["offsite"].(map[string]interface{})
|
||||
if o == nil {
|
||||
t.Fatalf("no offsite object in %q", configJSON)
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Render leg: a debug-on ConfigJSON draws the checkbox checked; a plain one draws it unchecked.
|
||||
func TestConfigForm_DebugRenderState(t *testing.T) {
|
||||
s, _ := newTestServer(t)
|
||||
|
||||
render := func(configJSON string) string {
|
||||
var overrides map[string]interface{}
|
||||
json.Unmarshal([]byte(configJSON), &overrides)
|
||||
data := struct {
|
||||
IsNew bool
|
||||
Config *store.CustomerConfig
|
||||
Overrides map[string]interface{}
|
||||
ActiveNav string
|
||||
Error string
|
||||
CSRFField string
|
||||
}{
|
||||
Config: &store.CustomerConfig{CustomerID: "c1"},
|
||||
Overrides: overrides,
|
||||
ActiveNav: "configs",
|
||||
}
|
||||
var b strings.Builder
|
||||
if err := s.templates.ExecuteTemplate(&b, "config_form.html", data); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// debug on → the debug_mode checkbox carries `checked`.
|
||||
out := render(`{"logging":{"level":"debug"}}`)
|
||||
if !debugChecked(out) {
|
||||
t.Fatalf("debug ConfigJSON should render the checkbox checked:\n%s", isolate(out))
|
||||
}
|
||||
// plain → not checked.
|
||||
out = render(`{}`)
|
||||
if debugChecked(out) {
|
||||
t.Fatalf("plain ConfigJSON must render the checkbox UNchecked:\n%s", isolate(out))
|
||||
}
|
||||
}
|
||||
|
||||
// debugChecked reports whether the debug_mode checkbox input renders with the `checked` attribute.
|
||||
func debugChecked(html string) bool {
|
||||
i := strings.Index(html, `name="debug_mode"`)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
// The input tag ends at the next '>'; `checked` (if present) sits before it.
|
||||
end := strings.Index(html[i:], ">")
|
||||
if end < 0 {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(html[i:i+end], "checked")
|
||||
}
|
||||
|
||||
// isolate trims the rendered page down to the debug_mode input's neighborhood for readable failures.
|
||||
func isolate(html string) string {
|
||||
i := strings.Index(html, `name="debug_mode"`)
|
||||
if i < 0 {
|
||||
return "(debug_mode input not found)"
|
||||
}
|
||||
start := i - 60
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := i + 60
|
||||
if end > len(html) {
|
||||
end = len(html)
|
||||
}
|
||||
return html[start:end]
|
||||
}
|
||||
@@ -103,6 +103,18 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card" {{if index .Overrides "logging"}}open{{end}}>
|
||||
<summary><h2 style="display:inline">Hibakeresési mód (fejlesztői)</h2></summary>
|
||||
<div class="form-grid" style="margin-top: 1rem;">
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="debug_mode"
|
||||
{{with .Overrides}}{{with index . "logging"}}{{if eq (index . "level") "debug"}}checked{{end}}{{end}}{{end}}>
|
||||
Debug mód — bőbeszédű napló + /debug menü a vezérlőn</label>
|
||||
<small class="form-hint">Bekapcsolva a vezérlő újraindul a következő ciklusban; a menü: https://felhom.<domain>/debug. Tesztelés után kapcsold ki.</small>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="card" {{if index .Overrides "offsite"}}open{{end}}>
|
||||
<summary><h2 style="display:inline">Offsite backup</h2></summary>
|
||||
<div class="form-grid" style="margin-top: 1rem;">
|
||||
|
||||
Reference in New Issue
Block a user