diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 1b50445..bddb98a 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -2,8 +2,10 @@ package catalog import ( "errors" + "fmt" "io/fs" "log" + "math" "net/url" "os" "path" @@ -276,6 +278,13 @@ 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 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). 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 // current egress address. Only that variable is ever copied out; the rest may @@ -516,6 +525,39 @@ 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, 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: + 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 + case float64: + if math.IsNaN(t) || math.IsInf(t, 0) { + return fmt.Sprint(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..38bb665 --- /dev/null +++ b/internal/catalog/health_probe_test.go @@ -0,0 +1,73 @@ +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) + } +} + +// 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) + } +}