From 79f98f62de56ed5ac2bd61319f9b01027ab60386 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:01:01 +0100 Subject: [PATCH 1/3] fix(catalog): read docker.health_probe so the next web catalog sync stays green The web catalog is adding docker.health_probe (GeiserX/CashPilot#703): a read-only command its worker runs inside a Mysterium node. Desktop does not run it, but the strict-decode test fails on any key the loader does not declare. Parse it as a passthrough, like node_firewall. --- internal/catalog/catalog.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 1b50445..376a389 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -276,6 +276,11 @@ type DockerConfig struct { // container health cannot see. For services with no collector it is the only // signal there is. HealthSignals []HealthSignal `json:"healthSignals" yaml:"health_signals"` + // HealthProbe is docker.health_probe (services/_schema.yml): a read-only command + // the web worker runs inside a running node, with regex checks on its answer + // (Mysterium's monitoring status and recent sessions). Desktop parses it so the + // field round-trips and the strict-decode test passes, and does not act on it. + HealthProbe map[string]any `json:"healthProbe,omitempty" yaml:"health_probe"` // AdvertisedAddressEnv names the ONE env var holding the address the network // dials this service back at, so it can be compared against the machine's // current egress address. Only that variable is ever copied out; the rest may From d9b20e57d00e832677ac92ceaa0ac3ee6f7de469 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:08:44 +0100 Subject: [PATCH 2/3] fix(catalog): keep a numeric-keyed health_probe from blanking the service list yaml.v3 decodes a nested mapping with an unquoted numeric key into map[any]any, which encoding/json refuses, so one such probe would stop the service list from reaching the UI. The loader now turns nested probe maps into map[string]any. The field comment also stops pointing at a schema file that does not describe the block. --- internal/catalog/catalog.go | 38 ++++++++++++++++++++-- internal/catalog/health_probe_test.go | 46 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 internal/catalog/health_probe_test.go diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 376a389..bb358d1 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -2,6 +2,7 @@ package catalog import ( "errors" + "fmt" "io/fs" "log" "net/url" @@ -276,10 +277,12 @@ type DockerConfig struct { // container health cannot see. For services with no collector it is the only // signal there is. HealthSignals []HealthSignal `json:"healthSignals" yaml:"health_signals"` - // HealthProbe is docker.health_probe (services/_schema.yml): a read-only command + // HealthProbe is the web catalog's docker.health_probe block: a read-only command // the web worker runs inside a running node, with regex checks on its answer - // (Mysterium's monitoring status and recent sessions). Desktop parses it so the - // field round-trips and the strict-decode test passes, and does not act on it. + // (Mysterium's monitoring status and recent sessions). Neither schema file + // describes it yet. Desktop parses it so the field round-trips and the + // strict-decode test passes, and does not act on it. The loader makes it + // JSON-safe (see jsonSafe), since the service list crosses the Wails bridge. HealthProbe map[string]any `json:"healthProbe,omitempty" yaml:"health_probe"` // AdvertisedAddressEnv names the ONE env var holding the address the network // dials this service back at, so it can be compared against the machine's @@ -521,6 +524,35 @@ func normalize(svc *Service) { svc.SupportURL = "" } svc.Docker.HealthSignals = normalizeSignals(svc.Slug, svc.Docker.HealthSignals) + if svc.Docker.HealthProbe != nil { + svc.Docker.HealthProbe = jsonSafe(svc.Docker.HealthProbe).(map[string]any) + } +} + +// jsonSafe turns the map[any]any that yaml.v3 produces for a nested mapping with a +// non-string key (an unquoted number, say) into map[string]any. encoding/json +// cannot marshal map[any]any, and one such value would fail the whole service list +// on its way to the UI. +func jsonSafe(v any) any { + switch t := v.(type) { + case map[string]any: + for k, e := range t { + t[k] = jsonSafe(e) + } + return t + case map[any]any: + m := make(map[string]any, len(t)) + for k, e := range t { + m[fmt.Sprint(k)] = jsonSafe(e) + } + return m + case []any: + for i, e := range t { + t[i] = jsonSafe(e) + } + return t + } + return v } // normalizeSignals lowercases each state and drops the signals the web catalog diff --git a/internal/catalog/health_probe_test.go b/internal/catalog/health_probe_test.go new file mode 100644 index 0000000..66033fb --- /dev/null +++ b/internal/catalog/health_probe_test.go @@ -0,0 +1,46 @@ +package catalog + +import ( + "encoding/json" + "testing" + "testing/fstest" +) + +// TestHealthProbeMarshalsToJSON. yaml.v3 decodes a nested mapping with an unquoted +// numeric key into map[any]any, which encoding/json refuses; the service list then +// cannot reach the UI and the app shows nothing. +func TestHealthProbeMarshalsToJSON(t *testing.T) { + fsys := fstest.MapFS{"services/x/a.yml": {Data: []byte(` +name: A +slug: a +docker: + image: a:1 + health_probe: + command: [myst, status] + checks: + - expect: + 200: ok + state: {1: up} +`)}} + cat, err := LoadEmbedded(fsys) + if err != nil { + t.Fatal(err) + } + svc := mustGet(t, cat, "a") + out, err := json.Marshal(svc) + if err != nil { + t.Fatalf("service with a numeric-keyed health_probe must marshal to JSON: %v", err) + } + var back struct { + Docker struct { + HealthProbe map[string]any `json:"healthProbe"` + } `json:"docker"` + } + if err := json.Unmarshal(out, &back); err != nil { + t.Fatal(err) + } + expect := back.Docker.HealthProbe["checks"].([]any)[0].(map[string]any)["expect"].(map[string]any) + if expect["200"] != "ok" || expect["state"].(map[string]any)["1"] != "up" { + t.Errorf("probe keys lost on the way through: %v", expect) + } +} From 0338a406fa883584251dd12da4e0eaa004f83fa8 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:20:27 +0100 Subject: [PATCH 3/3] fix(catalog): keep a .nan or .inf in health_probe from blanking the service list YAML .nan, .inf and -.inf decode to float64 NaN and Inf, which encoding/json refuses, so one such value in a health_probe failed the whole service list on its way to the UI. jsonSafe now turns them into their string form. --- internal/catalog/catalog.go | 11 ++++++++--- internal/catalog/health_probe_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index bb358d1..bddb98a 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -5,6 +5,7 @@ import ( "fmt" "io/fs" "log" + "math" "net/url" "os" "path" @@ -530,9 +531,9 @@ func normalize(svc *Service) { } // jsonSafe turns the map[any]any that yaml.v3 produces for a nested mapping with a -// non-string key (an unquoted number, say) into map[string]any. encoding/json -// cannot marshal map[any]any, and one such value would fail the whole service list -// on its way to the UI. +// non-string key (an unquoted number, say) into map[string]any, and a .nan, .inf or +// -.inf into its string form ("NaN", "+Inf", "-Inf"). encoding/json refuses both, +// and one such value would fail the whole service list on its way to the UI. func jsonSafe(v any) any { switch t := v.(type) { case map[string]any: @@ -551,6 +552,10 @@ func jsonSafe(v any) any { t[i] = jsonSafe(e) } return t + case float64: + if math.IsNaN(t) || math.IsInf(t, 0) { + return fmt.Sprint(t) + } } return v } diff --git a/internal/catalog/health_probe_test.go b/internal/catalog/health_probe_test.go index 66033fb..38bb665 100644 --- a/internal/catalog/health_probe_test.go +++ b/internal/catalog/health_probe_test.go @@ -44,3 +44,30 @@ docker: t.Errorf("probe keys lost on the way through: %v", expect) } } + +// TestHealthProbeNonFiniteFloatMarshals. YAML .nan, .inf and -.inf decode to +// float64 NaN and Inf, which encoding/json refuses just like map[any]any. +func TestHealthProbeNonFiniteFloatMarshals(t *testing.T) { + fsys := fstest.MapFS{"services/x/a.yml": {Data: []byte(` +name: A +slug: a +docker: + image: a:1 + health_probe: + threshold: .nan + checks: + - {max: .inf, min: -.inf, ok: 1.5} +`)}} + cat, err := LoadEmbedded(fsys) + if err != nil { + t.Fatal(err) + } + if _, err := json.Marshal(cat.ListVisible()); err != nil { + t.Fatalf("service list with a non-finite health_probe float must marshal to JSON: %v", err) + } + p := mustGet(t, cat, "a").Docker.HealthProbe + c := p["checks"].([]any)[0].(map[string]any) + if p["threshold"] != "NaN" || c["max"] != "+Inf" || c["min"] != "-Inf" || c["ok"] != 1.5 { + t.Errorf("probe floats not normalised as expected: %v", p) + } +}