Add Cato Networks collector (#22373)
Co-authored-by: ilyam8 <ilya@netdata.cloud>
Costa Tsaousis committed
May 29, 2026 at 23:32 UTC
44620a5d47fa728c2b1c2478945cd21d825d6172
58 files changed
+7325
-16
src/collectors/COLLECTORS.md
+1
@@ -249,6 +249,7 @@ Need a dedicated integration? [Submit a feature request](https://github.com/netd
249
| [8430FT modem](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/8430ft_modem.md) | Keep track of vital metrics from the MTS 8430FT modem for streamlined network performance and diagnostics. |
250
| [Access Points](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/ap/integrations/access_points.md) | This collector monitors various wireless access point metrics like connected clients, bandwidth, packets, transmit issues, signal strength, and bitrate for each device and its associated SSID. |
251
| [Bird Routing Daemon](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/bird_routing_daemon.md) | Keep an eye on Bird Routing Daemon metrics for optimized network routing and management. |
252
+| [Cato Networks](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/cato_networks/integrations/cato_networks.md) | This collector monitors Cato Networks accounts through the Cato GraphQL API. |
253
| [Chrony](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/chrony/integrations/chrony.md) | This collector monitors the system's clock performance and peers activity status |
254
| [Clash](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/prometheus/integrations/clash.md) | Keep an eye on Clash proxy server metrics for optimized network performance and management. |
255
| [Conntrack](https://github.com/netdata/netdata/blob/master/src/collectors/proc.plugin/integrations/conntrack.md) | This integration monitors the connection tracking mechanism of Netfilter in the Linux Kernel. |
src/go/go.mod
+7
@@ -75,6 +75,7 @@ require (
75
github.com/Azure/azure-sdk-for-go/sdk/monitor/query/azmetrics v1.3.0
76
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0
77
github.com/alexbrainman/odbc v0.0.0-20250601004241-49e6b2bc0cf0
78
+ github.com/catonetworks/cato-go-sdk v0.2.5
79
github.com/cespare/xxhash/v2 v2.3.0
80
github.com/docker/go-units v0.5.0
81
github.com/ibm-messaging/mq-golang/v5 v5.7.1
@@ -89,6 +90,7 @@ require (
90
cloud.google.com/go/compute/metadata v0.9.0 // indirect
91
dario.cat/mergo v1.0.2 // indirect
92
filippo.io/edwards25519 v1.2.0 // indirect
93
+ github.com/99designs/gqlgen v0.17.61 // indirect
94
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
95
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
96
github.com/Azure/go-ntlmssp v0.1.1 // indirect
@@ -96,6 +98,7 @@ require (
98
github.com/Masterminds/goutils v1.1.1 // indirect
99
github.com/Masterminds/semver/v3 v3.5.0 // indirect
100
github.com/Microsoft/go-winio v0.6.1 // indirect
101
+ github.com/Yamashou/gqlgenc v0.30.2 // indirect
102
github.com/containerd/errdefs v1.0.0 // indirect
103
github.com/containerd/errdefs/pkg v0.3.0 // indirect
104
github.com/containerd/log v0.1.0 // indirect
@@ -124,6 +127,8 @@ require (
127
github.com/google/go-cmp v0.7.0 // indirect
128
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
129
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
130
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
131
+ github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
132
github.com/huandu/xstrings v1.5.0 // indirect
133
github.com/ibmruntimes/go-recordio/v2 v2.0.0-20260316030125-5f5b8d0a3634 // indirect
134
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -158,10 +163,12 @@ require (
163
github.com/prometheus/client_model v0.6.2 // indirect
164
github.com/shopspring/decimal v1.4.0 // indirect
165
github.com/sirupsen/logrus v1.9.4 // indirect
166
+ github.com/sosodev/duration v1.3.1 // indirect
167
github.com/spf13/cast v1.10.0 // indirect
168
github.com/spf13/pflag v1.0.9 // indirect
169
github.com/tidwall/match v1.2.0 // indirect
170
github.com/tidwall/pretty v1.2.1 // indirect
171
+ github.com/vektah/gqlparser/v2 v2.5.20 // indirect
172
github.com/x448/float16 v0.8.4 // indirect
173
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
174
github.com/xdg-go/scram v1.1.2 // indirect
src/go/go.sum
+24
@@ -8,6 +8,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
8
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
9
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
10
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
11
+github.com/99designs/gqlgen v0.17.61 h1:vE7xLRC066n9wehgjeplILOWtwz75zbzcV2/Iv9i3pw=
12
+github.com/99designs/gqlgen v0.17.61/go.mod h1:rFU1T3lhv/tPeAlww/DJ4ol2YxT/pPpue+xxPbkd3r4=
13
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
14
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
15
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
@@ -46,12 +48,16 @@ github.com/PaloAltoNetworks/pango v0.10.2 h1:Tjn6vIzzAq6Dd7N0mDuiP8w8pz8k5W9zz/T
48
github.com/PaloAltoNetworks/pango v0.10.2/go.mod h1:GztcRnVLur7G+VFG7Z5ZKNFgScLtsycwPMp1qVebE5g=
49
github.com/Wing924/ltsv v0.4.0 h1:MMTcnzNVS26KLermAJDebO7/n5Qt42UpvC6vVcLjmJQ=
50
github.com/Wing924/ltsv v0.4.0/go.mod h1:BgO75Q6CyIcRVvHN2acl63ZzoH/Tkj7R3rjYO9/hleQ=
51
+github.com/Yamashou/gqlgenc v0.30.2 h1:hkoWStLZI2JgrgZuHlJXVnDFt0/B2X6YLv6hFPYpEAg=
52
+github.com/Yamashou/gqlgenc v0.30.2/go.mod h1:0VNZtzXhyDofkNNZXXPw8LSLvi3vyG6LXo7XvcWv2X4=
53
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
54
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
55
github.com/alexbrainman/odbc v0.0.0-20250601004241-49e6b2bc0cf0 h1:gUrYWktqvF8PVb2SIBQR5WsFxjctn7d1JBIx/FrSzik=
56
github.com/alexbrainman/odbc v0.0.0-20250601004241-49e6b2bc0cf0/go.mod h1:c5eyz5amZqTKvY3ipqerFO/74a/8CYmXOahSr40c+Ww=
57
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
58
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
59
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
60
+github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
61
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
62
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
63
github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk=
@@ -72,6 +78,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
78
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
79
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
80
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
81
+github.com/catonetworks/cato-go-sdk v0.2.5 h1:Ev/jnhd3jZZvILKixMBLlaBL1FPM8e+bHwz2x37B+6o=
82
+github.com/catonetworks/cato-go-sdk v0.2.5/go.mod h1:KnebAFFIvAarFAHxPYuOPK/1e/mRkvhKCbnoc6vfuHc=
83
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
84
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
85
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -111,6 +119,8 @@ github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bF
119
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
120
github.com/facebook/time v0.0.0-20250211113239-e3e1421a0980 h1:jxAsxgSZ+7KqDS8MsSvoOHw7XJfkzmM+jwPPgthzOGc=
121
github.com/facebook/time v0.0.0-20250211113239-e3e1421a0980/go.mod h1:6Fcpck9qC/7PIHUM5/llrFNZnDzosibanENzYRadZeg=
122
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
123
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
124
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
125
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
126
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -186,6 +196,12 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV
196
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
197
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
198
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
199
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
200
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
201
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
202
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
203
+github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
204
+github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
205
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
206
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
207
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@@ -261,6 +277,8 @@ github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
277
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
278
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
279
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
280
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
281
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
282
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
283
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
284
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -355,6 +373,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
373
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
374
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
375
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
376
+github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
377
+github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
378
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
379
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
380
github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg=
@@ -362,6 +382,8 @@ github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYo
382
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
383
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
384
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
385
+github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
386
+github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
387
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
388
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
389
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -391,6 +413,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
413
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
414
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
415
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
416
+github.com/vektah/gqlparser/v2 v2.5.20 h1:kPaWbhBntxoZPaNdBaIPT1Kh0i1b/onb5kXgEdP5JCo=
417
+github.com/vektah/gqlparser/v2 v2.5.20/go.mod h1:xMl+ta8a5M1Yo1A1Iwt/k7gSpscwSnHZdw7tfhEGfTM=
418
github.com/vmware/govmomi v0.54.0 h1:akEKkM9XKMOhTskmdzTLG8JzH+sh61jbFrVPbAzv5IQ=
419
github.com/vmware/govmomi v0.54.0/go.mod h1:0F3hChqXDrSQQnjfSiCqRE5lPD4aZlbOtKG4uroq2a4=
420
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
src/go/pkg/topology/v1/types.go
-5
@@ -436,11 +436,6 @@ type AggregationScope struct {
436
EvidencePolicy string `json:"evidence_policy,omitempty"`
437
}
438
439
-//go:fix inline
440
-func Bool(value bool) *bool {
441
- return new(value)
442
-}
443
-
439
type EvidenceMap map[string]EvidenceSection
440
441
type EvidenceSection struct {
src/go/plugin/go.d/README.md
+1
@@ -42,6 +42,7 @@ All capabilities are set automatically during Netdata installation using the [of
42
| [bind](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/bind) | ISC Bind |
43
| [boinc](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/boinc) | BOINC |
44
| [cassandra](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/cassandra) | Cassandra |
45
+| [cato_networks](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/cato_networks) | Cato Networks |
46
| [ceph](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/ceph) | Ceph |
47
| [chrony](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/chrony) | Chrony |
48
| [clickhouse](https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/collector/clickhouse) | ClickHouse |
src/go/plugin/go.d/collector/cato_networks/README.md
new
+1
@@ -0,0 +1 @@
1
+integrations/cato_networks.md
\ No newline at end of file
src/go/plugin/go.d/collector/cato_networks/catofunc/deps.go
new
+10
@@ -0,0 +1,10 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package catofunc
4
+
5
+import topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
6
+
7
+// Deps defines the dependency surface required by Cato Networks function handlers.
8
+type Deps interface {
9
+ CurrentTopology() (*topologyv1.Data, bool)
10
+}
src/go/plugin/go.d/collector/cato_networks/catofunc/presentation.go
new
+275
@@ -0,0 +1,275 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package catofunc
4
+
5
+import (
6
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
7
+ topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
8
+)
9
+
10
+func topologyMethodConfig(updateEvery int) funcapi.MethodConfig {
11
+ return funcapi.MethodConfig{
12
+ ID: TopologyMethodID,
13
+ Aliases: []string{TopologyMethodID},
14
+ Name: "Topology (Cato Networks)",
15
+ UpdateEvery: updateEvery,
16
+ Help: "Cato Networks site, device, PoP, tunnel, and BGP topology data",
17
+ RequireCloud: true,
18
+ ResponseType: topologyv1.ResponseType,
19
+ }.WithPresentation(TopologyPresentation())
20
+}
21
+
22
+func TopologyActorTypes() map[string]topologyv1.ActorType {
23
+ return map[string]topologyv1.ActorType{
24
+ ActorTypeSite: {
25
+ Layer: "network",
26
+ Identity: []string{"id"},
27
+ MergeIdentity: []string{"account_id", "site_id"},
28
+ AggregationScopes: []string{"site", "network"},
29
+ Search: &topologyv1.ActorSearchPolicy{Columns: []string{"display_name", "site_id", "pop_name", "country_name", "region"}},
30
+ Presentation: &topologyv1.ActorPresentation{
31
+ Label: "Cato site",
32
+ Role: "actor",
33
+ Icon: "network",
34
+ ColorSlot: "green",
35
+ Border: &topologyv1.BorderPresentation{Enabled: new(true)},
36
+ Size: &topologyv1.ActorSizePresentation{Mode: "link_count", Scale: "emphasized"},
37
+ Layout: &topologyv1.ActorLayoutPresentation{Repulsion: "stronger"},
38
+ LabelPolicy: &topologyv1.LabelPolicy{
39
+ Columns: []string{"display_name", "site_id"},
40
+ Fallback: "type_label",
41
+ MaxLength: 80,
42
+ Array: "reject",
43
+ },
44
+ Modal: catoSiteModal(),
45
+ },
46
+ },
47
+ ActorTypeDevice: {
48
+ Layer: "network",
49
+ Identity: []string{"id"},
50
+ MergeIdentity: []string{"account_id", "site_id", "device_id"},
51
+ ParentIdentity: []string{"site_id"},
52
+ AggregationScopes: []string{"site", "network"},
53
+ Search: &topologyv1.ActorSearchPolicy{Columns: []string{"display_name", "device_id", "socket_serial"}},
54
+ Presentation: &topologyv1.ActorPresentation{
55
+ Label: "Cato device",
56
+ Role: "actor",
57
+ Icon: "device",
58
+ ColorSlot: "green",
59
+ Border: &topologyv1.BorderPresentation{Enabled: new(true)},
60
+ Size: &topologyv1.ActorSizePresentation{Mode: "link_count", Scale: "normal"},
61
+ LabelPolicy: &topologyv1.LabelPolicy{
62
+ Columns: []string{"display_name", "device_id"},
63
+ Fallback: "type_label",
64
+ MaxLength: 80,
65
+ Array: "reject",
66
+ },
67
+ Modal: catoDeviceModal(),
68
+ },
69
+ },
70
+ ActorTypePop: {
71
+ Layer: "network",
72
+ Identity: []string{"id"},
73
+ MergeIdentity: []string{"account_id", "pop_name"},
74
+ AggregationScopes: []string{"pop", "network"},
75
+ Search: &topologyv1.ActorSearchPolicy{Columns: []string{"display_name", "pop_name"}},
76
+ Presentation: &topologyv1.ActorPresentation{
77
+ Label: "Cato PoP",
78
+ Role: "actor",
79
+ Icon: "cloud",
80
+ ColorSlot: "blue",
81
+ Border: &topologyv1.BorderPresentation{Enabled: new(true)},
82
+ Size: &topologyv1.ActorSizePresentation{Mode: "link_count", Scale: "normal"},
83
+ LabelPolicy: &topologyv1.LabelPolicy{
84
+ Columns: []string{"display_name", "pop_name"},
85
+ Fallback: "type_label",
86
+ MaxLength: 80,
87
+ Array: "reject",
88
+ },
89
+ },
90
+ },
91
+ ActorTypeBGPPeer: {
92
+ Layer: "network",
93
+ Identity: []string{"id"},
94
+ MergeIdentity: []string{"remote_ip", "remote_asn"},
95
+ ParentIdentity: []string{"site_id"},
96
+ AggregationScopes: []string{"network"},
97
+ Search: &topologyv1.ActorSearchPolicy{Columns: []string{"display_name", "remote_ip", "remote_asn"}},
98
+ Presentation: &topologyv1.ActorPresentation{
99
+ Label: "BGP peer",
100
+ Role: "endpoint",
101
+ Icon: "remote-endpoint",
102
+ ColorSlot: "orange",
103
+ Border: &topologyv1.BorderPresentation{Enabled: new(true)},
104
+ Size: &topologyv1.ActorSizePresentation{Mode: "fixed", Scale: "compact"},
105
+ LabelPolicy: &topologyv1.LabelPolicy{
106
+ Columns: []string{"display_name", "remote_ip", "remote_asn"},
107
+ Fallback: "type_label",
108
+ MaxLength: 80,
109
+ Array: "reject",
110
+ },
111
+ Modal: catoBGPPeerModal(),
112
+ },
113
+ },
114
+ }
115
+}
116
+
117
+func TopologyLinkTypes() map[string]topologyv1.LinkType {
118
+ return map[string]topologyv1.LinkType{
119
+ LinkTypeTunnel: {
120
+ Orientation: "observed_bidirectional",
121
+ DirectionRole: "observation",
122
+ SemanticRole: "traffic",
123
+ Aggregation: topologyv1.LinkAggregation{
124
+ Direction: "canonicalize_unordered",
125
+ Evidence: "drop",
126
+ Metrics: map[string]string{
127
+ "evidence_count": "sum",
128
+ "bytes_upstream_max": "max",
129
+ "bytes_downstream_max": "max",
130
+ "lost_upstream_percent": "avg",
131
+ "rtt_ms": "avg",
132
+ },
133
+ },
134
+ Presentation: &topologyv1.LinkPresentation{
135
+ Label: "Cato tunnel",
136
+ ColorSlot: "green",
137
+ Width: "normal",
138
+ Curve: "straight",
139
+ Arrow: "none",
140
+ },
141
+ },
142
+ LinkTypeBGP: {
143
+ Orientation: "observed_bidirectional",
144
+ DirectionRole: "observation",
145
+ SemanticRole: "control",
146
+ Aggregation: topologyv1.LinkAggregation{
147
+ Direction: "canonicalize_unordered",
148
+ Evidence: "drop",
149
+ Metrics: map[string]string{
150
+ "evidence_count": "sum",
151
+ "routes": "sum",
152
+ "routes_limit": "max",
153
+ "rib_out_routes": "sum",
154
+ },
155
+ },
156
+ Presentation: &topologyv1.LinkPresentation{
157
+ Label: "BGP session",
158
+ ColorSlot: "orange",
159
+ Width: "normal",
160
+ Curve: "straight",
161
+ Arrow: "none",
162
+ },
163
+ },
164
+ }
165
+}
166
+
167
+func TopologyPresentation() *topologyv1.Presentation {
168
+ return &topologyv1.Presentation{
169
+ ProfileVersion: "cato-networks.v1",
170
+ Selection: &topologyv1.SelectionPresentation{
171
+ ActorClick: &topologyv1.ActorClickPresentation{Mode: "highlight_connections"},
172
+ },
173
+ Legend: &topologyv1.PresentationLegend{
174
+ Actors: []topologyv1.LegendEntry{
175
+ {Type: ActorTypeSite, Label: "Cato site"},
176
+ {Type: ActorTypeDevice, Label: "Cato device"},
177
+ {Type: ActorTypePop, Label: "Cato PoP"},
178
+ {Type: ActorTypeBGPPeer, Label: "BGP peer"},
179
+ },
180
+ Links: []topologyv1.LegendEntry{
181
+ {Type: LinkTypeTunnel, Label: "Cato tunnel"},
182
+ {Type: LinkTypeBGP, Label: "BGP session"},
183
+ },
184
+ },
185
+ }
186
+}
187
+
188
+func catoSiteModal() *topologyv1.ModalPresentation {
189
+ return &topologyv1.ModalPresentation{
190
+ MiniTopology: &topologyv1.ModalMiniTopologyPresentation{Depth: 1},
191
+ }
192
+}
193
+
194
+func catoDeviceModal() *topologyv1.ModalPresentation {
195
+ return &topologyv1.ModalPresentation{
196
+ MiniTopology: &topologyv1.ModalMiniTopologyPresentation{Depth: 1},
197
+ Sections: []topologyv1.ModalSection{
198
+ {
199
+ ID: ActorTableInterfaces,
200
+ Label: "Interfaces",
201
+ Order: 1,
202
+ Source: topologyv1.ModalSource{
203
+ Kind: "actor_table",
204
+ Table: ActorTableInterfaces,
205
+ },
206
+ OwnerFilter: &topologyv1.ModalOwnerFilter{
207
+ Mode: "actor_column",
208
+ ActorColumn: "actor",
209
+ },
210
+ Columns: []topologyv1.ModalColumn{
211
+ modalDirectColumn("name", "Name", "name", "text"),
212
+ modalDirectColumn("type", "Type", "type", "badge"),
213
+ modalDirectColumn("connected", "Connected", "connected", "badge"),
214
+ modalDirectColumn("pop_name", "PoP", "pop_name", "text"),
215
+ modalDirectColumn("tunnel_remote_ip", "Remote IP", "tunnel_remote_ip", "text"),
216
+ modalDirectColumn("tunnel_uptime", "Uptime", "tunnel_uptime", "duration"),
217
+ modalDirectColumn("upstream_bandwidth", "Upstream bandwidth", "upstream_bandwidth", "number"),
218
+ modalDirectColumn("downstream_bandwidth", "Downstream bandwidth", "downstream_bandwidth", "number"),
219
+ },
220
+ Sort: &topologyv1.ModalSort{Column: "name", Direction: "asc"},
221
+ },
222
+ },
223
+ }
224
+}
225
+
226
+func catoBGPPeerModal() *topologyv1.ModalPresentation {
227
+ return &topologyv1.ModalPresentation{
228
+ MiniTopology: &topologyv1.ModalMiniTopologyPresentation{Depth: 1},
229
+ Sections: []topologyv1.ModalSection{
230
+ {
231
+ ID: "links",
232
+ Label: "Links",
233
+ Order: 1,
234
+ Source: topologyv1.ModalSource{
235
+ Kind: "links",
236
+ },
237
+ OwnerFilter: &topologyv1.ModalOwnerFilter{
238
+ Mode: "incident_link",
239
+ SrcActorColumn: "src_actor",
240
+ DstActorColumn: "dst_actor",
241
+ },
242
+ Columns: []topologyv1.ModalColumn{
243
+ {
244
+ ID: "remote",
245
+ Label: "Remote Actor",
246
+ Projection: topologyv1.ModalProjection{
247
+ Kind: "opposite_actor",
248
+ SrcActorColumn: "src_actor",
249
+ DstActorColumn: "dst_actor",
250
+ },
251
+ Cell: "actor_link",
252
+ },
253
+ modalDirectColumn("protocol", "Protocol", "protocol", "badge"),
254
+ modalDirectColumn("state", "State", "state", "badge"),
255
+ modalDirectColumn("routes", "Routes", "routes", "number"),
256
+ modalDirectColumn("routes_limit", "Routes limit", "routes_limit", "number"),
257
+ modalDirectColumn("routes_limit_exceeded", "Limit exceeded", "routes_limit_exceeded", "badge"),
258
+ modalDirectColumn("rib_out_routes", "RIB out", "rib_out_routes", "number"),
259
+ },
260
+ },
261
+ },
262
+ }
263
+}
264
+
265
+func modalDirectColumn(id, label, sourceColumn, cell string) topologyv1.ModalColumn {
266
+ return topologyv1.ModalColumn{
267
+ ID: id,
268
+ Label: label,
269
+ Projection: topologyv1.ModalProjection{
270
+ Kind: "direct",
271
+ Column: sourceColumn,
272
+ },
273
+ Cell: cell,
274
+ }
275
+}
src/go/plugin/go.d/collector/cato_networks/catofunc/router.go
new
+57
@@ -0,0 +1,57 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package catofunc
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+
9
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
10
+)
11
+
12
+type router struct {
13
+ deps Deps
14
+
15
+ handlers map[string]funcapi.MethodHandler
16
+}
17
+
18
+func newRouter(deps Deps) *router {
19
+ r := &router{
20
+ deps: deps,
21
+ handlers: make(map[string]funcapi.MethodHandler),
22
+ }
23
+ r.handlers[TopologyMethodID] = newFuncTopology(r)
24
+ return r
25
+}
26
+
27
+var _ funcapi.MethodHandler = (*router)(nil)
28
+
29
+func (r *router) MethodParams(ctx context.Context, method string) ([]funcapi.ParamConfig, error) {
30
+ if h, ok := r.handlers[method]; ok {
31
+ return h.MethodParams(ctx, method)
32
+ }
33
+ return nil, fmt.Errorf("unknown method: %s", method)
34
+}
35
+
36
+func (r *router) Handle(ctx context.Context, method string, params funcapi.ResolvedParams) *funcapi.FunctionResponse {
37
+ if h, ok := r.handlers[method]; ok {
38
+ return h.Handle(ctx, method, params)
39
+ }
40
+ return funcapi.NotFoundResponse(method)
41
+}
42
+
43
+func (r *router) Cleanup(ctx context.Context) {
44
+ for _, h := range r.handlers {
45
+ h.Cleanup(ctx)
46
+ }
47
+}
48
+
49
+func Methods(updateEvery int) []funcapi.MethodConfig {
50
+ return []funcapi.MethodConfig{
51
+ topologyMethodConfig(updateEvery),
52
+ }
53
+}
54
+
55
+func NewRouter(deps Deps) funcapi.MethodHandler {
56
+ return newRouter(deps)
57
+}
src/go/plugin/go.d/collector/cato_networks/catofunc/router_test.go
new
+134
@@ -0,0 +1,134 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package catofunc
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/require"
11
+
12
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
13
+ topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
14
+)
15
+
16
+type fakeDeps struct {
17
+ data *topologyv1.Data
18
+ ok bool
19
+}
20
+
21
+func (d fakeDeps) CurrentTopology() (*topologyv1.Data, bool) {
22
+ return d.data, d.ok
23
+}
24
+
25
+func TestRouter_Handle(t *testing.T) {
26
+ tests := map[string]struct {
27
+ deps fakeDeps
28
+ method string
29
+ check func(*testing.T, *funcapi.FunctionResponse, *topologyv1.Data)
30
+ }{
31
+ "returns current topology": {
32
+ deps: fakeDeps{data: &topologyv1.Data{Producer: topologyv1.Producer{Source: "cato_networks"}}, ok: true},
33
+ method: TopologyMethodID,
34
+ check: func(t *testing.T, resp *funcapi.FunctionResponse, data *topologyv1.Data) {
35
+ require.Equal(t, 200, resp.Status)
36
+ require.Equal(t, "topology", resp.ResponseType)
37
+ require.Same(t, data, resp.Data)
38
+ },
39
+ },
40
+ "returns unavailable without snapshot": {
41
+ method: TopologyMethodID,
42
+ check: func(t *testing.T, resp *funcapi.FunctionResponse, _ *topologyv1.Data) {
43
+ require.Equal(t, 503, resp.Status)
44
+ },
45
+ },
46
+ "rejects unknown method": {
47
+ method: "unknown",
48
+ check: func(t *testing.T, resp *funcapi.FunctionResponse, _ *topologyv1.Data) {
49
+ require.Equal(t, 404, resp.Status)
50
+ },
51
+ },
52
+ }
53
+
54
+ for name, tc := range tests {
55
+ t.Run(name, func(t *testing.T) {
56
+ handler := NewRouter(tc.deps)
57
+ resp := handler.Handle(context.Background(), tc.method, nil)
58
+ tc.check(t, resp, tc.deps.data)
59
+ })
60
+ }
61
+}
62
+
63
+func TestRouter_MethodParams(t *testing.T) {
64
+ tests := map[string]struct {
65
+ method string
66
+ wantErr string
67
+ }{
68
+ "known topology method": {
69
+ method: TopologyMethodID,
70
+ },
71
+ "unknown method": {
72
+ method: "unknown",
73
+ wantErr: "unknown method: unknown",
74
+ },
75
+ }
76
+
77
+ for name, tc := range tests {
78
+ t.Run(name, func(t *testing.T) {
79
+ handler := NewRouter(fakeDeps{})
80
+ params, err := handler.MethodParams(context.Background(), tc.method)
81
+
82
+ if tc.wantErr != "" {
83
+ require.Nil(t, params)
84
+ require.ErrorContains(t, err, tc.wantErr)
85
+ return
86
+ }
87
+ require.NoError(t, err)
88
+ require.Nil(t, params)
89
+ })
90
+ }
91
+}
92
+
93
+func TestRouter_Cleanup(t *testing.T) {
94
+ tests := map[string]struct {
95
+ handlers map[string]funcapi.MethodHandler
96
+ check func(*testing.T, *cleanupHandler)
97
+ }{
98
+ "forwards cleanup to handlers": {
99
+ handlers: map[string]funcapi.MethodHandler{
100
+ "test": &cleanupHandler{},
101
+ },
102
+ check: func(t *testing.T, h *cleanupHandler) {
103
+ require.True(t, h.called)
104
+ },
105
+ },
106
+ }
107
+
108
+ for name, tc := range tests {
109
+ t.Run(name, func(t *testing.T) {
110
+ h := tc.handlers["test"].(*cleanupHandler)
111
+ r := &router{handlers: tc.handlers}
112
+
113
+ r.Cleanup(context.Background())
114
+
115
+ tc.check(t, h)
116
+ })
117
+ }
118
+}
119
+
120
+type cleanupHandler struct {
121
+ called bool
122
+}
123
+
124
+func (h *cleanupHandler) MethodParams(context.Context, string) ([]funcapi.ParamConfig, error) {
125
+ return nil, errors.New("not implemented")
126
+}
127
+
128
+func (h *cleanupHandler) Handle(context.Context, string, funcapi.ResolvedParams) *funcapi.FunctionResponse {
129
+ return nil
130
+}
131
+
132
+func (h *cleanupHandler) Cleanup(context.Context) {
133
+ h.called = true
134
+}
src/go/plugin/go.d/collector/cato_networks/catofunc/topology.go
new
+63
@@ -0,0 +1,63 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package catofunc
4
+
5
+import (
6
+ "context"
7
+
8
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
9
+ topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
10
+)
11
+
12
+const (
13
+ TopologyMethodID = "topology:cato_networks"
14
+
15
+ ActorTypeSite = "cato_site"
16
+ ActorTypeDevice = "cato_device"
17
+ ActorTypePop = "cato_pop"
18
+ ActorTypeBGPPeer = "bgp_peer"
19
+
20
+ LinkTypeTunnel = "cato_tunnel"
21
+ LinkTypeBGP = "bgp_session"
22
+
23
+ ActorTableInterfaces = "interfaces"
24
+)
25
+
26
+const topologyUnavailable = "Cato Networks topology data is not available yet"
27
+
28
+type funcTopology struct {
29
+ router *router
30
+}
31
+
32
+func newFuncTopology(r *router) *funcTopology {
33
+ return &funcTopology{router: r}
34
+}
35
+
36
+var _ funcapi.MethodHandler = (*funcTopology)(nil)
37
+
38
+func (f *funcTopology) MethodParams(context.Context, string) ([]funcapi.ParamConfig, error) {
39
+ return nil, nil
40
+}
41
+
42
+func (f *funcTopology) Handle(_ context.Context, method string, _ funcapi.ResolvedParams) *funcapi.FunctionResponse {
43
+ if method != TopologyMethodID {
44
+ return funcapi.NotFoundResponse(method)
45
+ }
46
+ if f.router == nil || f.router.deps == nil {
47
+ return funcapi.UnavailableResponse(topologyUnavailable)
48
+ }
49
+
50
+ data, ok := f.router.deps.CurrentTopology()
51
+ if !ok {
52
+ return funcapi.UnavailableResponse(topologyUnavailable)
53
+ }
54
+
55
+ return &funcapi.FunctionResponse{
56
+ Status: 200,
57
+ Help: "Cato Networks site, device, PoP, tunnel, and BGP topology data",
58
+ ResponseType: topologyv1.ResponseType,
59
+ Data: data,
60
+ }
61
+}
62
+
63
+func (f *funcTopology) Cleanup(context.Context) {}
src/go/plugin/go.d/collector/cato_networks/charts.yaml
new
+282
@@ -0,0 +1,282 @@
1
+version: v1
2
+context_namespace: cato_networks
3
+groups:
4
+ - family: sites
5
+ metrics:
6
+ - site_connectivity_status
7
+ - site_operational_status
8
+ - site_hosts
9
+ - site_bytes_upstream_max
10
+ - site_bytes_downstream_max
11
+ - site_lost_upstream_percent
12
+ - site_lost_downstream_percent
13
+ - site_packets_discarded_upstream
14
+ - site_packets_discarded_downstream
15
+ - site_jitter_upstream_ms
16
+ - site_jitter_downstream_ms
17
+ - site_rtt_ms
18
+ - site_last_mile_latency_ms
19
+ - site_last_mile_packet_loss_percent
20
+ charts:
21
+ - id: site_hosts
22
+ title: Cato site hosts
23
+ context: site_hosts
24
+ units: hosts
25
+ type: line
26
+ instances:
27
+ by_labels: [site_id]
28
+ label_promotion: [site_name, pop_name]
29
+ dimensions:
30
+ - selector: site_hosts
31
+ name: hosts
32
+ - id: site_connectivity_status
33
+ title: Cato site connectivity status
34
+ context: site_connectivity_status
35
+ units: status
36
+ type: line
37
+ instances:
38
+ by_labels: [site_id]
39
+ label_promotion: [site_name, pop_name]
40
+ dimensions:
41
+ - selector: site_connectivity_status
42
+ - id: site_operational_status
43
+ title: Cato site operational status
44
+ context: site_operational_status
45
+ units: status
46
+ type: line
47
+ instances:
48
+ by_labels: [site_id]
49
+ label_promotion: [site_name, pop_name]
50
+ dimensions:
51
+ - selector: site_operational_status
52
+ - id: site_traffic
53
+ title: Cato site traffic
54
+ context: site_traffic
55
+ units: bytes
56
+ type: area
57
+ instances:
58
+ by_labels: [site_id]
59
+ label_promotion: [site_name, pop_name]
60
+ dimensions:
61
+ - selector: site_bytes_upstream_max
62
+ name: upstream
63
+ - selector: site_bytes_downstream_max
64
+ name: downstream
65
+ - id: site_packet_loss
66
+ title: Cato site packet loss
67
+ context: site_packet_loss
68
+ units: percentage
69
+ type: line
70
+ instances:
71
+ by_labels: [site_id]
72
+ label_promotion: [site_name, pop_name]
73
+ dimensions:
74
+ - selector: site_lost_upstream_percent
75
+ name: upstream
76
+ - selector: site_lost_downstream_percent
77
+ name: downstream
78
+ - id: site_discarded_packets
79
+ title: Cato site discarded packets
80
+ context: site_discarded_packets
81
+ units: packets
82
+ type: line
83
+ instances:
84
+ by_labels: [site_id]
85
+ label_promotion: [site_name, pop_name]
86
+ dimensions:
87
+ - selector: site_packets_discarded_upstream
88
+ name: upstream
89
+ - selector: site_packets_discarded_downstream
90
+ name: downstream
91
+ - id: site_jitter
92
+ title: Cato site jitter
93
+ context: site_jitter
94
+ units: milliseconds
95
+ type: line
96
+ instances:
97
+ by_labels: [site_id]
98
+ label_promotion: [site_name, pop_name]
99
+ dimensions:
100
+ - selector: site_jitter_upstream_ms
101
+ name: upstream
102
+ - selector: site_jitter_downstream_ms
103
+ name: downstream
104
+ - id: site_latency
105
+ title: Cato site latency
106
+ context: site_latency
107
+ units: milliseconds
108
+ type: line
109
+ instances:
110
+ by_labels: [site_id]
111
+ label_promotion: [site_name, pop_name]
112
+ dimensions:
113
+ - selector: site_rtt_ms
114
+ name: rtt
115
+ - selector: site_last_mile_latency_ms
116
+ name: last_mile
117
+ - id: site_last_mile_packet_loss
118
+ title: Cato site last mile packet loss
119
+ context: site_last_mile_packet_loss
120
+ units: percentage
121
+ type: line
122
+ instances:
123
+ by_labels: [site_id]
124
+ label_promotion: [site_name, pop_name]
125
+ dimensions:
126
+ - selector: site_last_mile_packet_loss_percent
127
+ name: loss
128
+ - family: devices
129
+ metrics:
130
+ - device_connection_status
131
+ charts:
132
+ - id: device_connection_status
133
+ title: Cato device connection status
134
+ context: device_connection_status
135
+ units: status
136
+ type: line
137
+ instances:
138
+ by_labels: [site_id, device_id]
139
+ label_promotion: [site_name, device_name]
140
+ dimensions:
141
+ - selector: device_connection_status
142
+ - family: interfaces
143
+ metrics:
144
+ - interface_connection_status
145
+ - interface_tunnel_uptime_seconds
146
+ - interface_bytes_upstream_max
147
+ - interface_bytes_downstream_max
148
+ - interface_lost_upstream_percent
149
+ - interface_lost_downstream_percent
150
+ - interface_packets_discarded_upstream
151
+ - interface_packets_discarded_downstream
152
+ - interface_jitter_upstream_ms
153
+ - interface_jitter_downstream_ms
154
+ - interface_rtt_ms
155
+ charts:
156
+ - id: interface_connection_status
157
+ title: Cato interface connection status
158
+ context: interface_connection_status
159
+ units: status
160
+ type: line
161
+ instances:
162
+ by_labels: [site_id, device_id, interface_id, interface_name]
163
+ label_promotion: [site_name, device_name]
164
+ dimensions:
165
+ - selector: interface_connection_status
166
+ - id: interface_tunnel_uptime
167
+ title: Cato interface tunnel uptime
168
+ context: interface_tunnel_uptime
169
+ units: seconds
170
+ type: line
171
+ instances:
172
+ by_labels: [site_id, device_id, interface_id, interface_name]
173
+ label_promotion: [site_name, device_name]
174
+ dimensions:
175
+ - selector: interface_tunnel_uptime_seconds
176
+ name: uptime
177
+ - id: interface_traffic
178
+ title: Cato interface traffic
179
+ context: interface_traffic
180
+ units: bytes
181
+ type: area
182
+ instances:
183
+ by_labels: [site_id, device_id, interface_id, interface_name]
184
+ label_promotion: [site_name, device_name]
185
+ dimensions:
186
+ - selector: interface_bytes_upstream_max
187
+ name: upstream
188
+ - selector: interface_bytes_downstream_max
189
+ name: downstream
190
+ - id: interface_packet_loss
191
+ title: Cato interface packet loss
192
+ context: interface_packet_loss
193
+ units: percentage
194
+ type: line
195
+ instances:
196
+ by_labels: [site_id, device_id, interface_id, interface_name]
197
+ label_promotion: [site_name, device_name]
198
+ dimensions:
199
+ - selector: interface_lost_upstream_percent
200
+ name: upstream
201
+ - selector: interface_lost_downstream_percent
202
+ name: downstream
203
+ - id: interface_discarded_packets
204
+ title: Cato interface discarded packets
205
+ context: interface_discarded_packets
206
+ units: packets
207
+ type: line
208
+ instances:
209
+ by_labels: [site_id, device_id, interface_id, interface_name]
210
+ label_promotion: [site_name, device_name]
211
+ dimensions:
212
+ - selector: interface_packets_discarded_upstream
213
+ name: upstream
214
+ - selector: interface_packets_discarded_downstream
215
+ name: downstream
216
+ - id: interface_jitter
217
+ title: Cato interface jitter
218
+ context: interface_jitter
219
+ units: milliseconds
220
+ type: line
221
+ instances:
222
+ by_labels: [site_id, device_id, interface_id, interface_name]
223
+ label_promotion: [site_name, device_name]
224
+ dimensions:
225
+ - selector: interface_jitter_upstream_ms
226
+ name: upstream
227
+ - selector: interface_jitter_downstream_ms
228
+ name: downstream
229
+ - id: interface_latency
230
+ title: Cato interface latency
231
+ context: interface_latency
232
+ units: milliseconds
233
+ type: line
234
+ instances:
235
+ by_labels: [site_id, device_id, interface_id, interface_name]
236
+ label_promotion: [site_name, device_name]
237
+ dimensions:
238
+ - selector: interface_rtt_ms
239
+ name: rtt
240
+ - family: bgp
241
+ metrics:
242
+ - bgp_session_status
243
+ - bgp_routes
244
+ - bgp_routes_limit
245
+ - bgp_routes_limit_status
246
+ - bgp_rib_out_routes
247
+ charts:
248
+ - id: bgp_session_status
249
+ title: Cato BGP session status
250
+ context: bgp_session_status
251
+ units: status
252
+ type: line
253
+ instances:
254
+ by_labels: [site_id, peer_ip, peer_asn]
255
+ label_promotion: [site_name]
256
+ dimensions:
257
+ - selector: bgp_session_status
258
+ - id: bgp_routes
259
+ title: Cato BGP routes
260
+ context: bgp_routes
261
+ units: routes
262
+ type: line
263
+ instances:
264
+ by_labels: [site_id, peer_ip, peer_asn]
265
+ label_promotion: [site_name]
266
+ dimensions:
267
+ - selector: bgp_routes
268
+ name: accepted
269
+ - selector: bgp_routes_limit
270
+ name: limit
271
+ - selector: bgp_rib_out_routes
272
+ name: rib_out
273
+ - id: bgp_routes_limit_status
274
+ title: Cato BGP route limit status
275
+ context: bgp_routes_limit_status
276
+ units: status
277
+ type: line
278
+ instances:
279
+ by_labels: [site_id, peer_ip, peer_asn]
280
+ label_promotion: [site_name]
281
+ dimensions:
282
+ - selector: bgp_routes_limit_status
src/go/plugin/go.d/collector/cato_networks/charts_test.go
new
+52
@@ -0,0 +1,52 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/require"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
13
+)
14
+
15
+func TestCollector_ChartTemplateYAML(t *testing.T) {
16
+ tests := map[string]struct {
17
+ check func(*testing.T, string)
18
+ }{
19
+ "matches schema": {
20
+ check: func(t *testing.T, yaml string) {
21
+ collecttest.AssertChartTemplateSchema(t, yaml)
22
+ },
23
+ },
24
+ "compiles": {
25
+ check: func(t *testing.T, yaml string) {
26
+ spec, err := charttpl.DecodeYAML([]byte(yaml))
27
+ require.NoError(t, err)
28
+ _, err = chartengine.Compile(spec, 1)
29
+ require.NoError(t, err)
30
+ },
31
+ },
32
+ "charts use default lifecycle": {
33
+ check: func(t *testing.T, yaml string) {
34
+ spec, err := charttpl.DecodeYAML([]byte(yaml))
35
+ require.NoError(t, err)
36
+
37
+ for _, group := range spec.Groups {
38
+ for _, chart := range group.Charts {
39
+ require.Nil(t, chart.Lifecycle, "chart %s must use the default lifecycle", chart.ID)
40
+ }
41
+ }
42
+ },
43
+ },
44
+ }
45
+
46
+ c := New()
47
+ for name, tc := range tests {
48
+ t.Run(name, func(t *testing.T) {
49
+ tc.check(t, c.ChartTemplateYAML())
50
+ })
51
+ }
52
+}
src/go/plugin/go.d/collector/cato_networks/client.go
new
+253
@@ -0,0 +1,253 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "bytes"
7
+ "context"
8
+ "encoding/json"
9
+ "errors"
10
+ "fmt"
11
+ "io"
12
+ "net/http"
13
+ "strings"
14
+
15
+ catosdk "github.com/catonetworks/cato-go-sdk"
16
+ catomodels "github.com/catonetworks/cato-go-sdk/models"
17
+)
18
+
19
+type apiClient interface {
20
+ Probe(ctx context.Context, accountID string) error
21
+ LookupSites(ctx context.Context, accountID string, limit, from int64) (*catosdk.EntityLookup, error)
22
+ AccountSnapshot(ctx context.Context, accountID string, siteIDs []string) (*catosdk.AccountSnapshot, error)
23
+ AccountMetrics(ctx context.Context, accountID string, siteIDs []string, timeFrame string, buckets int64, groupInterfaces *bool) (*catosdk.AccountMetrics, error)
24
+ SiteBgpStatus(ctx context.Context, accountID, siteID string) ([]*catosdk.SiteBgpStatusResult, error)
25
+}
26
+
27
+type sdkAPIClient struct {
28
+ client *catosdk.Client
29
+ raw rawGraphQLClient
30
+}
31
+
32
+type rawGraphQLClient struct {
33
+ url string
34
+ apiKey string
35
+ headers map[string]string
36
+ httpClient *http.Client
37
+}
38
+
39
+func newSDKAPIClient(cfg Config, httpClient *http.Client) (apiClient, error) {
40
+ headers := catoRequestHeaders(cfg.Headers)
41
+
42
+ client, err := catosdk.New(cfg.URL, cfg.APIKey, cfg.AccountID, httpClient, headers)
43
+ if err != nil {
44
+ return nil, err
45
+ }
46
+
47
+ return &sdkAPIClient{
48
+ client: client,
49
+ raw: rawGraphQLClient{
50
+ url: cfg.URL,
51
+ apiKey: cfg.APIKey,
52
+ headers: headers,
53
+ httpClient: httpClient,
54
+ },
55
+ }, nil
56
+}
57
+
58
+func catoRequestHeaders(src map[string]string) map[string]string {
59
+ headers := make(map[string]string, len(src)+1)
60
+ for key, value := range src {
61
+ if isCatoReservedHeader(key) {
62
+ continue
63
+ }
64
+ headers[key] = value
65
+ }
66
+ if !hasCatoHeader(headers, "User-Agent") {
67
+ headers["User-Agent"] = "Netdata go.d.plugin cato_networks"
68
+ }
69
+ return headers
70
+}
71
+
72
+func hasCatoHeader(headers map[string]string, key string) bool {
73
+ want := http.CanonicalHeaderKey(strings.TrimSpace(key))
74
+ for existing := range headers {
75
+ if http.CanonicalHeaderKey(strings.TrimSpace(existing)) == want {
76
+ return true
77
+ }
78
+ }
79
+ return false
80
+}
81
+
82
+func (c *sdkAPIClient) Probe(ctx context.Context, accountID string) error {
83
+ limit := int64(1)
84
+ from := int64(0)
85
+ _, err := c.client.EntityLookup(ctx, accountID, catomodels.EntityTypeSite, &limit, &from, nil, nil, nil, nil, nil, nil)
86
+ if err != nil {
87
+ return fmt.Errorf("entityLookup: %w", err)
88
+ }
89
+ return nil
90
+}
91
+
92
+func (c *sdkAPIClient) LookupSites(ctx context.Context, accountID string, limit, from int64) (*catosdk.EntityLookup, error) {
93
+ res, err := c.client.EntityLookup(ctx, accountID, catomodels.EntityTypeSite, &limit, &from, nil, nil, nil, nil, nil, nil)
94
+ if err != nil {
95
+ return nil, fmt.Errorf("entityLookup: %w", err)
96
+ }
97
+ return res, nil
98
+}
99
+
100
+func (c *sdkAPIClient) AccountSnapshot(ctx context.Context, accountID string, siteIDs []string) (*catosdk.AccountSnapshot, error) {
101
+ res, err := c.client.AccountSnapshot(ctx, siteIDs, nil, &accountID)
102
+ if err != nil && isAccountSnapshotEnumDecodeError(err) {
103
+ res, err = c.raw.AccountSnapshot(ctx, accountID, siteIDs)
104
+ }
105
+ if err != nil {
106
+ return nil, fmt.Errorf("accountSnapshot: %w", err)
107
+ }
108
+ return res, nil
109
+}
110
+
111
+func (c *sdkAPIClient) AccountMetrics(ctx context.Context, accountID string, siteIDs []string, timeFrame string, buckets int64, groupInterfaces *bool) (*catosdk.AccountMetrics, error) {
112
+ labels := []catomodels.TimeseriesMetricType{
113
+ catomodels.TimeseriesMetricTypeBytesUpstreamMax,
114
+ catomodels.TimeseriesMetricTypeBytesDownstreamMax,
115
+ catomodels.TimeseriesMetricTypeLostUpstreamPcnt,
116
+ catomodels.TimeseriesMetricTypeLostDownstreamPcnt,
117
+ catomodels.TimeseriesMetricTypeJitterUpstream,
118
+ catomodels.TimeseriesMetricTypeJitterDownstream,
119
+ catomodels.TimeseriesMetricTypePacketsDiscardedUpstream,
120
+ catomodels.TimeseriesMetricTypePacketsDiscardedDownstream,
121
+ catomodels.TimeseriesMetricTypeRtt,
122
+ catomodels.TimeseriesMetricTypeLastMileLatency,
123
+ catomodels.TimeseriesMetricTypeLastMilePacketLoss,
124
+ }
125
+
126
+ res, err := c.client.AccountMetrics(ctx,
127
+ nil, nil, nil, &buckets,
128
+ labels,
129
+ nil, nil, nil, nil, nil, nil, nil, nil,
130
+ siteIDs,
131
+ nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
132
+ nil, nil, &accountID, nil, timeFrame, groupInterfaces, nil,
133
+ )
134
+ if err != nil {
135
+ return nil, fmt.Errorf("accountMetrics: %w", err)
136
+ }
137
+ return res, nil
138
+}
139
+
140
+func (c *sdkAPIClient) SiteBgpStatus(ctx context.Context, accountID, siteID string) ([]*catosdk.SiteBgpStatusResult, error) {
141
+ input := catomodels.SiteBgpStatusInput{
142
+ Site: &catomodels.SiteRefInput{
143
+ By: catomodels.ObjectRefByID,
144
+ Input: siteID,
145
+ },
146
+ }
147
+
148
+ _, res, err := c.client.SiteBgpStatus(ctx, input, accountID)
149
+ if err != nil {
150
+ return nil, fmt.Errorf("siteBgpStatus: %w", err)
151
+ }
152
+ return res, nil
153
+}
154
+
155
+type rawGraphQLRequest struct {
156
+ OperationName string `json:"operationName,omitempty"`
157
+ Query string `json:"query"`
158
+ Variables map[string]any `json:"variables,omitempty"`
159
+}
160
+
161
+type rawGraphQLError struct {
162
+ Message string `json:"message"`
163
+}
164
+
165
+type rawAccountSnapshotResponse struct {
166
+ Data catosdk.AccountSnapshot `json:"data"`
167
+ Errors []rawGraphQLError `json:"errors"`
168
+ NetworkErrors []rawGraphQLError `json:"networkErrors"`
169
+ GraphQLErrors []rawGraphQLError `json:"graphqlErrors"`
170
+}
171
+
172
+func (c rawGraphQLClient) AccountSnapshot(ctx context.Context, accountID string, siteIDs []string) (*catosdk.AccountSnapshot, error) {
173
+ payload := rawGraphQLRequest{
174
+ OperationName: "accountSnapshot",
175
+ Query: catosdk.AccountSnapshotDocument,
176
+ Variables: map[string]any{
177
+ "siteIDs": siteIDs,
178
+ "userIDs": nil,
179
+ "accountID": accountID,
180
+ },
181
+ }
182
+
183
+ body, err := json.Marshal(payload)
184
+ if err != nil {
185
+ return nil, err
186
+ }
187
+
188
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
189
+ if err != nil {
190
+ return nil, err
191
+ }
192
+ for key, value := range c.headers {
193
+ if isCatoReservedHeader(key) {
194
+ continue
195
+ }
196
+ req.Header.Set(key, value)
197
+ }
198
+ req.Header.Set("Content-Type", "application/json")
199
+ req.Header.Set("x-api-key", c.apiKey)
200
+ req.Header.Set("x-account-id", accountID)
201
+
202
+ resp, err := c.httpClient.Do(req)
203
+ if err != nil {
204
+ return nil, err
205
+ }
206
+ defer resp.Body.Close()
207
+
208
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
209
+ _, _ = io.Copy(io.Discard, resp.Body)
210
+ return nil, fmt.Errorf("http status %d", resp.StatusCode)
211
+ }
212
+
213
+ var decoded rawAccountSnapshotResponse
214
+ if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
215
+ return nil, err
216
+ }
217
+ if msg := firstRawGraphQLError(decoded.Errors, decoded.NetworkErrors, decoded.GraphQLErrors); msg != "" {
218
+ return nil, fmt.Errorf("graphql: %s", msg)
219
+ }
220
+ if decoded.Data.AccountSnapshot == nil {
221
+ return nil, errors.New("graphql accountSnapshot returned no data")
222
+ }
223
+
224
+ return &decoded.Data, nil
225
+}
226
+
227
+func firstRawGraphQLError(groups ...[]rawGraphQLError) string {
228
+ for _, group := range groups {
229
+ for _, err := range group {
230
+ if msg := strings.TrimSpace(err.Message); msg != "" {
231
+ return msg
232
+ }
233
+ }
234
+ }
235
+ return ""
236
+}
237
+
238
+func isCatoReservedHeader(key string) bool {
239
+ switch http.CanonicalHeaderKey(strings.TrimSpace(key)) {
240
+ case "Content-Type", "X-Api-Key", "X-Account-Id":
241
+ return true
242
+ default:
243
+ return false
244
+ }
245
+}
246
+
247
+func isAccountSnapshotEnumDecodeError(err error) bool {
248
+ if err == nil {
249
+ return false
250
+ }
251
+ msg := err.Error()
252
+ return strings.Contains(msg, "ConnectivityStatus") && strings.Contains(msg, "not a valid")
253
+}
src/go/plugin/go.d/collector/cato_networks/client_test.go
new
+160
@@ -0,0 +1,160 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "io"
8
+ "net/http"
9
+ "net/http/httptest"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/stretchr/testify/require"
14
+
15
+ "github.com/netdata/netdata/go/plugins/pkg/confopt"
16
+)
17
+
18
+func TestCatoRequestHeaders(t *testing.T) {
19
+ headers := catoRequestHeaders(map[string]string{
20
+ "Content-Type": "text/plain",
21
+ "x-api-key": "wrong-key",
22
+ "X-Account-Id": "wrong-account",
23
+ "user-agent": "custom-agent",
24
+ "X-Custom-Header": "custom-value",
25
+ })
26
+
27
+ require.False(t, hasCatoHeader(headers, "Content-Type"))
28
+ require.False(t, hasCatoHeader(headers, "x-api-key"))
29
+ require.False(t, hasCatoHeader(headers, "x-account-id"))
30
+ require.True(t, hasCatoHeader(headers, "User-Agent"))
31
+ require.Equal(t, "custom-agent", headers["user-agent"])
32
+ require.Equal(t, "custom-value", headers["X-Custom-Header"])
33
+}
34
+
35
+func TestRawGraphQLClient(t *testing.T) {
36
+ tests := map[string]struct {
37
+ handler func(*testing.T, http.ResponseWriter, *http.Request)
38
+ client func(string, *http.Client) rawGraphQLClient
39
+ check func(*testing.T, *rawGraphQLClient)
40
+ }{
41
+ "account snapshot uses method account id": {
42
+ handler: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
43
+ require.Equal(t, "argument-account", r.Header.Get("x-account-id"))
44
+ body, err := io.ReadAll(r.Body)
45
+ require.NoError(t, err)
46
+ require.Contains(t, string(body), `"accountID":"argument-account"`)
47
+ w.Header().Set("Content-Type", "application/json")
48
+ _, err = w.Write([]byte(`{"data":{"accountSnapshot":{"sites":[{"id":"1001","connectivityStatusSiteSnapshot":"connected"}]}}}`))
49
+ require.NoError(t, err)
50
+ },
51
+ client: func(url string, httpClient *http.Client) rawGraphQLClient {
52
+ return rawGraphQLClient{url: url, apiKey: "secret", httpClient: httpClient}
53
+ },
54
+ check: func(t *testing.T, client *rawGraphQLClient) {
55
+ snapshot, err := client.AccountSnapshot(context.Background(), "argument-account", []string{"1001"})
56
+ require.NoError(t, err)
57
+ require.Len(t, snapshot.GetAccountSnapshot().GetSites(), 1)
58
+ },
59
+ },
60
+ "does not override reserved headers": {
61
+ handler: func(t *testing.T, w http.ResponseWriter, r *http.Request) {
62
+ require.Equal(t, "application/json", r.Header.Get("Content-Type"))
63
+ require.Equal(t, "secret", r.Header.Get("x-api-key"))
64
+ require.Equal(t, "argument-account", r.Header.Get("x-account-id"))
65
+ require.Equal(t, "custom-value", r.Header.Get("x-custom-header"))
66
+ w.Header().Set("Content-Type", "application/json")
67
+ _, err := w.Write([]byte(`{"data":{"accountSnapshot":{"sites":[]}}}`))
68
+ require.NoError(t, err)
69
+ },
70
+ client: func(url string, httpClient *http.Client) rawGraphQLClient {
71
+ return rawGraphQLClient{
72
+ url: url,
73
+ apiKey: "secret",
74
+ httpClient: httpClient,
75
+ headers: map[string]string{
76
+ "Content-Type": "text/plain",
77
+ "x-api-key": "wrong-key",
78
+ "X-Account-Id": "wrong-account",
79
+ "X-Custom-Header": "custom-value",
80
+ },
81
+ }
82
+ },
83
+ check: func(t *testing.T, client *rawGraphQLClient) {
84
+ _, err := client.AccountSnapshot(context.Background(), "argument-account", nil)
85
+ require.NoError(t, err)
86
+ },
87
+ },
88
+ }
89
+
90
+ for name, tc := range tests {
91
+ t.Run(name, func(t *testing.T) {
92
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93
+ tc.handler(t, w, r)
94
+ }))
95
+ defer server.Close()
96
+
97
+ client := tc.client(server.URL, server.Client())
98
+ tc.check(t, &client)
99
+ })
100
+ }
101
+}
102
+
103
+func TestSDKClient(t *testing.T) {
104
+ tests := map[string]struct {
105
+ setup func(*testing.T) (*httptest.Server, Config, *int)
106
+ check func(*testing.T, apiClient, *int)
107
+ }{
108
+ "account snapshot falls back on enum decode error": {
109
+ setup: func(t *testing.T) (*httptest.Server, Config, *int) {
110
+ var calls int
111
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
112
+ calls++
113
+ w.Header().Set("Content-Type", "application/json")
114
+ _, err := w.Write([]byte(`{"data":{"accountSnapshot":{"sites":[{"id":"1001","connectivityStatusSiteSnapshot":"degraded","operationalStatusSiteSnapshot":"active"}]}}}`))
115
+ require.NoError(t, err)
116
+ }))
117
+ cfg := Config{AccountID: "12345", APIKey: "secret"}
118
+ cfg.URL = server.URL
119
+ return server, cfg, &calls
120
+ },
121
+ check: func(t *testing.T, client apiClient, calls *int) {
122
+ snapshot, err := client.AccountSnapshot(context.Background(), "12345", []string{"1001"})
123
+ require.NoError(t, err)
124
+ require.Equal(t, 2, *calls)
125
+ require.Len(t, snapshot.GetAccountSnapshot().GetSites(), 1)
126
+ require.Equal(t, "degraded", connectivityStatusString(snapshot.GetAccountSnapshot().GetSites()[0].GetConnectivityStatusSiteSnapshot()))
127
+ },
128
+ },
129
+ }
130
+
131
+ for name, tc := range tests {
132
+ t.Run(name, func(t *testing.T) {
133
+ server, cfg, calls := tc.setup(t)
134
+ defer server.Close()
135
+
136
+ client, err := newSDKAPIClient(cfg, server.Client())
137
+ require.NoError(t, err)
138
+ tc.check(t, client, calls)
139
+ })
140
+ }
141
+}
142
+
143
+func TestSDKClientClassifiesHTTPClientTimeout(t *testing.T) {
144
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
145
+ time.Sleep(50 * time.Millisecond)
146
+ }))
147
+ defer server.Close()
148
+
149
+ c := New()
150
+ c.URL = server.URL
151
+ c.AccountID = "12345"
152
+ c.APIKey = "secret"
153
+ c.Timeout = confopt.Duration(time.Millisecond)
154
+
155
+ require.NoError(t, c.Init(context.Background()))
156
+ err := collectError(t, c)
157
+
158
+ require.Error(t, err)
159
+ require.Equal(t, "timeout", classifyCatoError(err))
160
+}
src/go/plugin/go.d/collector/cato_networks/collect.go
new
+214
@@ -0,0 +1,214 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "strings"
10
+ "time"
11
+)
12
+
13
+type discoveryState struct {
14
+ siteIDs []string
15
+ siteNames map[string]string
16
+ fetchedAt time.Time
17
+ skippedBySelector int
18
+}
19
+
20
+func (c *Collector) collect(ctx context.Context) error {
21
+ if c.client == nil {
22
+ return errors.New("Cato client is not initialized")
23
+ }
24
+
25
+ if err := c.refreshDiscovery(ctx, false); err != nil {
26
+ return wrapCatoOperationError("site discovery", err)
27
+ }
28
+ if len(c.discovery.siteIDs) == 0 {
29
+ return errors.New("no Cato sites discovered")
30
+ }
31
+
32
+ sites, order, err := c.collectSnapshot(ctx)
33
+ if err != nil {
34
+ return wrapCatoOperationError("account snapshot", err)
35
+ }
36
+ if len(sites) == 0 {
37
+ return errors.New("no Cato sites returned by account snapshot")
38
+ }
39
+ c.pruneUnselectedSites(sites, &order)
40
+
41
+ if err := c.collectMetrics(ctx, sites); err != nil {
42
+ errorClass := classifyCatoError(err)
43
+ c.warnRecoverable(warningKeyMetrics, errorClass, "account metrics collection incomplete, error_class=%s", errorClass)
44
+ }
45
+
46
+ if err := c.collectBGP(ctx, sites, order); err != nil {
47
+ errorClass := classifyCatoError(err)
48
+ c.warnRecoverable(warningKeyBGP, errorClass, "BGP status collection incomplete, error_class=%s", errorClass)
49
+ }
50
+
51
+ c.pruneUnselectedSites(sites, &order)
52
+
53
+ now := c.now()
54
+ topo, err := buildTopology(c.AccountID, sites, order, now)
55
+ if err != nil {
56
+ return fmt.Errorf("build topology: %w", err)
57
+ }
58
+ c.topology.Publish(topo)
59
+
60
+ c.writeMetrics(sites, order)
61
+
62
+ return nil
63
+}
64
+
65
+func (c *Collector) refreshDiscovery(ctx context.Context, force bool) error {
66
+ now := c.now()
67
+ if !force && len(c.discovery.siteIDs) > 0 {
68
+ if now.Sub(c.discovery.fetchedAt) < seconds(defaultDiscoveryEvery) {
69
+ return nil
70
+ }
71
+ }
72
+
73
+ limit := int64(defaultDiscoveryLimit)
74
+ var from int64
75
+ siteNames := make(map[string]string)
76
+ var siteIDs []string
77
+
78
+ for {
79
+ if from/limit >= maxDiscoveryPages {
80
+ err := fmt.Errorf("entityLookup pagination exceeded %d pages", maxDiscoveryPages)
81
+ if c.useCachedDiscoveryAfterRefreshFailure(now, force, err) {
82
+ return nil
83
+ }
84
+ return err
85
+ }
86
+
87
+ res, err := c.client.LookupSites(ctx, c.AccountID, limit, from)
88
+ if err != nil {
89
+ if c.useCachedDiscoveryAfterRefreshFailure(now, force, err) {
90
+ return nil
91
+ }
92
+ return err
93
+ }
94
+
95
+ items := res.GetEntityLookup().GetItems()
96
+ for _, item := range items {
97
+ entity := item.GetEntity()
98
+ siteID := strings.TrimSpace(entity.GetID())
99
+ if siteID == "" {
100
+ continue
101
+ }
102
+ siteIDs = append(siteIDs, siteID)
103
+ if name := derefZero(entity.GetName()); name != "" {
104
+ siteNames[siteID] = name
105
+ }
106
+ }
107
+
108
+ total := derefZero(res.GetEntityLookup().GetTotal())
109
+ from += int64(len(items))
110
+ if len(items) == 0 || (total > 0 && from >= total) || int64(len(items)) < limit {
111
+ break
112
+ }
113
+ }
114
+
115
+ selectedSiteIDs, skippedBySelector := c.selectSites(siteIDs, siteNames)
116
+ c.discovery = discoveryState{
117
+ siteIDs: selectedSiteIDs,
118
+ siteNames: siteNames,
119
+ fetchedAt: now,
120
+ skippedBySelector: skippedBySelector,
121
+ }
122
+ if skippedBySelector > 0 {
123
+ c.Limit("cato:site_selector", 1, recurringLogEvery).
124
+ Infof("selected %d of %d Cato site(s) after applying site_selector", len(selectedSiteIDs), len(siteIDs))
125
+ }
126
+ return nil
127
+}
128
+
129
+func (c *Collector) selectSites(siteIDs []string, siteNames map[string]string) ([]string, int) {
130
+ selected := make([]string, 0, len(siteIDs))
131
+ var skippedSelector int
132
+
133
+ for _, siteID := range siteIDs {
134
+ if !c.siteMatcher.MatchString(siteSelectorValue(siteID, siteNames[siteID])) {
135
+ skippedSelector++
136
+ continue
137
+ }
138
+ selected = append(selected, siteID)
139
+ }
140
+
141
+ return selected, skippedSelector
142
+}
143
+
144
+func siteSelectorValue(siteID, siteName string) string {
145
+ if name := strings.TrimSpace(siteName); name != "" {
146
+ return name
147
+ }
148
+ return strings.TrimSpace(siteID)
149
+}
150
+
151
+func (c *Collector) pruneUnselectedSites(sites map[string]*siteState, order *[]string) {
152
+ active := make(map[string]bool, len(c.discovery.siteIDs))
153
+ nextOrder := make([]string, 0, len(c.discovery.siteIDs))
154
+ for _, siteID := range c.discovery.siteIDs {
155
+ if sites[siteID] == nil {
156
+ continue
157
+ }
158
+ active[siteID] = true
159
+ nextOrder = append(nextOrder, siteID)
160
+ }
161
+ for siteID := range sites {
162
+ if !active[siteID] {
163
+ delete(sites, siteID)
164
+ }
165
+ }
166
+ *order = nextOrder
167
+}
168
+
169
+func (c *Collector) useCachedDiscoveryAfterRefreshFailure(now time.Time, force bool, err error) bool {
170
+ if force || len(c.discovery.siteIDs) == 0 {
171
+ return false
172
+ }
173
+
174
+ c.discovery.fetchedAt = now
175
+ errorClass := classifyCatoError(err)
176
+ c.warnRecoverable(warningKeyDiscoveryCache, errorClass, "entityLookup refresh failed; using cached discovery for %d site(s), error_class=%s", len(c.discovery.siteIDs), errorClass)
177
+ return true
178
+}
179
+
180
+func (c *Collector) collectSnapshot(ctx context.Context) (map[string]*siteState, []string, error) {
181
+ res, err := c.client.AccountSnapshot(ctx, c.AccountID, c.discovery.siteIDs)
182
+ if err != nil {
183
+ return nil, nil, err
184
+ }
185
+
186
+ sites, order := normalizeSnapshot(res, c.discovery.siteNames)
187
+ if len(order) == 0 {
188
+ return sites, order, nil
189
+ }
190
+
191
+ seen := make(map[string]bool, len(order))
192
+ for _, siteID := range order {
193
+ seen[siteID] = true
194
+ }
195
+ for _, siteID := range c.discovery.siteIDs {
196
+ if seen[siteID] {
197
+ continue
198
+ }
199
+ sites[siteID] = &siteState{
200
+ ID: siteID,
201
+ Name: siteDisplayName(siteID, c.discovery.siteNames, "", ""),
202
+ ConnectivityStatus: "unknown",
203
+ OperationalStatus: "unknown",
204
+ Interfaces: make(map[string]*interfaceState),
205
+ }
206
+ order = append(order, siteID)
207
+ }
208
+
209
+ return sites, order, nil
210
+}
211
+
212
+func seconds(v int) time.Duration {
213
+ return time.Duration(v) * time.Second
214
+}
src/go/plugin/go.d/collector/cato_networks/collect_bgp.go
new
+99
@@ -0,0 +1,99 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "hash/fnv"
9
+ "time"
10
+)
11
+
12
+type bgpState struct {
13
+ noBGPUntil map[string]time.Time
14
+}
15
+
16
+func (c *Collector) collectBGP(ctx context.Context, sites map[string]*siteState, order []string) error {
17
+ now := c.now()
18
+ pruneNoBGPCache(c.bgp.noBGPUntil, order, now)
19
+
20
+ var requestCount int
21
+ var errCount int
22
+ for _, siteID := range order {
23
+ if c.isNoBGPCached(siteID, now) {
24
+ continue
25
+ }
26
+
27
+ requestCount++
28
+ raw, err := c.client.SiteBgpStatus(ctx, c.AccountID, siteID)
29
+ if err != nil {
30
+ errCount++
31
+ c.Debugf("siteBgpStatus failed for one site, error_class=%s", classifyCatoError(err))
32
+ continue
33
+ }
34
+
35
+ peers, issues := normalizeBGP(raw)
36
+ for _, issue := range issues {
37
+ c.logNormalizationIssue(normalizationSurfaceBGP, issue)
38
+ }
39
+
40
+ if len(peers) == 0 {
41
+ if len(issues) > 0 {
42
+ continue
43
+ }
44
+ c.bgp.noBGPUntil[siteID] = c.noBGPCacheUntil(siteID, now)
45
+ continue
46
+ }
47
+ delete(c.bgp.noBGPUntil, siteID)
48
+ if site := sites[siteID]; site != nil {
49
+ site.BGPPeers = peers
50
+ }
51
+ }
52
+
53
+ if requestCount == 0 {
54
+ return nil
55
+ }
56
+ if errCount == requestCount {
57
+ return fmt.Errorf("all siteBgpStatus requests failed")
58
+ }
59
+ if errCount > 0 {
60
+ return fmt.Errorf("%d of %d siteBgpStatus requests failed", errCount, requestCount)
61
+ }
62
+ return nil
63
+}
64
+
65
+func (c *Collector) isNoBGPCached(siteID string, now time.Time) bool {
66
+ until, ok := c.bgp.noBGPUntil[siteID]
67
+ return ok && now.Before(until)
68
+}
69
+
70
+func (c *Collector) noBGPCacheUntil(siteID string, now time.Time) time.Time {
71
+ return now.Add(seconds(defaultNoBGPCacheTTL) + c.noBGPCacheJitter(siteID))
72
+}
73
+
74
+func (c *Collector) noBGPCacheJitter(siteID string) time.Duration {
75
+ interval := seconds(c.UpdateEvery)
76
+ if interval <= 0 {
77
+ interval = seconds(defaultUpdateEvery)
78
+ }
79
+
80
+ window := interval * 5
81
+ h := fnv.New64a()
82
+ _, _ = h.Write([]byte(siteID))
83
+ return time.Duration(h.Sum64() % uint64(window))
84
+}
85
+
86
+func pruneNoBGPCache(noBGPUntil map[string]time.Time, order []string, now time.Time) {
87
+ if len(noBGPUntil) == 0 {
88
+ return
89
+ }
90
+ active := make(map[string]bool, len(order))
91
+ for _, siteID := range order {
92
+ active[siteID] = true
93
+ }
94
+ for siteID, until := range noBGPUntil {
95
+ if !active[siteID] || !now.Before(until) {
96
+ delete(noBGPUntil, siteID)
97
+ }
98
+ }
99
+}
src/go/plugin/go.d/collector/cato_networks/collect_metrics.go
new
+56
@@ -0,0 +1,56 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "sort"
9
+
10
+ catosdk "github.com/catonetworks/cato-go-sdk"
11
+ "github.com/sourcegraph/conc/pool"
12
+)
13
+
14
+type accountMetricsResult struct {
15
+ siteID string
16
+ res *catosdk.AccountMetrics
17
+ err error
18
+}
19
+
20
+func (c *Collector) collectMetrics(ctx context.Context, sites map[string]*siteState) error {
21
+ ids := append([]string(nil), c.discovery.siteIDs...)
22
+ if len(ids) == 0 {
23
+ for id := range sites {
24
+ ids = append(ids, id)
25
+ }
26
+ sort.Strings(ids)
27
+ }
28
+
29
+ p := pool.NewWithResults[accountMetricsResult]().WithMaxGoroutines(defaultMetricsParallel)
30
+ for _, siteID := range ids {
31
+ p.Go(func() accountMetricsResult {
32
+ res, err := c.client.AccountMetrics(ctx, c.AccountID, []string{siteID}, defaultMetricsTimeFrame, defaultMetricsBuckets, nil)
33
+ return accountMetricsResult{siteID: siteID, res: res, err: err}
34
+ })
35
+ }
36
+
37
+ var errCount int
38
+ for _, result := range p.Wait() {
39
+ if result.err != nil {
40
+ errCount++
41
+ c.Debugf("accountMetrics query failed for site_id=%s, error_class=%s", result.siteID, classifyCatoError(result.err))
42
+ continue
43
+ }
44
+ for _, issue := range mergeMetrics(result.res, sites) {
45
+ c.logNormalizationIssue(normalizationSurfaceMetrics, issue)
46
+ }
47
+ }
48
+
49
+ if errCount > 0 && errCount == len(ids) {
50
+ return fmt.Errorf("all accountMetrics site queries failed")
51
+ }
52
+ if errCount > 0 {
53
+ return fmt.Errorf("%d of %d accountMetrics site queries failed", errCount, len(ids))
54
+ }
55
+ return nil
56
+}
src/go/plugin/go.d/collector/cato_networks/collector.go
new
+147
@@ -0,0 +1,147 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ _ "embed"
8
+ "errors"
9
+ "fmt"
10
+ "net/http"
11
+ "time"
12
+
13
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
14
+ "github.com/netdata/netdata/go/plugins/pkg/matcher"
15
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
16
+ "github.com/netdata/netdata/go/plugins/pkg/web"
17
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
18
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/cato_networks/catofunc"
19
+)
20
+
21
+//go:embed "config_schema.json"
22
+var configSchema string
23
+
24
+//go:embed "charts.yaml"
25
+var chartTemplateYAML string
26
+
27
+func init() {
28
+ collectorapi.Register("cato_networks", collectorapi.Creator{
29
+ JobConfigSchema: configSchema,
30
+ Defaults: collectorapi.Defaults{
31
+ UpdateEvery: defaultUpdateEvery,
32
+ AutoDetectionRetry: 0,
33
+ },
34
+ CreateV2: func() collectorapi.CollectorV2 { return New() },
35
+ Config: func() any { return &Config{} },
36
+ Methods: catoMethods,
37
+ MethodHandler: catoFunctionHandler,
38
+ })
39
+}
40
+
41
+func New() *Collector {
42
+ store := metrix.NewCollectorStore()
43
+
44
+ c := &Collector{
45
+ Config: Config{
46
+ UpdateEvery: defaultUpdateEvery,
47
+ HTTPConfig: web.HTTPConfig{
48
+ RequestConfig: web.RequestConfig{
49
+ URL: defaultEndpoint,
50
+ },
51
+ ClientConfig: web.ClientConfig{
52
+ Timeout: defaultTimeout,
53
+ },
54
+ },
55
+ SiteSelector: defaultEntitySelector,
56
+ },
57
+ store: store,
58
+ metrics: newCollectorMetrics(store),
59
+ newClient: newSDKAPIClient,
60
+ siteMatcher: matcher.TRUE(),
61
+ now: time.Now,
62
+ }
63
+ c.funcRouter = catofunc.NewRouter(funcDepsAdapter{store: &c.topology})
64
+ return c
65
+}
66
+
67
+type Collector struct {
68
+ collectorapi.Base
69
+ Config `yaml:",inline" json:""`
70
+
71
+ store metrix.CollectorStore
72
+ metrics *collectorMetrics
73
+
74
+ httpClient *http.Client
75
+ client apiClient
76
+ newClient func(Config, *http.Client) (apiClient, error)
77
+
78
+ funcRouter funcapi.MethodHandler
79
+ topology topologyStore
80
+
81
+ discovery discoveryState
82
+ bgp bgpState
83
+
84
+ siteMatcher matcher.Matcher
85
+
86
+ now func() time.Time
87
+}
88
+
89
+func (c *Collector) Configuration() any { return c.Config }
90
+
91
+func (c *Collector) Init(context.Context) error {
92
+ c.Config.applyDefaults()
93
+ if err := c.Config.validate(); err != nil {
94
+ return fmt.Errorf("config validation: %w", err)
95
+ }
96
+ if err := c.initSiteSelector(); err != nil {
97
+ return err
98
+ }
99
+ if err := c.initClient(); err != nil {
100
+ return err
101
+ }
102
+
103
+ c.discovery = discoveryState{}
104
+ c.bgp = bgpState{noBGPUntil: make(map[string]time.Time)}
105
+
106
+ return nil
107
+}
108
+
109
+func (c *Collector) Check(ctx context.Context) error {
110
+ if c.client == nil {
111
+ return errors.New("Cato client is not initialized")
112
+ }
113
+ if err := c.client.Probe(ctx, c.AccountID); err != nil {
114
+ return wrapCatoOperationError("Cato API probe", err)
115
+ }
116
+ return nil
117
+}
118
+
119
+func (c *Collector) Collect(ctx context.Context) error {
120
+ if err := c.collect(ctx); err != nil {
121
+ if ctxErr := contextErr(ctx); ctxErr != nil {
122
+ return ctxErr
123
+ }
124
+ return err
125
+ }
126
+ return contextErr(ctx)
127
+}
128
+
129
+func (c *Collector) Cleanup(ctx context.Context) {
130
+ if c.funcRouter != nil {
131
+ c.funcRouter.Cleanup(ctx)
132
+ }
133
+ if c.httpClient != nil {
134
+ c.httpClient.CloseIdleConnections()
135
+ }
136
+}
137
+
138
+func (c *Collector) MetricStore() metrix.CollectorStore { return c.store }
139
+
140
+func (c *Collector) ChartTemplateYAML() string { return chartTemplateYAML }
141
+
142
+func contextErr(ctx context.Context) error {
143
+ if ctx == nil {
144
+ return nil
145
+ }
146
+ return ctx.Err()
147
+}
src/go/plugin/go.d/collector/cato_networks/collector_collect_test.go
new
+783
@@ -0,0 +1,783 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "net/http"
9
+ "testing"
10
+ "time"
11
+
12
+ catosdk "github.com/catonetworks/cato-go-sdk"
13
+ catomodels "github.com/catonetworks/cato-go-sdk/models"
14
+ "github.com/stretchr/testify/require"
15
+
16
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
17
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
18
+)
19
+
20
+func TestCollector_Collect(t *testing.T) {
21
+ type collectStep struct {
22
+ name string
23
+ setup func(*testing.T, *Collector, *fakeAPIClient)
24
+ collect func(*testing.T, *Collector) (map[string]metrix.SampleValue, error)
25
+ wantErr string
26
+ wantErrIs error
27
+ wantClass string
28
+ wantMetrics map[string]metrix.SampleValue
29
+ wantMissing []string
30
+ check func(*testing.T, *Collector, *fakeAPIClient, map[string]metrix.SampleValue, error)
31
+ }
32
+ tests := map[string]struct {
33
+ setup func(*testing.T, *Collector, *fakeAPIClient)
34
+ skipInit bool
35
+ steps []collectStep
36
+ }{
37
+ "fixture metrics and topology": {
38
+ steps: []collectStep{{
39
+ name: "collects metrics and topology",
40
+ wantMetrics: map[string]metrix.SampleValue{
41
+ stateMetricKey("site_connectivity_status", "connected", siteLabels("1001", "Paris Office", "POP-Paris")): 1,
42
+ stateMetricKey("site_connectivity_status", "degraded", siteLabels("1002", "Toulouse Office", "POP-Toulouse")): 1,
43
+ stateMetricKey("device_connection_status", "connected", deviceLabels("1001", "Paris Office", "dev-1", "Socket 1")): 1,
44
+ metricKey("interface_bytes_upstream_max", interfaceLabels("1001", "Paris Office", "", "", "", "all")): 7168,
45
+ metricKey("site_bytes_upstream_max", siteLabels("1001", "Paris Office", "POP-Paris")): 7168,
46
+ metricKey("site_packets_discarded_upstream", siteLabels("1001", "Paris Office", "POP-Paris")): 2,
47
+ metricKey("interface_packets_discarded_upstream", interfaceLabels("1001", "Paris Office", "", "", "", "all")): 2,
48
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
49
+ stateMetricKey("bgp_routes_limit_status", "ok", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
50
+ metricKey("bgp_routes", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 12,
51
+ metricKey("bgp_routes_limit", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 100,
52
+ metricKey("bgp_rib_out_routes", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
53
+ },
54
+ check: func(t *testing.T, c *Collector, _ *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
55
+ topo, ok := c.topology.CurrentTopology()
56
+ require.True(t, ok)
57
+ require.Equal(t, topologySource, topo.Producer.Source)
58
+ require.Equal(t, 6, topo.Actors.Rows)
59
+ require.Equal(t, 3, topo.Links.Rows)
60
+ requireSiteHostScope(t, c, "1001", "Paris Office", "POP-Paris")
61
+ requireSiteHostScope(t, c, "1002", "Toulouse Office", "POP-Toulouse")
62
+ require.Empty(t, scalarSeriesFromReader(c.store.Read(metrix.ReadFlatten(), metrix.ReadHostScope(""))))
63
+ scoped1001 := scalarSeriesFromReader(c.store.Read(
64
+ metrix.ReadFlatten(),
65
+ metrix.ReadHostScope(c.siteHostScope(&siteState{ID: "1001"}).ScopeKey),
66
+ ))
67
+ requireMetricValues(t, scoped1001, map[string]metrix.SampleValue{
68
+ stateMetricKey("site_connectivity_status", "connected", siteLabels("1001", "Paris Office", "POP-Paris")): 1,
69
+ stateMetricKey("device_connection_status", "connected", deviceLabels("1001", "Paris Office", "dev-1", "Socket 1")): 1,
70
+ metricKey("interface_bytes_upstream_max", interfaceLabels("1001", "Paris Office", "", "", "", "all")): 7168,
71
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
72
+ })
73
+ collecttest.AssertChartCoverage(t, c, collecttest.ChartCoverageExpectation{})
74
+ },
75
+ }},
76
+ },
77
+ "raw Centreon fixture through SDK": {
78
+ setup: func(t *testing.T, c *Collector, _ *fakeAPIClient) {
79
+ server := newRawCatoFixtureServer(t)
80
+ t.Cleanup(server.Close)
81
+ c.client = nil
82
+ c.URL = server.URL
83
+ },
84
+ steps: []collectStep{{
85
+ name: "decodes raw fixture",
86
+ wantMetrics: map[string]metrix.SampleValue{
87
+ stateMetricKey("site_connectivity_status", "connected", siteLabels("1001", "Paris Office", "POP-Paris")): 1,
88
+ stateMetricKey("site_connectivity_status", "disconnected", siteLabels("1002", "Toulouse Office", "POP-Toulouse")): 1,
89
+ stateMetricKey("site_connectivity_status", "degraded", siteLabels("1003", "Saint Girons Office", "POP-Ariege")): 1,
90
+ metricKey("site_bytes_upstream_max", siteLabels("1001", "Paris Office", "POP-Paris")): 4684,
91
+ metricKey("site_packets_discarded_downstream", siteLabels("1001", "Paris Office", "POP-Paris")): 1,
92
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
93
+ metricKey("bgp_routes", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 12,
94
+ metricKey("bgp_routes_limit", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 100,
95
+ metricKey("bgp_rib_out_routes", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
96
+ },
97
+ }},
98
+ },
99
+ "default accountMetrics SDK arguments": {
100
+ steps: []collectStep{{
101
+ name: "one ungrouped query per site",
102
+ check: func(t *testing.T, _ *Collector, fake *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
103
+ require.ElementsMatch(t, [][]string{{"1001"}, {"1002"}}, fake.metricsSiteIDs)
104
+ require.Len(t, fake.groupInterfaces, 2)
105
+ require.Nil(t, fake.groupInterfaces[0])
106
+ require.Nil(t, fake.groupInterfaces[1])
107
+ },
108
+ }},
109
+ },
110
+ "partial accountMetrics failure is fail-soft": {
111
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
112
+ fake.metricsErrSites = map[string]error{"1002": errors.New("rate limit exceeded")}
113
+ },
114
+ steps: []collectStep{{
115
+ name: "keeps successful site metrics",
116
+ wantMetrics: map[string]metrix.SampleValue{
117
+ metricKey("site_bytes_upstream_max", siteLabels("1001", "Paris Office", "POP-Paris")): 7168,
118
+ },
119
+ check: func(t *testing.T, _ *Collector, fake *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
120
+ require.ElementsMatch(t, [][]string{{"1001"}, {"1002"}}, fake.metricsSiteIDs)
121
+ },
122
+ }},
123
+ },
124
+ "late cancellation during fail-soft metrics returns cancellation": func() struct {
125
+ setup func(*testing.T, *Collector, *fakeAPIClient)
126
+ skipInit bool
127
+ steps []collectStep
128
+ } {
129
+ ctx, cancel := context.WithCancel(context.Background())
130
+ return struct {
131
+ setup func(*testing.T, *Collector, *fakeAPIClient)
132
+ skipInit bool
133
+ steps []collectStep
134
+ }{
135
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
136
+ fake.metricsErrSites = map[string]error{"1002": errors.New("rate limit exceeded")}
137
+ fake.metricsHook = func(_ context.Context, siteIDs []string) {
138
+ if len(siteIDs) == 1 && siteIDs[0] == "1002" {
139
+ cancel()
140
+ }
141
+ }
142
+ },
143
+ steps: []collectStep{{
144
+ name: "returns cancellation after metrics fan-out",
145
+ wantErrIs: context.Canceled,
146
+ collect: func(t *testing.T, c *Collector) (map[string]metrix.SampleValue, error) {
147
+ t.Helper()
148
+ t.Cleanup(cancel)
149
+
150
+ cc := mustCycleController(t, c.store)
151
+ cc.BeginCycle()
152
+ err := c.Collect(ctx)
153
+ cc.AbortCycle()
154
+ return nil, err
155
+ },
156
+ }},
157
+ }
158
+ }(),
159
+ "unknown timeseries labels do not fail collection": {
160
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
161
+ iface := fake.metrics.GetAccountMetrics().GetSites()[0].GetInterfaces()[0]
162
+ iface.Timeseries = append(iface.Timeseries, &catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces_Timeseries{
163
+ Label: "renamedByVendor",
164
+ Data: [][]float64{{1, 42}},
165
+ })
166
+ },
167
+ steps: []collectStep{{
168
+ name: "still emits known metrics",
169
+ wantMetrics: map[string]metrix.SampleValue{
170
+ metricKey("site_bytes_upstream_max", siteLabels("1001", "Paris Office", "POP-Paris")): 7168,
171
+ },
172
+ }},
173
+ },
174
+ "partial BGP failure is fail-soft": {
175
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
176
+ fake.bgpErrSites = map[string]error{"1002": errors.New("site bgp failed")}
177
+ },
178
+ steps: []collectStep{{
179
+ name: "keeps successful site data",
180
+ wantMetrics: map[string]metrix.SampleValue{
181
+ metricKey("site_bytes_upstream_max", siteLabels("1001", "Paris Office", "POP-Paris")): 7168,
182
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
183
+ },
184
+ }},
185
+ },
186
+ "partial BGP failure drops stale failed site state": func() struct {
187
+ setup func(*testing.T, *Collector, *fakeAPIClient)
188
+ skipInit bool
189
+ steps []collectStep
190
+ } {
191
+ now := fixedCatoTestNow()
192
+ stalePeerLabels := bgpLabels("1002", "Toulouse Office", "192.0.2.20", "64513")
193
+ return struct {
194
+ setup func(*testing.T, *Collector, *fakeAPIClient)
195
+ skipInit bool
196
+ steps []collectStep
197
+ }{
198
+ setup: func(_ *testing.T, c *Collector, fake *fakeAPIClient) {
199
+ c.now = func() time.Time { return now }
200
+ fake.bgp["1002"] = []*catosdk.SiteBgpStatusResult{
201
+ {
202
+ RemoteIP: "192.0.2.20",
203
+ RemoteASN: "64513",
204
+ BGPSession: "Established",
205
+ },
206
+ }
207
+ },
208
+ steps: []collectStep{
209
+ {
210
+ name: "emit initial peer",
211
+ wantMetrics: map[string]metrix.SampleValue{
212
+ stateMetricKey("bgp_session_status", "up", stalePeerLabels): 1,
213
+ },
214
+ },
215
+ {
216
+ name: "omit failed site peer next cycle",
217
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
218
+ fake.bgpErrSites = map[string]error{"1002": errors.New("site bgp failed")}
219
+ },
220
+ wantMetrics: map[string]metrix.SampleValue{
221
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
222
+ },
223
+ wantMissing: []string{
224
+ stateMetricKey("bgp_session_status", "up", stalePeerLabels),
225
+ },
226
+ },
227
+ },
228
+ }
229
+ }(),
230
+ "unrecognized statuses map to unknown": {
231
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
232
+ fake.snapshot = &catosdk.AccountSnapshot{AccountSnapshot: &catosdk.AccountSnapshot_AccountSnapshot{
233
+ Sites: []*catosdk.AccountSnapshot_AccountSnapshot_Sites{
234
+ {
235
+ ID: new("1001"),
236
+ ConnectivityStatusSiteSnapshot: connectivityPtr("Initializing"),
237
+ OperationalStatusSiteSnapshot: operationalPtr("Maintenance"),
238
+ PopName: new("POP-Paris"),
239
+ InfoSiteSnapshot: &catosdk.AccountSnapshot_AccountSnapshot_Sites_InfoSiteSnapshot{
240
+ Name: new("Paris Office"),
241
+ },
242
+ },
243
+ },
244
+ }}
245
+ },
246
+ steps: []collectStep{{
247
+ name: "emits unknown states",
248
+ wantMetrics: map[string]metrix.SampleValue{
249
+ stateMetricKey("site_connectivity_status", "unknown", siteLabels("1001", "Paris Office", "POP-Paris")): 1,
250
+ stateMetricKey("site_operational_status", "unknown", siteLabels("1001", "Paris Office", "POP-Paris")): 1,
251
+ },
252
+ }},
253
+ },
254
+ "empty discovery fails collection": {
255
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
256
+ total := int64(0)
257
+ fake.lookup = &catosdk.EntityLookup{EntityLookup: catosdk.EntityLookup_EntityLookup{Total: &total}}
258
+ },
259
+ steps: []collectStep{{
260
+ name: "returns empty error",
261
+ wantErr: "no Cato sites discovered",
262
+ wantClass: "empty",
263
+ }},
264
+ },
265
+ "discovers multiple pages": {
266
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
267
+ ids := numberedSiteIDs(1001, 205)
268
+ fake.lookupPages = map[int64]*catosdk.EntityLookup{
269
+ 0: fixtureLookupPage(205, ids[:100]...),
270
+ 100: fixtureLookupPage(205, ids[100:200]...),
271
+ 200: fixtureLookupPage(205, ids[200:]...),
272
+ }
273
+ },
274
+ steps: []collectStep{{
275
+ name: "stores discovered IDs in order",
276
+ check: func(t *testing.T, c *Collector, _ *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
277
+ require.Len(t, c.discovery.siteIDs, 205)
278
+ require.Equal(t, "1001", c.discovery.siteIDs[0])
279
+ require.Equal(t, "1205", c.discovery.siteIDs[len(c.discovery.siteIDs)-1])
280
+ },
281
+ }},
282
+ },
283
+ "applies site selector": {
284
+ setup: func(_ *testing.T, c *Collector, fake *fakeAPIClient) {
285
+ c.SiteSelector = "!Toulouse* Paris*"
286
+ total := int64(3)
287
+ siteType := catomodels.EntityTypeSite
288
+ fake.lookup = &catosdk.EntityLookup{EntityLookup: catosdk.EntityLookup_EntityLookup{
289
+ Total: &total,
290
+ Items: []*catosdk.EntityLookup_EntityLookup_Items{
291
+ {Entity: catosdk.EntityLookup_EntityLookup_Items_Entity{ID: "1001", Name: new("Paris Office"), Type: siteType}},
292
+ {Entity: catosdk.EntityLookup_EntityLookup_Items_Entity{ID: "1002", Name: new("Toulouse Office"), Type: siteType}},
293
+ {Entity: catosdk.EntityLookup_EntityLookup_Items_Entity{ID: "1003", Name: new("Madrid Office"), Type: siteType}},
294
+ },
295
+ }}
296
+ },
297
+ steps: []collectStep{{
298
+ name: "collects selected site only",
299
+ wantMetrics: map[string]metrix.SampleValue{
300
+ stateMetricKey("site_connectivity_status", "connected", siteLabels("1001", "Paris Office", "POP-Paris")): 1,
301
+ },
302
+ wantMissing: []string{
303
+ stateMetricKey("site_connectivity_status", "degraded", siteLabels("1002", "Toulouse Office", "POP-Toulouse")),
304
+ },
305
+ check: func(t *testing.T, c *Collector, _ *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
306
+ require.Equal(t, []string{"1001"}, c.discovery.siteIDs)
307
+ },
308
+ }},
309
+ },
310
+ "collects all interfaces and BGP peers": {
311
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
312
+ snapshot := fixtureSnapshot()
313
+ snapshot.GetAccountSnapshot().GetSites()[0].GetDevices()[0].Interfaces = append(
314
+ snapshot.GetAccountSnapshot().GetSites()[0].GetDevices()[0].GetInterfaces(),
315
+ &catosdk.AccountSnapshot_AccountSnapshot_Sites_Devices_Interfaces{
316
+ ID: new("wan2"),
317
+ Name: new("WAN 2"),
318
+ Connected: new(true),
319
+ },
320
+ )
321
+ fake.snapshot = snapshot
322
+ fake.bgp["1001"] = append(fake.bgp["1001"], &catosdk.SiteBgpStatusResult{
323
+ RemoteIP: "192.0.2.11",
324
+ RemoteASN: "64513",
325
+ BGPSession: "Established",
326
+ })
327
+ },
328
+ steps: []collectStep{{
329
+ name: "emits all entity label sets",
330
+ wantMetrics: map[string]metrix.SampleValue{
331
+ stateMetricKey("interface_connection_status", "connected", interfaceLabels("1001", "Paris Office", "dev-1", "Socket 1", "wan1", "WAN 1")): 1,
332
+ stateMetricKey("interface_connection_status", "connected", interfaceLabels("1001", "Paris Office", "dev-1", "Socket 1", "wan2", "WAN 2")): 1,
333
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
334
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.11", "64513")): 1,
335
+ },
336
+ }},
337
+ },
338
+ "uses cached discovery when refresh fails after bootstrap": {
339
+ setup: func(_ *testing.T, c *Collector, _ *fakeAPIClient) {
340
+ now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
341
+ c.now = func() time.Time { return now }
342
+ },
343
+ steps: []collectStep{
344
+ {name: "bootstrap"},
345
+ {
346
+ name: "uses cache after refresh failure",
347
+ setup: func(_ *testing.T, c *Collector, fake *fakeAPIClient) {
348
+ now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC).Add(seconds(defaultDiscoveryEvery) + time.Second)
349
+ c.now = func() time.Time { return now }
350
+ fake.lookupErr = errors.New("connection refused")
351
+ },
352
+ wantMetrics: map[string]metrix.SampleValue{
353
+ stateMetricKey("site_connectivity_status", "connected", siteLabels("1001", "Paris Office", "POP-Paris")): 1,
354
+ },
355
+ check: func(t *testing.T, c *Collector, _ *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
356
+ require.Equal(t, []string{"1001", "1002"}, c.discovery.siteIDs)
357
+ require.Equal(t, time.Date(2026, 5, 1, 13, 0, 1, 0, time.UTC), c.discovery.fetchedAt)
358
+ },
359
+ },
360
+ },
361
+ },
362
+ "BGP failures are not cached": {
363
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
364
+ fake.bgpErrSites = map[string]error{
365
+ "1001": errors.New("rate limit exceeded"),
366
+ "1002": errors.New("rate limit exceeded"),
367
+ }
368
+ },
369
+ steps: []collectStep{
370
+ {
371
+ name: "first failed BGP cycle",
372
+ wantMissing: []string{
373
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")),
374
+ },
375
+ check: func(t *testing.T, c *Collector, fake *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
376
+ require.Equal(t, 2, fake.bgpCalls)
377
+ require.Empty(t, c.bgp.noBGPUntil)
378
+ },
379
+ },
380
+ {
381
+ name: "failed BGP sites are retried next cycle",
382
+ check: func(t *testing.T, c *Collector, fake *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
383
+ require.Equal(t, 4, fake.bgpCalls)
384
+ require.Empty(t, c.bgp.noBGPUntil)
385
+ },
386
+ },
387
+ },
388
+ },
389
+ "filters empty BGP peers": {
390
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
391
+ fake.bgp["1001"] = []*catosdk.SiteBgpStatusResult{{}}
392
+ },
393
+ steps: []collectStep{{
394
+ name: "omits empty peer label set",
395
+ wantMissing: []string{
396
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "", "")),
397
+ },
398
+ check: func(t *testing.T, c *Collector, _ *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
399
+ require.NotContains(t, c.bgp.noBGPUntil, "1001")
400
+ },
401
+ }},
402
+ },
403
+ "caches sites without BGP peers for one hour with jitter": {
404
+ setup: func(_ *testing.T, c *Collector, _ *fakeAPIClient) {
405
+ now := fixedCatoTestNow()
406
+ c.now = func() time.Time { return now }
407
+ },
408
+ steps: []collectStep{
409
+ {
410
+ name: "initial collection caches no-peer site",
411
+ wantMetrics: map[string]metrix.SampleValue{
412
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
413
+ },
414
+ check: func(t *testing.T, c *Collector, fake *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
415
+ require.Equal(t, 2, fake.bgpCalls)
416
+ require.NotContains(t, c.bgp.noBGPUntil, "1001")
417
+ require.Equal(t, c.noBGPCacheUntil("1002", fixedCatoTestNow()), c.bgp.noBGPUntil["1002"])
418
+ },
419
+ },
420
+ {
421
+ name: "no-peer site is skipped within TTL",
422
+ setup: func(_ *testing.T, c *Collector, fake *fakeAPIClient) {
423
+ now := fixedCatoTestNow().Add(time.Minute)
424
+ c.now = func() time.Time { return now }
425
+ fake.lookupErr = errors.New("unexpected discovery refresh")
426
+ fake.bgpErrSites = map[string]error{
427
+ "1002": errors.New("unexpected bgp refresh"),
428
+ }
429
+ },
430
+ wantMetrics: map[string]metrix.SampleValue{
431
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1001", "Paris Office", "192.0.2.10", "64512")): 1,
432
+ },
433
+ check: func(t *testing.T, _ *Collector, fake *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
434
+ require.Equal(t, 3, fake.bgpCalls)
435
+ },
436
+ },
437
+ {
438
+ name: "expired no-peer site is polled again",
439
+ setup: func(_ *testing.T, c *Collector, fake *fakeAPIClient) {
440
+ now := c.noBGPCacheUntil("1002", fixedCatoTestNow()).Add(time.Second)
441
+ c.now = func() time.Time { return now }
442
+ fake.lookupErr = nil
443
+ fake.bgpErrSites = nil
444
+ fake.bgp["1002"] = []*catosdk.SiteBgpStatusResult{
445
+ {
446
+ RemoteIP: "192.0.2.20",
447
+ RemoteASN: "64513",
448
+ BGPSession: "Established",
449
+ },
450
+ }
451
+ },
452
+ wantMetrics: map[string]metrix.SampleValue{
453
+ stateMetricKey("bgp_session_status", "up", bgpLabels("1002", "Toulouse Office", "192.0.2.20", "64513")): 1,
454
+ },
455
+ check: func(t *testing.T, c *Collector, fake *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
456
+ require.Equal(t, 5, fake.bgpCalls)
457
+ require.NotContains(t, c.bgp.noBGPUntil, "1002")
458
+ },
459
+ },
460
+ },
461
+ },
462
+ "collection fails before BGP polling": {
463
+ setup: func(_ *testing.T, c *Collector, fake *fakeAPIClient) {
464
+ fake.bgp["1002"] = []*catosdk.SiteBgpStatusResult{
465
+ {
466
+ RemoteIP: "192.0.2.20",
467
+ RemoteASN: "64513",
468
+ BGPSession: "Established",
469
+ },
470
+ }
471
+ now := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
472
+ c.now = func() time.Time { return now }
473
+ },
474
+ steps: []collectStep{
475
+ {name: "first collection"},
476
+ {
477
+ name: "snapshot failure aborts before BGP polling",
478
+ setup: func(_ *testing.T, c *Collector, fake *fakeAPIClient) {
479
+ bgpCalls := fake.bgpCalls
480
+ fake.snapshotErr = errors.New("snapshot unavailable")
481
+ now := time.Date(2026, 5, 1, 12, 1, 0, 0, time.UTC)
482
+ c.now = func() time.Time { return now }
483
+ fake.bgpCalls = bgpCalls
484
+ },
485
+ wantErr: "account snapshot failed",
486
+ check: func(t *testing.T, _ *Collector, fake *fakeAPIClient, _ map[string]metrix.SampleValue, _ error) {
487
+ require.Equal(t, 2, fake.bgpCalls)
488
+ },
489
+ },
490
+ },
491
+ },
492
+ "provider errors are classified": {
493
+ setup: func(t *testing.T, c *Collector, _ *fakeAPIClient) {
494
+ server := newRawCatoFixtureServerWithResponses(t, map[string]rawCatoResponse{
495
+ operationDiscovery: {
496
+ status: http.StatusUnauthorized,
497
+ body: `{"errors":[{"message":"Unauthorized"}]}`,
498
+ },
499
+ })
500
+ t.Cleanup(server.Close)
501
+ c.client = nil
502
+ c.URL = server.URL
503
+ },
504
+ steps: []collectStep{{
505
+ name: "auth failure",
506
+ wantErr: "site discovery failed",
507
+ wantClass: "auth",
508
+ }},
509
+ },
510
+ "rate limit errors are classified": {
511
+ setup: func(t *testing.T, c *Collector, _ *fakeAPIClient) {
512
+ server := newRawCatoFixtureServerWithResponses(t, map[string]rawCatoResponse{
513
+ operationSnapshot: {
514
+ status: http.StatusTooManyRequests,
515
+ body: `{"errors":[{"message":"rate limit exceeded"}]}`,
516
+ },
517
+ })
518
+ t.Cleanup(server.Close)
519
+ c.client = nil
520
+ c.URL = server.URL
521
+ },
522
+ steps: []collectStep{{
523
+ name: "snapshot rate limit",
524
+ wantErr: "account snapshot failed",
525
+ wantClass: "rate_limit",
526
+ }},
527
+ },
528
+ "canceled context returns cancellation": {
529
+ setup: func(_ *testing.T, _ *Collector, fake *fakeAPIClient) {
530
+ fake.lookupErr = context.Canceled
531
+ },
532
+ steps: []collectStep{{
533
+ name: "context canceled",
534
+ wantErrIs: context.Canceled,
535
+ collect: func(t *testing.T, c *Collector) (map[string]metrix.SampleValue, error) {
536
+ cc := mustCycleController(t, c.store)
537
+ ctx, cancel := context.WithCancel(context.Background())
538
+ cancel()
539
+ cc.BeginCycle()
540
+ err := c.Collect(ctx)
541
+ cc.AbortCycle()
542
+ return nil, err
543
+ },
544
+ }},
545
+ },
546
+ "provider error is sanitized": {
547
+ setup: func(t *testing.T, c *Collector, _ *fakeAPIClient) {
548
+ server := newRawCatoFixtureServerWithResponses(t, map[string]rawCatoResponse{
549
+ operationDiscovery: {
550
+ status: http.StatusUnauthorized,
551
+ body: `{"errors":[{"message":"Unauthorized secret token leaked by upstream"}]}`,
552
+ },
553
+ })
554
+ t.Cleanup(server.Close)
555
+ c.client = nil
556
+ c.URL = server.URL
557
+ },
558
+ steps: []collectStep{{
559
+ name: "redacts provider detail",
560
+ wantErr: "site discovery failed",
561
+ wantClass: "auth",
562
+ check: func(t *testing.T, _ *Collector, _ *fakeAPIClient, _ map[string]metrix.SampleValue, err error) {
563
+ require.Contains(t, err.Error(), "error_class=auth")
564
+ require.NotContains(t, err.Error(), "secret")
565
+ require.NotContains(t, err.Error(), "Unauthorized")
566
+ },
567
+ }},
568
+ },
569
+ }
570
+
571
+ for name, tc := range tests {
572
+ t.Run(name, func(t *testing.T) {
573
+ c, fake := newTestCollector()
574
+ if tc.setup != nil {
575
+ tc.setup(t, c, fake)
576
+ }
577
+ if !tc.skipInit {
578
+ initCollector(t, c)
579
+ }
580
+
581
+ for _, step := range tc.steps {
582
+ t.Run(step.name, func(t *testing.T) {
583
+ if step.setup != nil {
584
+ step.setup(t, c, fake)
585
+ }
586
+ collect := step.collect
587
+ if collect == nil {
588
+ collect = collectScalarSeries
589
+ }
590
+
591
+ mx, err := collect(t, c)
592
+ if step.wantErr != "" || step.wantErrIs != nil {
593
+ require.Error(t, err)
594
+ if step.wantErr != "" {
595
+ require.ErrorContains(t, err, step.wantErr)
596
+ }
597
+ if step.wantErrIs != nil {
598
+ require.ErrorIs(t, err, step.wantErrIs)
599
+ }
600
+ if step.wantClass != "" {
601
+ require.Equal(t, step.wantClass, classifyCatoError(err))
602
+ }
603
+ if step.check != nil {
604
+ step.check(t, c, fake, mx, err)
605
+ }
606
+ return
607
+ }
608
+
609
+ require.NoError(t, err)
610
+ requireMetricValues(t, mx, step.wantMetrics)
611
+ requireMetricsMissing(t, mx, step.wantMissing)
612
+ if step.check != nil {
613
+ step.check(t, c, fake, mx, err)
614
+ }
615
+ })
616
+ }
617
+ })
618
+ }
619
+}
620
+
621
+func TestCollector_WriteMetrics(t *testing.T) {
622
+ tests := map[string]struct {
623
+ sites map[string]*siteState
624
+ order []string
625
+ want map[string]metrix.SampleValue
626
+ }{
627
+ "distinguishes duplicate interface names": {
628
+ sites: map[string]*siteState{
629
+ "1001": {
630
+ ID: "1001",
631
+ Name: "Paris Office",
632
+ Interfaces: make(map[string]*interfaceState),
633
+ },
634
+ },
635
+ order: []string{"1001"},
636
+ want: map[string]metrix.SampleValue{
637
+ metricKey("interface_bytes_upstream_max", interfaceLabels("1001", "Paris Office", "", "", "wan1", "WAN")): 100,
638
+ metricKey("interface_bytes_upstream_max", interfaceLabels("1001", "Paris Office", "", "", "wan2", "WAN")): 200,
639
+ },
640
+ },
641
+ }
642
+
643
+ for name, tc := range tests {
644
+ t.Run(name, func(t *testing.T) {
645
+ tc.sites["1001"].Interfaces[interfaceKey("wan1", "WAN")] = &interfaceState{
646
+ ID: "wan1",
647
+ Name: "WAN",
648
+ Metrics: trafficMetrics{
649
+ present: trafficMetricBytesUpstreamMax,
650
+ BytesUpstreamMax: 100,
651
+ },
652
+ }
653
+ tc.sites["1001"].Interfaces[interfaceKey("wan2", "WAN")] = &interfaceState{
654
+ ID: "wan2",
655
+ Name: "WAN",
656
+ Metrics: trafficMetrics{
657
+ present: trafficMetricBytesUpstreamMax,
658
+ BytesUpstreamMax: 200,
659
+ },
660
+ }
661
+
662
+ c := New()
663
+ cc := mustCycleController(t, c.store)
664
+ cc.BeginCycle()
665
+ c.writeMetrics(tc.sites, tc.order)
666
+ cc.CommitCycleSuccess()
667
+
668
+ got := scalarSeriesFromAllHostScopes(c.store)
669
+ requireMetricValues(t, got, tc.want)
670
+ })
671
+ }
672
+}
673
+
674
+func TestNoBGPCache(t *testing.T) {
675
+ tests := map[string]struct {
676
+ setup func(map[string]time.Time, time.Time)
677
+ want []string
678
+ }{
679
+ "keeps active unexpired sites": {
680
+ setup: func(noBGPUntil map[string]time.Time, now time.Time) {
681
+ noBGPUntil["1002"] = now.Add(time.Hour)
682
+ },
683
+ want: []string{"1002"},
684
+ },
685
+ "prunes inactive sites": {
686
+ setup: func(noBGPUntil map[string]time.Time, now time.Time) {
687
+ noBGPUntil["1001"] = now.Add(time.Hour)
688
+ noBGPUntil["1002"] = now.Add(time.Hour)
689
+ },
690
+ want: []string{"1002"},
691
+ },
692
+ "prunes expired sites": {
693
+ setup: func(noBGPUntil map[string]time.Time, now time.Time) {
694
+ noBGPUntil["1002"] = now.Add(-time.Second)
695
+ },
696
+ },
697
+ }
698
+
699
+ for name, tc := range tests {
700
+ t.Run(name, func(t *testing.T) {
701
+ now := fixedCatoTestNow()
702
+ noBGPUntil := map[string]time.Time{}
703
+ tc.setup(noBGPUntil, now)
704
+
705
+ pruneNoBGPCache(noBGPUntil, []string{"1002"}, now)
706
+
707
+ require.ElementsMatch(t, tc.want, mapKeys(noBGPUntil))
708
+ })
709
+ }
710
+}
711
+
712
+func TestNoBGPCacheJitter(t *testing.T) {
713
+ c := New()
714
+ c.UpdateEvery = defaultUpdateEvery
715
+
716
+ first := c.noBGPCacheJitter("1001")
717
+ require.Equal(t, first, c.noBGPCacheJitter("1001"))
718
+ require.GreaterOrEqual(t, first, time.Duration(0))
719
+ require.Less(t, first, seconds(defaultUpdateEvery)*5)
720
+ require.Greater(t, seconds(defaultUpdateEvery)*5, seconds(defaultUpdateEvery))
721
+}
722
+
723
+func siteLabels(siteID, siteName, popName string) metrix.Labels {
724
+ return metrix.Labels{"site_id": siteID, "site_name": siteName, "pop_name": popName}
725
+}
726
+
727
+func interfaceLabels(siteID, siteName, deviceID, deviceName, interfaceID, interfaceName string) metrix.Labels {
728
+ return metrix.Labels{
729
+ "site_id": siteID,
730
+ "site_name": siteName,
731
+ "device_id": deviceID,
732
+ "device_name": deviceName,
733
+ "interface_id": interfaceID,
734
+ "interface_name": interfaceName,
735
+ }
736
+}
737
+
738
+func deviceLabels(siteID, siteName, deviceID, deviceName string) metrix.Labels {
739
+ return metrix.Labels{"site_id": siteID, "site_name": siteName, "device_id": deviceID, "device_name": deviceName}
740
+}
741
+
742
+func bgpLabels(siteID, siteName, peerIP, peerASN string) metrix.Labels {
743
+ return metrix.Labels{"site_id": siteID, "site_name": siteName, "peer_ip": peerIP, "peer_asn": peerASN}
744
+}
745
+
746
+func mapKeys[V any](m map[string]V) []string {
747
+ keys := make([]string, 0, len(m))
748
+ for key := range m {
749
+ keys = append(keys, key)
750
+ }
751
+ return keys
752
+}
753
+
754
+func metricKeyFromLabelView(name string, labels metrix.LabelView) string {
755
+ out := make(metrix.Labels)
756
+ labels.Range(func(key, value string) bool {
757
+ out[key] = value
758
+ return true
759
+ })
760
+ return metricKey(name, out)
761
+}
762
+
763
+func requireSiteHostScope(t *testing.T, c *Collector, siteID, siteName, popName string) {
764
+ t.Helper()
765
+
766
+ want := c.siteHostScope(&siteState{ID: siteID, Name: siteName, PopName: popName})
767
+ require.False(t, want.IsDefault())
768
+
769
+ for _, scope := range c.store.Read(metrix.ReadFlatten()).HostScopes() {
770
+ if scope.ScopeKey != want.ScopeKey {
771
+ continue
772
+ }
773
+ require.Equal(t, want.GUID, scope.GUID)
774
+ require.Equal(t, want.Hostname, scope.Hostname)
775
+ require.Equal(t, catoSiteScopeLabelValue, scope.Labels[catoSiteScopeLabelKey])
776
+ require.Equal(t, c.AccountID, scope.Labels["cato_account_id"])
777
+ require.Equal(t, siteID, scope.Labels["cato_site_id"])
778
+ require.Equal(t, siteName, scope.Labels["cato_site_name"])
779
+ require.Equal(t, popName, scope.Labels["cato_pop_name"])
780
+ return
781
+ }
782
+ t.Fatalf("missing host scope for site %s", siteID)
783
+}
src/go/plugin/go.d/collector/cato_networks/collector_lifecycle_test.go
new
+179
@@ -0,0 +1,179 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "net/http"
9
+ "testing"
10
+
11
+ "github.com/stretchr/testify/require"
12
+
13
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
14
+)
15
+
16
+func TestCollector_Init(t *testing.T) {
17
+ tests := map[string]struct {
18
+ setup func(*Collector, *fakeAPIClient)
19
+ wantErr string
20
+ check func(*testing.T, *Collector, *fakeAPIClient)
21
+ }{
22
+ "success with injected client": {
23
+ check: func(t *testing.T, c *Collector, _ *fakeAPIClient) {
24
+ require.NotNil(t, c.client)
25
+ require.Equal(t, defaultEntitySelector, c.SiteSelector)
26
+ require.NotNil(t, c.siteMatcher)
27
+ require.True(t, c.siteMatcher.MatchString("any-site"))
28
+ require.NotNil(t, c.bgp.noBGPUntil)
29
+ },
30
+ },
31
+ "missing account id": {
32
+ setup: func(c *Collector, _ *fakeAPIClient) {
33
+ c.AccountID = ""
34
+ },
35
+ wantErr: "'account_id' is required",
36
+ },
37
+ "missing api key": {
38
+ setup: func(c *Collector, _ *fakeAPIClient) {
39
+ c.APIKey = ""
40
+ },
41
+ wantErr: "'api_key' is required",
42
+ },
43
+ "client factory error": {
44
+ setup: func(c *Collector, _ *fakeAPIClient) {
45
+ c.client = nil
46
+ c.newClient = func(Config, *http.Client) (apiClient, error) {
47
+ return nil, errors.New("factory failed")
48
+ }
49
+ },
50
+ wantErr: "init Cato client: factory failed",
51
+ },
52
+ }
53
+
54
+ for name, tc := range tests {
55
+ t.Run(name, func(t *testing.T) {
56
+ c, fake := newTestCollector()
57
+ if tc.setup != nil {
58
+ tc.setup(c, fake)
59
+ }
60
+
61
+ err := c.Init(context.Background())
62
+ if tc.wantErr != "" {
63
+ require.ErrorContains(t, err, tc.wantErr)
64
+ return
65
+ }
66
+ require.NoError(t, err)
67
+ if tc.check != nil {
68
+ tc.check(t, c, fake)
69
+ }
70
+ })
71
+ }
72
+}
73
+
74
+func TestCollector_Check(t *testing.T) {
75
+ tests := map[string]struct {
76
+ setup func(*Collector, *fakeAPIClient)
77
+ skipInit bool
78
+ wantErr string
79
+ wantErrIs error
80
+ wantClass string
81
+ check func(*testing.T, *Collector, *fakeAPIClient)
82
+ }{
83
+ "success uses cheap probe only": {
84
+ setup: func(_ *Collector, fake *fakeAPIClient) {
85
+ fake.metricsErrSites = map[string]error{"1001": errors.New("metrics should not be called")}
86
+ fake.bgpErrSites = map[string]error{"1001": errors.New("BGP should not be called")}
87
+ },
88
+ check: func(t *testing.T, c *Collector, fake *fakeAPIClient) {
89
+ require.Equal(t, 1, fake.probeCalls)
90
+ require.Zero(t, fake.lookupCalls)
91
+ require.Zero(t, fake.snapshotCalls)
92
+ require.Zero(t, fake.metricsCalls)
93
+ require.Zero(t, fake.bgpCalls)
94
+ require.Empty(t, c.discovery.siteIDs)
95
+ require.Empty(t, c.bgp.noBGPUntil)
96
+ _, ok := c.topology.CurrentTopology()
97
+ require.False(t, ok)
98
+ },
99
+ },
100
+ "does not populate no-BGP cache": {
101
+ check: func(t *testing.T, c *Collector, _ *fakeAPIClient) {
102
+ require.Empty(t, c.bgp.noBGPUntil)
103
+ },
104
+ },
105
+ "preserves probe error cause": {
106
+ setup: func(_ *Collector, fake *fakeAPIClient) {
107
+ fake.probeErr = context.Canceled
108
+ },
109
+ wantErr: "Cato API probe failed",
110
+ wantErrIs: context.Canceled,
111
+ wantClass: "canceled",
112
+ },
113
+ "fails before Init": {
114
+ setup: func(c *Collector, _ *fakeAPIClient) {
115
+ c.client = nil
116
+ },
117
+ skipInit: true,
118
+ wantErr: "Cato client is not initialized",
119
+ },
120
+ }
121
+
122
+ for name, tc := range tests {
123
+ t.Run(name, func(t *testing.T) {
124
+ c, fake := newTestCollector()
125
+ if tc.setup != nil {
126
+ tc.setup(c, fake)
127
+ }
128
+ if !tc.skipInit {
129
+ initCollector(t, c)
130
+ }
131
+
132
+ err := c.Check(context.Background())
133
+ if tc.wantErr != "" {
134
+ require.ErrorContains(t, err, tc.wantErr)
135
+ if tc.wantErrIs != nil {
136
+ require.ErrorIs(t, err, tc.wantErrIs)
137
+ }
138
+ if tc.wantClass != "" {
139
+ require.Equal(t, tc.wantClass, classifyCatoError(err))
140
+ }
141
+ return
142
+ }
143
+ require.NoError(t, err)
144
+ if tc.check != nil {
145
+ tc.check(t, c, fake)
146
+ }
147
+ })
148
+ }
149
+}
150
+
151
+func TestCollector_Cleanup(t *testing.T) {
152
+ router := &cleanupHandler{}
153
+ c := New()
154
+ c.funcRouter = router
155
+
156
+ c.Cleanup(context.Background())
157
+
158
+ require.True(t, router.called)
159
+}
160
+
161
+func TestCollector_MetricStore(t *testing.T) {
162
+ require.NotNil(t, New().MetricStore())
163
+}
164
+
165
+type cleanupHandler struct {
166
+ called bool
167
+}
168
+
169
+func (h *cleanupHandler) MethodParams(context.Context, string) ([]funcapi.ParamConfig, error) {
170
+ return nil, errors.New("not implemented")
171
+}
172
+
173
+func (h *cleanupHandler) Handle(context.Context, string, funcapi.ResolvedParams) *funcapi.FunctionResponse {
174
+ return nil
175
+}
176
+
177
+func (h *cleanupHandler) Cleanup(context.Context) {
178
+ h.called = true
179
+}
src/go/plugin/go.d/collector/cato_networks/config.go
new
+94
@@ -0,0 +1,94 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "errors"
7
+ "net"
8
+ "net/url"
9
+ "strings"
10
+ "time"
11
+
12
+ "github.com/netdata/netdata/go/plugins/pkg/confopt"
13
+ "github.com/netdata/netdata/go/plugins/pkg/web"
14
+)
15
+
16
+const (
17
+ defaultUpdateEvery = 60
18
+ defaultEndpoint = "https://api.catonetworks.com/api/v1/graphql2"
19
+ defaultDiscoveryEvery = 3600
20
+ defaultDiscoveryLimit = 100
21
+ defaultMetricsTimeFrame = "last.PT5M"
22
+ defaultMetricsBuckets = 5
23
+ defaultMetricsParallel = 4
24
+ defaultNoBGPCacheTTL = 3600
25
+ defaultEntitySelector = "*"
26
+ maxDiscoveryPages = 1000
27
+)
28
+
29
+var defaultTimeout = confopt.Duration(30 * time.Second)
30
+
31
+type Config struct {
32
+ Vnode string `yaml:"vnode,omitempty" json:"vnode,omitempty"`
33
+ UpdateEvery int `yaml:"update_every,omitempty" json:"update_every,omitempty"`
34
+ AutoDetectionRetry int `yaml:"autodetection_retry,omitempty" json:"autodetection_retry,omitempty"`
35
+
36
+ AccountID string `yaml:"account_id" json:"account_id"`
37
+ APIKey string `yaml:"api_key" json:"api_key"`
38
+
39
+ web.HTTPConfig `yaml:",inline" json:""`
40
+
41
+ SiteSelector string `yaml:"site_selector,omitempty" json:"site_selector,omitempty"`
42
+}
43
+
44
+func (c *Config) applyDefaults() {
45
+ c.AccountID = strings.TrimSpace(c.AccountID)
46
+ c.APIKey = strings.TrimSpace(c.APIKey)
47
+ c.URL = strings.TrimSpace(c.URL)
48
+ c.SiteSelector = strings.TrimSpace(c.SiteSelector)
49
+
50
+ if c.UpdateEvery <= 0 {
51
+ c.UpdateEvery = defaultUpdateEvery
52
+ }
53
+ if c.URL == "" {
54
+ c.URL = defaultEndpoint
55
+ }
56
+ if c.SiteSelector == "" {
57
+ c.SiteSelector = defaultEntitySelector
58
+ }
59
+ if c.Timeout.Duration() == 0 {
60
+ c.Timeout = defaultTimeout
61
+ }
62
+}
63
+
64
+func (c Config) validate() error {
65
+ var errs []error
66
+
67
+ if strings.TrimSpace(c.AccountID) == "" {
68
+ errs = append(errs, errors.New("'account_id' is required"))
69
+ }
70
+ if strings.TrimSpace(c.APIKey) == "" {
71
+ errs = append(errs, errors.New("'api_key' is required"))
72
+ }
73
+ if strings.TrimSpace(c.URL) == "" {
74
+ errs = append(errs, errors.New("'url' is required"))
75
+ } else if u, err := url.Parse(strings.TrimSpace(c.URL)); err != nil || u.Scheme == "" || u.Host == "" {
76
+ errs = append(errs, errors.New("'url' must be a valid absolute URL"))
77
+ } else if u.Scheme != "https" && !(u.Scheme == "http" && isLoopbackHost(u.Hostname())) {
78
+ errs = append(errs, errors.New("'url' scheme must be https unless using a loopback HTTP endpoint"))
79
+ }
80
+ if c.UpdateEvery < 60 {
81
+ errs = append(errs, errors.New("'update_every' must be >= 60 seconds"))
82
+ }
83
+
84
+ return errors.Join(errs...)
85
+}
86
+
87
+func isLoopbackHost(host string) bool {
88
+ host = strings.TrimSpace(host)
89
+ if strings.EqualFold(host, "localhost") {
90
+ return true
91
+ }
92
+ ip := net.ParseIP(host)
93
+ return ip != nil && ip.IsLoopback()
94
+}
src/go/plugin/go.d/collector/cato_networks/config_schema.json
new
+81
@@ -0,0 +1,81 @@
1
+{
2
+ "jsonSchema": {
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "type": "object",
5
+ "title": "Cato Networks collector configuration.",
6
+ "properties": {
7
+ "update_every": {
8
+ "title": "Update every",
9
+ "description": "Data collection interval, in seconds.",
10
+ "type": "integer",
11
+ "minimum": 60,
12
+ "default": 60
13
+ },
14
+ "autodetection_retry": {
15
+ "title": "Detection retry",
16
+ "description": "Recheck interval in seconds. Zero disables retries.",
17
+ "type": "integer",
18
+ "minimum": 0,
19
+ "default": 0
20
+ },
21
+ "url": {
22
+ "title": "Cato API URL",
23
+ "description": "Cato GraphQL API endpoint. Production endpoints must use HTTPS; HTTP is accepted only for loopback test endpoints.",
24
+ "type": "string",
25
+ "format": "uri",
26
+ "default": "https://api.catonetworks.com/api/v1/graphql2"
27
+ },
28
+ "account_id": {
29
+ "title": "Account ID",
30
+ "description": "Cato account ID.",
31
+ "type": "string",
32
+ "minLength": 1
33
+ },
34
+ "api_key": {
35
+ "title": "API key",
36
+ "description": "Cato API key.",
37
+ "type": "string",
38
+ "sensitive": true,
39
+ "minLength": 1
40
+ },
41
+ "site_selector": {
42
+ "title": "Site selector",
43
+ "description": "Space-separated simple patterns matched against the Cato site name when present, otherwise the site ID. Patterns use first-match ordering; put exclusions before broad includes, for example '!lab-* *'.",
44
+ "type": "string",
45
+ "default": "*"
46
+ },
47
+ "timeout": {
48
+ "title": "Timeout",
49
+ "description": "HTTP request timeout.",
50
+ "type": "number",
51
+ "minimum": 0,
52
+ "default": 30
53
+ },
54
+ "proxy_url": {
55
+ "title": "Proxy URL",
56
+ "description": "HTTP proxy URL. Empty value uses environment proxy variables.",
57
+ "type": "string"
58
+ },
59
+ "vnode": {
60
+ "title": "Vnode",
61
+ "description": "Associates this data collection job with a Virtual Node.",
62
+ "type": "string"
63
+ }
64
+ },
65
+ "required": [
66
+ "account_id",
67
+ "api_key"
68
+ ]
69
+ },
70
+ "uiSchema": {
71
+ "uiOptions": {
72
+ "fullPage": true
73
+ },
74
+ "api_key": {
75
+ "ui:widget": "password"
76
+ },
77
+ "vnode": {
78
+ "ui:placeholder": "To use this option, first create a Virtual Node and then reference its name here."
79
+ }
80
+ }
81
+}
src/go/plugin/go.d/collector/cato_networks/config_test.go
new
+147
@@ -0,0 +1,147 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "encoding/json"
7
+ "os"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/require"
11
+
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
13
+)
14
+
15
+var (
16
+ dataConfigJSON, _ = os.ReadFile("testdata/config.json")
17
+ dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
18
+)
19
+
20
+func TestTestDataIsValid(t *testing.T) {
21
+ tests := map[string]struct {
22
+ data []byte
23
+ }{
24
+ "config json": {data: dataConfigJSON},
25
+ "config yaml": {data: dataConfigYAML},
26
+ }
27
+
28
+ for name, tc := range tests {
29
+ t.Run(name, func(t *testing.T) {
30
+ require.NotNil(t, tc.data)
31
+ })
32
+ }
33
+}
34
+
35
+func TestCollector_ConfigurationSerialize(t *testing.T) {
36
+ collecttest.TestConfigurationSerialize(t, &Collector{}, dataConfigJSON, dataConfigYAML)
37
+}
38
+
39
+func TestConfig_Validate(t *testing.T) {
40
+ tests := map[string]struct {
41
+ setup func(*Config)
42
+ wantErr []string
43
+ check func(*testing.T, Config)
44
+ }{
45
+ "missing required fields": {
46
+ wantErr: []string{"'account_id' is required", "'api_key' is required"},
47
+ },
48
+ "valid defaults": {
49
+ setup: func(cfg *Config) {
50
+ cfg.AccountID = "12345"
51
+ cfg.APIKey = "secret"
52
+ },
53
+ check: func(t *testing.T, cfg Config) {
54
+ require.Equal(t, defaultEndpoint, cfg.URL)
55
+ require.Equal(t, defaultUpdateEvery, cfg.UpdateEvery)
56
+ },
57
+ },
58
+ "normalizes string inputs": {
59
+ setup: func(cfg *Config) {
60
+ cfg.AccountID = " 12345 "
61
+ cfg.APIKey = " secret "
62
+ cfg.URL = " https://api.catonetworks.com/api/v1/graphql2 "
63
+ cfg.SiteSelector = " !lab-* * "
64
+ },
65
+ check: func(t *testing.T, cfg Config) {
66
+ require.Equal(t, "12345", cfg.AccountID)
67
+ require.Equal(t, "secret", cfg.APIKey)
68
+ require.Equal(t, "https://api.catonetworks.com/api/v1/graphql2", cfg.URL)
69
+ require.Equal(t, "!lab-* *", cfg.SiteSelector)
70
+ },
71
+ },
72
+ "rejects non-HTTPS non-loopback endpoint": {
73
+ setup: func(cfg *Config) {
74
+ cfg.AccountID = "12345"
75
+ cfg.APIKey = "secret"
76
+ cfg.URL = "http://api.catonetworks.com/api/v1/graphql2"
77
+ },
78
+ wantErr: []string{"'url' scheme"},
79
+ },
80
+ "accepts loopback HTTP endpoint": {
81
+ setup: func(cfg *Config) {
82
+ cfg.AccountID = "12345"
83
+ cfg.APIKey = "secret"
84
+ cfg.URL = "http://127.0.0.1:8080/api/v1/graphql2"
85
+ },
86
+ },
87
+ "rejects low update_every": {
88
+ setup: func(cfg *Config) {
89
+ cfg.AccountID = "12345"
90
+ cfg.APIKey = "secret"
91
+ cfg.UpdateEvery = 10
92
+ },
93
+ wantErr: []string{"'update_every' must be >= 60 seconds"},
94
+ },
95
+ }
96
+
97
+ for name, tc := range tests {
98
+ t.Run(name, func(t *testing.T) {
99
+ cfg := Config{}
100
+ if tc.setup != nil {
101
+ tc.setup(&cfg)
102
+ }
103
+ cfg.applyDefaults()
104
+
105
+ err := cfg.validate()
106
+ for _, want := range tc.wantErr {
107
+ require.ErrorContains(t, err, want)
108
+ }
109
+ if len(tc.wantErr) > 0 {
110
+ return
111
+ }
112
+ require.NoError(t, err)
113
+ if tc.check != nil {
114
+ tc.check(t, cfg)
115
+ }
116
+ })
117
+ }
118
+}
119
+
120
+func TestConfigSchema(t *testing.T) {
121
+ tests := map[string]struct {
122
+ check func(*testing.T)
123
+ }{
124
+ "parses as JSON": {
125
+ check: func(t *testing.T) {
126
+ require.True(t, json.Valid([]byte(configSchema)))
127
+ },
128
+ },
129
+ "marks api_key sensitive": {
130
+ check: func(t *testing.T) {
131
+ var schema struct {
132
+ JSONSchema struct {
133
+ Properties map[string]map[string]any `json:"properties"`
134
+ } `json:"jsonSchema"`
135
+ }
136
+ require.NoError(t, json.Unmarshal([]byte(configSchema), &schema))
137
+ require.Equal(t, true, schema.JSONSchema.Properties["api_key"]["sensitive"])
138
+ },
139
+ },
140
+ }
141
+
142
+ for name, tc := range tests {
143
+ t.Run(name, func(t *testing.T) {
144
+ tc.check(t)
145
+ })
146
+ }
147
+}
src/go/plugin/go.d/collector/cato_networks/errors.go
new
+124
@@ -0,0 +1,124 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "net"
9
+ "strings"
10
+)
11
+
12
+const (
13
+ normalizationSurfaceSiteConnectivity = "site_connectivity"
14
+ normalizationSurfaceSiteOperational = "site_operational"
15
+ normalizationSurfaceMetrics = "metrics"
16
+ normalizationSurfaceBGP = "bgp"
17
+)
18
+
19
+const (
20
+ normalizationIssueUnknownStatus = "unknown_status"
21
+ normalizationIssueUnknownTimeseriesLabel = "unknown_timeseries_label"
22
+ normalizationIssueEmptyPeer = "empty_peer"
23
+ normalizationIssueParseInt = "parse_int"
24
+)
25
+
26
+type errorClasser interface {
27
+ ErrorClass() string
28
+}
29
+
30
+type classifiedError struct {
31
+ message string
32
+ class string
33
+ cause error
34
+}
35
+
36
+func wrapCatoOperationError(message string, err error) error {
37
+ if err == nil {
38
+ return nil
39
+ }
40
+ return classifiedError{
41
+ message: message + " failed",
42
+ class: classifyCatoError(err),
43
+ cause: err,
44
+ }
45
+}
46
+
47
+func (e classifiedError) Error() string {
48
+ return e.message + ", error_class=" + e.class
49
+}
50
+
51
+func (e classifiedError) Unwrap() error {
52
+ return e.cause
53
+}
54
+
55
+func (e classifiedError) ErrorClass() string {
56
+ return e.class
57
+}
58
+
59
+func classifyCatoError(err error) string {
60
+ if err == nil {
61
+ return "none"
62
+ }
63
+ var classified errorClasser
64
+ if errors.As(err, &classified) {
65
+ return classified.ErrorClass()
66
+ }
67
+ if errors.Is(err, context.Canceled) {
68
+ return "canceled"
69
+ }
70
+ if errors.Is(err, context.DeadlineExceeded) {
71
+ return "timeout"
72
+ }
73
+
74
+ var netErr net.Error
75
+ if errors.As(err, &netErr) && netErr.Timeout() {
76
+ return "timeout"
77
+ }
78
+
79
+ msg := strings.ToLower(err.Error())
80
+ switch {
81
+ case containsAny(msg, "rate limit", "rate-limit", "ratelimit", "too many requests"),
82
+ containsHTTPStatus(msg, "429"):
83
+ return "rate_limit"
84
+ case containsAny(msg, "unauthorized", "forbidden", "invalid api key"),
85
+ containsHTTPStatus(msg, "401"),
86
+ containsHTTPStatus(msg, "403"):
87
+ return "auth"
88
+ case containsAny(msg, "json", "decode", "unmarshal"):
89
+ return "decode"
90
+ case containsAny(msg, "deadline", "timeout", "i/o timeout"):
91
+ return "timeout"
92
+ case containsAny(msg, "proxyconnect", "proxy error", "proxy url", "proxy"):
93
+ return "proxy"
94
+ case containsAny(msg, "x509", "certificate", "tls", "handshake"):
95
+ return "tls"
96
+ case containsAny(msg, "no such host", "connection refused", "connection reset", "network is unreachable",
97
+ "temporary failure in name resolution", "server misbehaving", "dial tcp", "eof"):
98
+ return "network"
99
+ case containsAny(msg, "no cato sites", "no sites"):
100
+ return "empty"
101
+ case strings.Contains(msg, "pagination"):
102
+ return "pagination"
103
+ case strings.Contains(msg, "graphql"):
104
+ return "graphql"
105
+ default:
106
+ return "error"
107
+ }
108
+}
109
+
110
+func containsAny(s string, substrings ...string) bool {
111
+ for _, substring := range substrings {
112
+ if strings.Contains(s, substring) {
113
+ return true
114
+ }
115
+ }
116
+ return false
117
+}
118
+
119
+func containsHTTPStatus(msg, code string) bool {
120
+ return strings.Contains(msg, "http status "+code) ||
121
+ strings.Contains(msg, "http "+code) ||
122
+ strings.Contains(msg, "status code "+code) ||
123
+ strings.Contains(msg, "status "+code)
124
+}
src/go/plugin/go.d/collector/cato_networks/errors_test.go
new
+63
@@ -0,0 +1,63 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "fmt"
9
+ "net"
10
+ "testing"
11
+
12
+ "github.com/stretchr/testify/require"
13
+)
14
+
15
+func TestClassifyCatoError(t *testing.T) {
16
+ tests := map[string]struct {
17
+ err error
18
+ want string
19
+ }{
20
+ "auth": {err: errors.New("HTTP 403 forbidden"), want: "auth"},
21
+ "rate limit": {err: errors.New("GraphQL rate limit exceeded"), want: "rate_limit"},
22
+ "timeout": {err: context.DeadlineExceeded, want: "timeout"},
23
+ "canceled": {err: fmt.Errorf("wrapped: %w", context.Canceled), want: "canceled"},
24
+ "wrapped timeout": {
25
+ err: fmt.Errorf("wrapped: %w", context.DeadlineExceeded),
26
+ want: "timeout",
27
+ },
28
+ "decode": {
29
+ err: errors.New("json: cannot unmarshal number into Go struct field"),
30
+ want: "decode",
31
+ },
32
+ "network": {
33
+ err: &net.DNSError{Err: "no such host", Name: "api.invalid"},
34
+ want: "network",
35
+ },
36
+ "tls": {
37
+ err: errors.New("tls: failed to verify certificate: x509: certificate signed by unknown authority"),
38
+ want: "tls",
39
+ },
40
+ "proxy": {
41
+ err: errors.New("proxyconnect tcp: dial tcp: connection refused"),
42
+ want: "proxy",
43
+ },
44
+ "empty": {
45
+ err: errors.New("no Cato sites discovered"),
46
+ want: "empty",
47
+ },
48
+ "bare 403 is not enough for auth": {
49
+ err: errors.New("BGP ASN 403 has no session"),
50
+ want: "error",
51
+ },
52
+ "bare 429 is not enough for rate limit": {
53
+ err: errors.New("429 routes reported by peer"),
54
+ want: "error",
55
+ },
56
+ }
57
+
58
+ for name, tc := range tests {
59
+ t.Run(name, func(t *testing.T) {
60
+ require.Equal(t, tc.want, classifyCatoError(tc.err))
61
+ })
62
+ }
63
+}
src/go/plugin/go.d/collector/cato_networks/func_deps.go
new
+33
@@ -0,0 +1,33 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "github.com/netdata/netdata/go/plugins/pkg/funcapi"
7
+ topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
8
+ "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
9
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/cato_networks/catofunc"
10
+)
11
+
12
+type funcDepsAdapter struct {
13
+ store *topologyStore
14
+}
15
+
16
+func (a funcDepsAdapter) CurrentTopology() (*topologyv1.Data, bool) {
17
+ if a.store == nil {
18
+ return nil, false
19
+ }
20
+ return a.store.CurrentTopology()
21
+}
22
+
23
+func catoMethods() []funcapi.MethodConfig {
24
+ return catofunc.Methods(defaultUpdateEvery)
25
+}
26
+
27
+func catoFunctionHandler(job collectorapi.RuntimeJob) funcapi.MethodHandler {
28
+ c, ok := job.Collector().(*Collector)
29
+ if !ok {
30
+ return nil
31
+ }
32
+ return c.funcRouter
33
+}
src/go/plugin/go.d/collector/cato_networks/host_scope.go
new
+76
@@ -0,0 +1,76 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "strings"
7
+
8
+ "github.com/google/uuid"
9
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
10
+ "github.com/netdata/netdata/go/plugins/pkg/netdataapi"
11
+ "github.com/netdata/netdata/go/plugins/plugin/framework/chartemit"
12
+)
13
+
14
+const (
15
+ catoSiteScopeLabelKey = "_vnode_type"
16
+ catoSiteScopeLabelValue = "cato_site"
17
+ catoSiteGUIDPrefix = "cato_networks:"
18
+)
19
+
20
+func (c *Collector) siteHostScope(site *siteState) metrix.HostScope {
21
+ return catoSiteHostScope(c.AccountID, site)
22
+}
23
+
24
+func catoSiteHostScope(accountID string, site *siteState) metrix.HostScope {
25
+ if site == nil {
26
+ return metrix.HostScope{}
27
+ }
28
+ siteID := strings.TrimSpace(site.ID)
29
+ if siteID == "" {
30
+ return metrix.HostScope{}
31
+ }
32
+
33
+ guid := uuid.NewSHA1(uuid.NameSpaceDNS, []byte(catoSiteGUIDPrefix+strings.TrimSpace(accountID)+":site:"+siteID)).String()
34
+ scope := metrix.HostScope{
35
+ ScopeKey: guid,
36
+ GUID: guid,
37
+ Hostname: catoSiteHostname(siteID, site.Name),
38
+ Labels: map[string]string{
39
+ catoSiteScopeLabelKey: catoSiteScopeLabelValue,
40
+ "cato_account_id": strings.TrimSpace(accountID),
41
+ "cato_site_id": siteID,
42
+ "cato_site_name": site.Name,
43
+ "cato_pop_name": site.PopName,
44
+ },
45
+ }
46
+ if hostScopeIsSafe(scope) {
47
+ return scope
48
+ }
49
+
50
+ scope.Hostname = "cato-site-" + siteID
51
+ if hostScopeIsSafe(scope) {
52
+ return scope
53
+ }
54
+
55
+ scope.Hostname = "cato-site-" + guid
56
+ if hostScopeIsSafe(scope) {
57
+ return scope
58
+ }
59
+ return metrix.HostScope{}
60
+}
61
+
62
+func catoSiteHostname(siteID, siteName string) string {
63
+ if name := strings.TrimSpace(siteName); name != "" {
64
+ return name
65
+ }
66
+ return "cato-site-" + siteID
67
+}
68
+
69
+func hostScopeIsSafe(scope metrix.HostScope) bool {
70
+ _, err := chartemit.PrepareHostInfo(netdataapi.HostInfo{
71
+ GUID: scope.GUID,
72
+ Hostname: scope.Hostname,
73
+ Labels: scope.Labels,
74
+ })
75
+ return err == nil
76
+}
src/go/plugin/go.d/collector/cato_networks/host_scope_test.go
new
+71
@@ -0,0 +1,71 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "testing"
7
+
8
+ "github.com/stretchr/testify/require"
9
+)
10
+
11
+func TestCatoSiteHostScope(t *testing.T) {
12
+ tests := map[string]struct {
13
+ accountID string
14
+ site *siteState
15
+ wantEmpty bool
16
+ wantHost string
17
+ wantLabel map[string]string
18
+ }{
19
+ "site name hostname with stable id scope": {
20
+ accountID: "12345",
21
+ site: &siteState{ID: "1001", Name: "Paris Office", PopName: "POP-Paris"},
22
+ wantHost: "Paris Office",
23
+ wantLabel: map[string]string{
24
+ catoSiteScopeLabelKey: catoSiteScopeLabelValue,
25
+ "cato_account_id": "12345",
26
+ "cato_site_id": "1001",
27
+ "cato_site_name": "Paris Office",
28
+ "cato_pop_name": "POP-Paris",
29
+ },
30
+ },
31
+ "empty site name falls back to site id hostname": {
32
+ accountID: "12345",
33
+ site: &siteState{ID: "1001", PopName: "POP-Paris"},
34
+ wantHost: "cato-site-1001",
35
+ wantLabel: map[string]string{
36
+ catoSiteScopeLabelKey: catoSiteScopeLabelValue,
37
+ "cato_account_id": "12345",
38
+ "cato_site_id": "1001",
39
+ "cato_site_name": "",
40
+ "cato_pop_name": "POP-Paris",
41
+ },
42
+ },
43
+ "nil site uses default scope": {
44
+ accountID: "12345",
45
+ wantEmpty: true,
46
+ },
47
+ "empty site id uses default scope": {
48
+ accountID: "12345",
49
+ site: &siteState{Name: "Paris Office"},
50
+ wantEmpty: true,
51
+ },
52
+ }
53
+
54
+ for name, tc := range tests {
55
+ t.Run(name, func(t *testing.T) {
56
+ got := catoSiteHostScope(tc.accountID, tc.site)
57
+ if tc.wantEmpty {
58
+ require.True(t, got.IsDefault())
59
+ return
60
+ }
61
+
62
+ require.False(t, got.IsDefault())
63
+ require.Equal(t, got.ScopeKey, got.GUID)
64
+ require.Equal(t, tc.wantHost, got.Hostname)
65
+ require.Equal(t, tc.wantLabel, got.Labels)
66
+ require.Equal(t, got, catoSiteHostScope(tc.accountID, tc.site))
67
+ require.NotEqual(t, got.ScopeKey, catoSiteHostScope("other-account", tc.site).ScopeKey)
68
+ require.NotEqual(t, got.ScopeKey, catoSiteHostScope(tc.accountID, &siteState{ID: "1002"}).ScopeKey)
69
+ })
70
+ }
71
+}
src/go/plugin/go.d/collector/cato_networks/init.go
new
+45
@@ -0,0 +1,45 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/netdata/netdata/go/plugins/pkg/matcher"
10
+ "github.com/netdata/netdata/go/plugins/pkg/web"
11
+)
12
+
13
+func (c *Collector) initSiteSelector() error {
14
+ expr := strings.TrimSpace(c.SiteSelector)
15
+ if expr == "" || expr == "*" {
16
+ c.siteMatcher = matcher.TRUE()
17
+ return nil
18
+ }
19
+
20
+ m, err := matcher.NewSimplePatternsMatcher(expr)
21
+ if err != nil {
22
+ return fmt.Errorf("init site_selector: %w", err)
23
+ }
24
+ c.siteMatcher = m
25
+ return nil
26
+}
27
+
28
+func (c *Collector) initClient() error {
29
+ if c.client != nil {
30
+ return nil
31
+ }
32
+
33
+ httpClient, err := web.NewHTTPClient(c.ClientConfig)
34
+ if err != nil {
35
+ return fmt.Errorf("init http client: %w", err)
36
+ }
37
+ c.httpClient = httpClient
38
+
39
+ client, err := c.newClient(c.Config, httpClient)
40
+ if err != nil {
41
+ return fmt.Errorf("init Cato client: %w", err)
42
+ }
43
+ c.client = client
44
+ return nil
45
+}
src/go/plugin/go.d/collector/cato_networks/integrations/cato_networks.md
new
+356
@@ -0,0 +1,356 @@
1
+<!--startmeta
2
+custom_edit_url: "https://github.com/netdata/netdata/edit/master/src/go/plugin/go.d/collector/cato_networks/README.md"
3
+meta_yaml: "https://github.com/netdata/netdata/edit/master/src/go/plugin/go.d/collector/cato_networks/metadata.yaml"
4
+sidebar_label: "Cato Networks"
5
+learn_status: "Published"
6
+learn_rel_path: "Collecting Metrics/Collectors/Networking"
7
+keywords: ['cato', 'cato networks', 'sase', 'sd-wan', 'bgp', 'topology']
8
+message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
9
+endmeta-->
10
+
11
+# Cato Networks
12
+
13
+
14
+<img src="https://netdata.cloud/img/network-wired.svg" width="150"/>
15
+
16
+
17
+Plugin: go.d.plugin
18
+Module: cato_networks
19
+
20
+<img src="https://img.shields.io/badge/maintained%20by-Netdata-%2300ab44" />
21
+
22
+## Overview
23
+
24
+This collector monitors Cato Networks accounts through the Cato GraphQL API.
25
+
26
+It discovers sites, collects site, device, and interface status, traffic and quality metrics, collects BGP peer status, and exposes Cato site/device/PoP/BGP topology data.
27
+Metrics for each selected site are emitted under a generated virtual node named after the Cato site and keyed by the Cato account ID and site ID. Site labels are still attached to metric series for grouping and aggregation.
28
+
29
+
30
+The collector uses the Cato GraphQL API endpoint with an account ID and API key.
31
+
32
+
33
+This collector is supported on all platforms.
34
+
35
+This collector supports collecting metrics from multiple instances of this integration, including remote instances.
36
+
37
+A Cato API key with permission to read account snapshot, account metrics, entity lookup, and site BGP status data is required.
38
+
39
+
40
+### Default Behavior
41
+
42
+#### Auto-Detection
43
+
44
+This collector does not auto-detect Cato accounts. Configure at least one job with `account_id` and `api_key`.
45
+
46
+
47
+#### Limits
48
+
49
+Site discovery is paginated and refreshed hourly. The optional `site_selector` filters sites before snapshot, metrics, BGP, topology, and generated site virtual-node creation. Account metrics use bounded concurrent one-site API requests to preserve per-Socket device and interface attribution. BGP status uses one per-site API request for each selected site that is not currently cached as having no BGP peers.
50
+
51
+
52
+#### Performance Impact
53
+
54
+The default interval is 60 seconds. Sites that return a successful BGP response with no peers are cached as non-BGP sites for about one hour, with deterministic per-site jitter across multiple collection cycles to avoid synchronized refresh bursts. If BGP is enabled on such a site, BGP metrics can appear after that cache expires.
55
+
56
+
57
+## Setup
58
+
59
+
60
+You can configure the **cato_networks** collector in two ways:
61
+
62
+| Method | Best for | How to |
63
+|-----------------------|------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
64
+| [**UI**](#via-ui) | Fast setup without editing files | Go to **Nodes → Configure this node → Collectors → Jobs**, search for **cato_networks**, then click **+** to add a job. |
65
+| [**File**](#via-file) | If you prefer configuring via file, or need to automate deployments (e.g., with Ansible) | Edit `go.d/cato_networks.conf` and add a job. |
66
+
67
+:::important
68
+
69
+UI configuration requires paid Netdata Cloud plan.
70
+
71
+:::
72
+
73
+
74
+### Prerequisites
75
+
76
+#### Cato API credentials
77
+
78
+Create or obtain a Cato API key and account ID with read access to the required GraphQL API surfaces.
79
+
80
+
81
+
82
+### Configuration
83
+
84
+#### Options
85
+
86
+The following options can be defined globally: update_every, autodetection_retry.
87
+
88
+
89
+<details open><summary>Config options</summary>
90
+
91
+
92
+
93
+| Group | Option | Description | Default | Required |
94
+|:------|:-----|:------------|:--------|:---------:|
95
+| **Collection** | update_every | Data collection interval, in seconds. Must be at least 60. | 60 | no |
96
+| | autodetection_retry | Autodetection retry interval, in seconds. Set 0 to disable. | 0 | no |
97
+| **Target** | url | Cato GraphQL API endpoint. Production endpoints must use HTTPS; HTTP is accepted only for loopback test endpoints. | https://api.catonetworks.com/api/v1/graphql2 | no |
98
+| **Authentication** | account_id | Cato account ID. | | yes |
99
+| | api_key | Cato API key. | | yes |
100
+| **HTTP** | timeout | HTTP request timeout. | 30 | no |
101
+| | proxy_url | HTTP proxy URL. Empty value uses environment proxy variables. | | no |
102
+| **Cardinality** | site_selector | Space-separated simple patterns matched against the Cato site name when present, otherwise the site ID. Patterns use first-match ordering; put exclusions before broad includes, for example `!lab-* *`. | * | no |
103
+| **Virtual Node** | vnode | Associates this data collection job with a Virtual Node. | | no |
104
+
105
+
106
+</details>
107
+
108
+
109
+#### via UI
110
+
111
+Configure the **cato_networks** collector from the Netdata web interface:
112
+
113
+1. Go to **Nodes**.
114
+2. Select the node **where you want the cato_networks data-collection job to run** and click the :gear: (**Configure this node**). That node will run the data collection.
115
+3. The **Collectors → Jobs** view opens by default.
116
+4. In the Search box, type _cato_networks_ (or scroll the list) to locate the **cato_networks** collector.
117
+5. Click the **+** next to the **cato_networks** collector to add a new job.
118
+6. Fill in the job fields, then click **Test** to verify the configuration and **Submit** to save.
119
+ - **Test** runs the job with the provided settings and shows whether data can be collected.
120
+ - If it fails, an error message appears with details (for example, connection refused, timeout, or command execution errors), so you can adjust and retest.
121
+
122
+
123
+#### via File
124
+
125
+The configuration file name for this integration is `go.d/cato_networks.conf`.
126
+
127
+The file format is YAML. Generally, the structure is:
128
+
129
+```yaml
130
+update_every: 1
131
+autodetection_retry: 0
132
+jobs:
133
+ - name: some_name1
134
+ - name: some_name2
135
+```
136
+You can edit the configuration file using the [`edit-config`](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration/README.md#edit-configuration-files) script from the
137
+Netdata [config directory](https://github.com/netdata/netdata/blob/master/docs/netdata-agent/configuration/README.md#locate-your-config-directory).
138
+
139
+```bash
140
+cd /etc/netdata 2>/dev/null || cd /opt/netdata/etc/netdata
141
+sudo ./edit-config go.d/cato_networks.conf
142
+```
143
+
144
+##### Examples
145
+
146
+###### Basic
147
+
148
+Collect Cato Networks account metrics and topology.
149
+
150
+<details open><summary>Config</summary>
151
+
152
+```yaml
153
+jobs:
154
+ - name: account
155
+ account_id: "12345"
156
+ api_key: "replace-with-cato-api-key"
157
+
158
+```
159
+</details>
160
+
161
+
162
+
163
+## Alerts
164
+
165
+
166
+The following alerts are available:
167
+
168
+| Alert name | On metric | Description |
169
+|:------------|:----------|:------------|
170
+| [ cato_networks_site_disconnected ](https://github.com/netdata/netdata/blob/master/src/health/health.d/cato_networks.conf) | cato_networks.site_connectivity_status | Cato site ${label:site_name} reports disconnected connectivity status |
171
+| [ cato_networks_site_degraded ](https://github.com/netdata/netdata/blob/master/src/health/health.d/cato_networks.conf) | cato_networks.site_connectivity_status | Cato site ${label:site_name} reports degraded connectivity status |
172
+| [ cato_networks_site_packet_loss ](https://github.com/netdata/netdata/blob/master/src/health/health.d/cato_networks.conf) | cato_networks.site_packet_loss | Cato site ${label:site_name} packet loss over the last 10 minutes |
173
+| [ cato_networks_bgp_session_down ](https://github.com/netdata/netdata/blob/master/src/health/health.d/cato_networks.conf) | cato_networks.bgp_session_status | Cato BGP session to peer ${label:peer_ip} (${label:peer_asn}) is down |
174
+
175
+
176
+## Metrics
177
+
178
+Metrics grouped by *scope*.
179
+
180
+The scope defines the instance that the metric belongs to. An instance is uniquely identified by a set of labels.
181
+
182
+
183
+
184
+### Per site
185
+
186
+These metrics refer to a Cato site.
187
+
188
+Labels:
189
+
190
+| Label | Description |
191
+|:-----------|:----------------|
192
+| site_id | Cato site ID. |
193
+| site_name | Cato site name. |
194
+| pop_name | Cato PoP name. |
195
+
196
+Metrics:
197
+
198
+| Metric | Dimensions | Unit |
199
+|:------|:----------|:----|
200
+| cato_networks.site_connectivity_status | connected, disconnected, degraded, unknown | status |
201
+| cato_networks.site_operational_status | active, disabled, locked, unknown | status |
202
+| cato_networks.site_hosts | hosts | hosts |
203
+| cato_networks.site_traffic | upstream, downstream | bytes |
204
+| cato_networks.site_packet_loss | upstream, downstream | percentage |
205
+| cato_networks.site_discarded_packets | upstream, downstream | packets |
206
+| cato_networks.site_jitter | upstream, downstream | milliseconds |
207
+| cato_networks.site_latency | rtt, last_mile | milliseconds |
208
+| cato_networks.site_last_mile_packet_loss | loss | percentage |
209
+
210
+### Per device
211
+
212
+These metrics refer to a Cato Socket or vSocket attached to a site.
213
+
214
+Labels:
215
+
216
+| Label | Description |
217
+|:-----------|:----------------|
218
+| site_id | Cato site ID. |
219
+| site_name | Cato site name. |
220
+| device_id | Cato device ID. |
221
+| device_name | Cato device name. |
222
+
223
+Metrics:
224
+
225
+| Metric | Dimensions | Unit |
226
+|:------|:----------|:----|
227
+| cato_networks.device_connection_status | connected, disconnected | status |
228
+
229
+### Per interface
230
+
231
+These metrics refer to a Cato site interface.
232
+
233
+Labels:
234
+
235
+| Label | Description |
236
+|:-----------|:----------------|
237
+| site_id | Cato site ID. |
238
+| site_name | Cato site name. |
239
+| device_id | Cato device ID when the interface owner is known. |
240
+| device_name | Cato device name when the interface owner is known. |
241
+| interface_id | Cato interface ID. |
242
+| interface_name | Interface name. |
243
+
244
+Metrics:
245
+
246
+| Metric | Dimensions | Unit |
247
+|:------|:----------|:----|
248
+| cato_networks.interface_connection_status | connected, disconnected | status |
249
+| cato_networks.interface_tunnel_uptime | uptime | seconds |
250
+| cato_networks.interface_traffic | upstream, downstream | bytes |
251
+| cato_networks.interface_packet_loss | upstream, downstream | percentage |
252
+| cato_networks.interface_discarded_packets | upstream, downstream | packets |
253
+| cato_networks.interface_jitter | upstream, downstream | milliseconds |
254
+| cato_networks.interface_latency | rtt | milliseconds |
255
+
256
+### Per bgp peer
257
+
258
+These metrics refer to a Cato site BGP peer.
259
+
260
+Labels:
261
+
262
+| Label | Description |
263
+|:-----------|:----------------|
264
+| site_id | Cato site ID. |
265
+| site_name | Cato site name. |
266
+| peer_ip | BGP peer IP. |
267
+| peer_asn | BGP peer ASN. |
268
+
269
+Metrics:
270
+
271
+| Metric | Dimensions | Unit |
272
+|:------|:----------|:----|
273
+| cato_networks.bgp_session_status | up, down, unknown | status |
274
+| cato_networks.bgp_routes | accepted, limit, rib_out | routes |
275
+| cato_networks.bgp_routes_limit_status | ok, exceeded | status |
276
+
277
+
278
+
279
+## Troubleshooting
280
+
281
+### Debug Mode
282
+
283
+**Important**: Debug mode is not supported for data collection jobs created via the UI using the Dyncfg feature.
284
+
285
+To troubleshoot issues with the `cato_networks` collector, run the `go.d.plugin` with the debug option enabled. The output
286
+should give you clues as to why the collector isn't working.
287
+
288
+- Navigate to the `plugins.d` directory, usually at `/usr/libexec/netdata/plugins.d/`. If that's not the case on
289
+ your system, open `netdata.conf` and look for the `plugins` setting under `[directories]`.
290
+
291
+ ```bash
292
+ cd /usr/libexec/netdata/plugins.d/
293
+ ```
294
+
295
+- Switch to the `netdata` user.
296
+
297
+ ```bash
298
+ sudo -u netdata -s
299
+ ```
300
+
301
+- Run the `go.d.plugin` to debug the collector:
302
+
303
+ ```bash
304
+ ./go.d.plugin -d -m cato_networks
305
+ ```
306
+
307
+ To debug a specific job:
308
+
309
+ ```bash
310
+ ./go.d.plugin -d -m cato_networks -j jobName
311
+ ```
312
+
313
+### Getting Logs
314
+
315
+If you're encountering problems with the `cato_networks` collector, follow these steps to retrieve logs and identify potential issues:
316
+
317
+- **Run the command** specific to your system (systemd, non-systemd, or Docker container).
318
+- **Examine the output** for any warnings or error messages that might indicate issues. These messages should provide clues about the root cause of the problem.
319
+
320
+#### System with systemd
321
+
322
+Use the following command to view logs generated since the last Netdata service restart:
323
+
324
+```bash
325
+journalctl _SYSTEMD_INVOCATION_ID="$(systemctl show --value --property=InvocationID netdata)" --namespace=netdata --grep cato_networks
326
+```
327
+
328
+#### System without systemd
329
+
330
+Locate the collector log file, typically at `/var/log/netdata/collector.log`, and use `grep` to filter for collector's name:
331
+
332
+```bash
333
+grep cato_networks /var/log/netdata/collector.log
334
+```
335
+
336
+**Note**: This method shows logs from all restarts. Focus on the **latest entries** for troubleshooting current issues.
337
+
338
+#### Docker Container
339
+
340
+If your Netdata runs in a Docker container named "netdata" (replace if different), use this command:
341
+
342
+```bash
343
+docker logs netdata 2>&1 | grep cato_networks
344
+```
345
+
346
+### API rate limits
347
+
348
+Increase `update_every` or narrow `site_selector` if the Cato API reports rate limiting.
349
+
350
+
351
+### Connection, TLS, or proxy failures
352
+
353
+Check DNS, firewall egress, TLS inspection, proxy settings, endpoint region, and the collector logs.
354
+
355
+
356
+
src/go/plugin/go.d/collector/cato_networks/metadata.yaml
new
+348
@@ -0,0 +1,348 @@
1
+plugin_name: go.d.plugin
2
+modules:
3
+ - meta:
4
+ id: collector-go.d.plugin-cato_networks
5
+ plugin_name: go.d.plugin
6
+ module_name: cato_networks
7
+ monitored_instance:
8
+ name: Cato Networks
9
+ link: https://www.catonetworks.com/
10
+ icon_filename: network-wired.svg
11
+ categories:
12
+ - data-collection.networking
13
+ keywords:
14
+ - cato
15
+ - cato networks
16
+ - sase
17
+ - sd-wan
18
+ - bgp
19
+ - topology
20
+ related_resources:
21
+ integrations:
22
+ list: []
23
+ info_provided_to_referring_integrations:
24
+ description: ""
25
+ overview:
26
+ data_collection:
27
+ metrics_description: |
28
+ This collector monitors Cato Networks accounts through the Cato GraphQL API.
29
+
30
+ It discovers sites, collects site, device, and interface status, traffic and quality metrics, collects BGP peer status, and exposes Cato site/device/PoP/BGP topology data.
31
+ Metrics for each selected site are emitted under a generated virtual node named after the Cato site and keyed by the Cato account ID and site ID. Site labels are still attached to metric series for grouping and aggregation.
32
+ method_description: |
33
+ The collector uses the Cato GraphQL API endpoint with an account ID and API key.
34
+ supported_platforms:
35
+ include: []
36
+ exclude: []
37
+ multi_instance: true
38
+ additional_permissions:
39
+ description: |
40
+ A Cato API key with permission to read account snapshot, account metrics, entity lookup, and site BGP status data is required.
41
+ default_behavior:
42
+ auto_detection:
43
+ description: |
44
+ This collector does not auto-detect Cato accounts. Configure at least one job with `account_id` and `api_key`.
45
+ limits:
46
+ description: |
47
+ Site discovery is paginated and refreshed hourly. The optional `site_selector` filters sites before snapshot, metrics, BGP, topology, and generated site virtual-node creation. Account metrics use bounded concurrent one-site API requests to preserve per-Socket device and interface attribution. BGP status uses one per-site API request for each selected site that is not currently cached as having no BGP peers.
48
+ performance_impact:
49
+ description: |
50
+ The default interval is 60 seconds. Sites that return a successful BGP response with no peers are cached as non-BGP sites for about one hour, with deterministic per-site jitter across multiple collection cycles to avoid synchronized refresh bursts. If BGP is enabled on such a site, BGP metrics can appear after that cache expires.
51
+ setup:
52
+ prerequisites:
53
+ list:
54
+ - title: Cato API credentials
55
+ description: |
56
+ Create or obtain a Cato API key and account ID with read access to the required GraphQL API surfaces.
57
+ configuration:
58
+ file:
59
+ name: go.d/cato_networks.conf
60
+ options:
61
+ description: |
62
+ The following options can be defined globally: update_every, autodetection_retry.
63
+ folding:
64
+ title: Config options
65
+ enabled: true
66
+ list:
67
+ - name: update_every
68
+ description: Data collection interval, in seconds. Must be at least 60.
69
+ default_value: 60
70
+ required: false
71
+ group: Collection
72
+ - name: autodetection_retry
73
+ description: Autodetection retry interval, in seconds. Set 0 to disable.
74
+ default_value: 0
75
+ required: false
76
+ group: Collection
77
+ - name: url
78
+ description: Cato GraphQL API endpoint. Production endpoints must use HTTPS; HTTP is accepted only for loopback test endpoints.
79
+ default_value: https://api.catonetworks.com/api/v1/graphql2
80
+ required: false
81
+ group: Target
82
+ - name: account_id
83
+ description: Cato account ID.
84
+ default_value: ""
85
+ required: true
86
+ group: Authentication
87
+ - name: api_key
88
+ description: Cato API key.
89
+ default_value: ""
90
+ required: true
91
+ group: Authentication
92
+ - name: timeout
93
+ description: HTTP request timeout.
94
+ default_value: 30
95
+ required: false
96
+ group: HTTP
97
+ - name: proxy_url
98
+ description: HTTP proxy URL. Empty value uses environment proxy variables.
99
+ default_value: ""
100
+ required: false
101
+ group: HTTP
102
+ - name: site_selector
103
+ description: Space-separated simple patterns matched against the Cato site name when present, otherwise the site ID. Patterns use first-match ordering; put exclusions before broad includes, for example `!lab-* *`.
104
+ default_value: "*"
105
+ required: false
106
+ group: Cardinality
107
+ - name: vnode
108
+ description: Associates this data collection job with a Virtual Node.
109
+ default_value: ""
110
+ required: false
111
+ group: Virtual Node
112
+ examples:
113
+ folding:
114
+ title: Config
115
+ enabled: true
116
+ list:
117
+ - name: Basic
118
+ description: Collect Cato Networks account metrics and topology.
119
+ config: |
120
+ jobs:
121
+ - name: account
122
+ account_id: "12345"
123
+ api_key: "replace-with-cato-api-key"
124
+ troubleshooting:
125
+ problems:
126
+ list:
127
+ - name: API rate limits
128
+ description: |
129
+ Increase `update_every` or narrow `site_selector` if the Cato API reports rate limiting.
130
+ - name: Connection, TLS, or proxy failures
131
+ description: |
132
+ Check DNS, firewall egress, TLS inspection, proxy settings, endpoint region, and the collector logs.
133
+ alerts:
134
+ - name: cato_networks_site_disconnected
135
+ metric: cato_networks.site_connectivity_status
136
+ info: "Cato site ${label:site_name} reports disconnected connectivity status"
137
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/cato_networks.conf
138
+ - name: cato_networks_site_degraded
139
+ metric: cato_networks.site_connectivity_status
140
+ info: "Cato site ${label:site_name} reports degraded connectivity status"
141
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/cato_networks.conf
142
+ - name: cato_networks_site_packet_loss
143
+ metric: cato_networks.site_packet_loss
144
+ info: "Cato site ${label:site_name} packet loss over the last 10 minutes"
145
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/cato_networks.conf
146
+ - name: cato_networks_bgp_session_down
147
+ metric: cato_networks.bgp_session_status
148
+ info: "Cato BGP session to peer ${label:peer_ip} (${label:peer_asn}) is down"
149
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/cato_networks.conf
150
+ metrics:
151
+ folding:
152
+ title: Metrics
153
+ enabled: false
154
+ description: ""
155
+ availability: []
156
+ scopes:
157
+ - name: site
158
+ description: These metrics refer to a Cato site.
159
+ labels:
160
+ - name: site_id
161
+ description: Cato site ID.
162
+ - name: site_name
163
+ description: Cato site name.
164
+ - name: pop_name
165
+ description: Cato PoP name.
166
+ metrics:
167
+ - name: cato_networks.site_connectivity_status
168
+ description: Site connectivity status.
169
+ unit: status
170
+ chart_type: line
171
+ dimensions:
172
+ - name: connected
173
+ - name: disconnected
174
+ - name: degraded
175
+ - name: unknown
176
+ - name: cato_networks.site_operational_status
177
+ description: Site operational status.
178
+ unit: status
179
+ chart_type: line
180
+ dimensions:
181
+ - name: active
182
+ - name: disabled
183
+ - name: locked
184
+ - name: unknown
185
+ - name: cato_networks.site_hosts
186
+ description: Site hosts.
187
+ unit: hosts
188
+ chart_type: line
189
+ dimensions:
190
+ - name: hosts
191
+ - name: cato_networks.site_traffic
192
+ description: Site traffic.
193
+ unit: bytes
194
+ chart_type: area
195
+ dimensions:
196
+ - name: upstream
197
+ - name: downstream
198
+ - name: cato_networks.site_packet_loss
199
+ description: Site packet loss.
200
+ unit: percentage
201
+ chart_type: line
202
+ dimensions:
203
+ - name: upstream
204
+ - name: downstream
205
+ - name: cato_networks.site_discarded_packets
206
+ description: Site discarded packets.
207
+ unit: packets
208
+ chart_type: line
209
+ dimensions:
210
+ - name: upstream
211
+ - name: downstream
212
+ - name: cato_networks.site_jitter
213
+ description: Site jitter.
214
+ unit: milliseconds
215
+ chart_type: line
216
+ dimensions:
217
+ - name: upstream
218
+ - name: downstream
219
+ - name: cato_networks.site_latency
220
+ description: Site latency.
221
+ unit: milliseconds
222
+ chart_type: line
223
+ dimensions:
224
+ - name: rtt
225
+ - name: last_mile
226
+ - name: cato_networks.site_last_mile_packet_loss
227
+ description: Site last mile packet loss.
228
+ unit: percentage
229
+ chart_type: line
230
+ dimensions:
231
+ - name: loss
232
+ - name: device
233
+ description: These metrics refer to a Cato Socket or vSocket attached to a site.
234
+ labels:
235
+ - name: site_id
236
+ description: Cato site ID.
237
+ - name: site_name
238
+ description: Cato site name.
239
+ - name: device_id
240
+ description: Cato device ID.
241
+ - name: device_name
242
+ description: Cato device name.
243
+ metrics:
244
+ - name: cato_networks.device_connection_status
245
+ description: Device connection status.
246
+ unit: status
247
+ chart_type: line
248
+ dimensions:
249
+ - name: connected
250
+ - name: disconnected
251
+ - name: interface
252
+ description: These metrics refer to a Cato site interface.
253
+ labels:
254
+ - name: site_id
255
+ description: Cato site ID.
256
+ - name: site_name
257
+ description: Cato site name.
258
+ - name: device_id
259
+ description: Cato device ID when the interface owner is known.
260
+ - name: device_name
261
+ description: Cato device name when the interface owner is known.
262
+ - name: interface_id
263
+ description: Cato interface ID.
264
+ - name: interface_name
265
+ description: Interface name.
266
+ metrics:
267
+ - name: cato_networks.interface_connection_status
268
+ description: Interface connection status.
269
+ unit: status
270
+ chart_type: line
271
+ dimensions:
272
+ - name: connected
273
+ - name: disconnected
274
+ - name: cato_networks.interface_tunnel_uptime
275
+ description: Interface tunnel uptime.
276
+ unit: seconds
277
+ chart_type: line
278
+ dimensions:
279
+ - name: uptime
280
+ - name: cato_networks.interface_traffic
281
+ description: Interface traffic.
282
+ unit: bytes
283
+ chart_type: area
284
+ dimensions:
285
+ - name: upstream
286
+ - name: downstream
287
+ - name: cato_networks.interface_packet_loss
288
+ description: Interface packet loss.
289
+ unit: percentage
290
+ chart_type: line
291
+ dimensions:
292
+ - name: upstream
293
+ - name: downstream
294
+ - name: cato_networks.interface_discarded_packets
295
+ description: Interface discarded packets.
296
+ unit: packets
297
+ chart_type: line
298
+ dimensions:
299
+ - name: upstream
300
+ - name: downstream
301
+ - name: cato_networks.interface_jitter
302
+ description: Interface jitter.
303
+ unit: milliseconds
304
+ chart_type: line
305
+ dimensions:
306
+ - name: upstream
307
+ - name: downstream
308
+ - name: cato_networks.interface_latency
309
+ description: Interface latency.
310
+ unit: milliseconds
311
+ chart_type: line
312
+ dimensions:
313
+ - name: rtt
314
+ - name: bgp peer
315
+ description: These metrics refer to a Cato site BGP peer.
316
+ labels:
317
+ - name: site_id
318
+ description: Cato site ID.
319
+ - name: site_name
320
+ description: Cato site name.
321
+ - name: peer_ip
322
+ description: BGP peer IP.
323
+ - name: peer_asn
324
+ description: BGP peer ASN.
325
+ metrics:
326
+ - name: cato_networks.bgp_session_status
327
+ description: BGP session status.
328
+ unit: status
329
+ chart_type: line
330
+ dimensions:
331
+ - name: up
332
+ - name: down
333
+ - name: unknown
334
+ - name: cato_networks.bgp_routes
335
+ description: BGP route counts.
336
+ unit: routes
337
+ chart_type: line
338
+ dimensions:
339
+ - name: accepted
340
+ - name: limit
341
+ - name: rib_out
342
+ - name: cato_networks.bgp_routes_limit_status
343
+ description: BGP route limit exceeded status.
344
+ unit: status
345
+ chart_type: line
346
+ dimensions:
347
+ - name: ok
348
+ - name: exceeded
src/go/plugin/go.d/collector/cato_networks/metrix.go
new
+140
@@ -0,0 +1,140 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import "github.com/netdata/netdata/go/plugins/pkg/metrix"
6
+
7
+type collectorMetrics struct {
8
+ site siteMetricInstruments
9
+ device deviceMetricInstruments
10
+ iface interfaceMetricInstruments
11
+ bgp bgpMetricInstruments
12
+}
13
+
14
+type siteMetricInstruments struct {
15
+ connectivityStatus metrix.SnapshotStateSetVec
16
+ operationalStatus metrix.SnapshotStateSetVec
17
+ hosts metrix.SnapshotGaugeVec
18
+ traffic trafficMetricWriters
19
+}
20
+
21
+type deviceMetricInstruments struct {
22
+ connectionStatus metrix.SnapshotStateSetVec
23
+}
24
+
25
+type interfaceMetricInstruments struct {
26
+ connectionStatus metrix.SnapshotStateSetVec
27
+ tunnelUptime metrix.SnapshotGaugeVec
28
+ traffic trafficMetricWriters
29
+}
30
+
31
+type bgpMetricInstruments struct {
32
+ sessionStatus metrix.SnapshotStateSetVec
33
+ routes metrix.SnapshotGaugeVec
34
+ routesLimit metrix.SnapshotGaugeVec
35
+ routesLimitState metrix.SnapshotStateSetVec
36
+ ribOutRoutes metrix.SnapshotGaugeVec
37
+}
38
+
39
+type trafficMetricWriters struct {
40
+ bytesUp metrix.SnapshotGaugeVec
41
+ bytesDown metrix.SnapshotGaugeVec
42
+ lostUp metrix.SnapshotGaugeVec
43
+ lostDown metrix.SnapshotGaugeVec
44
+ jitterUp metrix.SnapshotGaugeVec
45
+ jitterDown metrix.SnapshotGaugeVec
46
+ discardUp metrix.SnapshotGaugeVec
47
+ discardDown metrix.SnapshotGaugeVec
48
+ rtt metrix.SnapshotGaugeVec
49
+ lastMileLatency metrix.SnapshotGaugeVec
50
+ lastMileLoss metrix.SnapshotGaugeVec
51
+}
52
+
53
+func (w trafficMetricWriters) withHostScope(scope metrix.HostScope) trafficMetricWriters {
54
+ return trafficMetricWriters{
55
+ bytesUp: gaugeVecWithHostScope(w.bytesUp, scope),
56
+ bytesDown: gaugeVecWithHostScope(w.bytesDown, scope),
57
+ lostUp: gaugeVecWithHostScope(w.lostUp, scope),
58
+ lostDown: gaugeVecWithHostScope(w.lostDown, scope),
59
+ jitterUp: gaugeVecWithHostScope(w.jitterUp, scope),
60
+ jitterDown: gaugeVecWithHostScope(w.jitterDown, scope),
61
+ discardUp: gaugeVecWithHostScope(w.discardUp, scope),
62
+ discardDown: gaugeVecWithHostScope(w.discardDown, scope),
63
+ rtt: gaugeVecWithHostScope(w.rtt, scope),
64
+ lastMileLatency: gaugeVecWithHostScope(w.lastMileLatency, scope),
65
+ lastMileLoss: gaugeVecWithHostScope(w.lastMileLoss, scope),
66
+ }
67
+}
68
+
69
+func gaugeVecWithHostScope(vec metrix.SnapshotGaugeVec, scope metrix.HostScope) metrix.SnapshotGaugeVec {
70
+ if vec == nil {
71
+ return nil
72
+ }
73
+ return vec.WithHostScope(scope)
74
+}
75
+
76
+var siteConnectivityStates = []string{"connected", "disconnected", "degraded", "unknown"}
77
+var siteOperationalStates = []string{"active", "disabled", "locked", "unknown"}
78
+var deviceConnectionStates = []string{"connected", "disconnected"}
79
+var interfaceConnectionStates = []string{"connected", "disconnected"}
80
+var bgpSessionStates = []string{"up", "down", "unknown"}
81
+var bgpRoutesLimitStates = []string{"ok", "exceeded"}
82
+
83
+func newCollectorMetrics(store metrix.CollectorStore) *collectorMetrics {
84
+ meter := store.Write().SnapshotMeter("")
85
+
86
+ siteVec := meter.Vec("site_id", "site_name", "pop_name")
87
+ deviceVec := meter.Vec("site_id", "site_name", "device_id", "device_name")
88
+ ifaceVec := meter.Vec("site_id", "site_name", "device_id", "device_name", "interface_id", "interface_name")
89
+ bgpVec := meter.Vec("site_id", "site_name", "peer_ip", "peer_asn")
90
+
91
+ return &collectorMetrics{
92
+ site: siteMetricInstruments{
93
+ connectivityStatus: newStateSetVec(siteVec, "site_connectivity_status", siteConnectivityStates),
94
+ operationalStatus: newStateSetVec(siteVec, "site_operational_status", siteOperationalStates),
95
+ hosts: siteVec.Gauge("site_hosts"),
96
+ traffic: trafficMetricWriters{
97
+ bytesUp: siteVec.Gauge("site_bytes_upstream_max"),
98
+ bytesDown: siteVec.Gauge("site_bytes_downstream_max"),
99
+ lostUp: siteVec.Gauge("site_lost_upstream_percent"),
100
+ lostDown: siteVec.Gauge("site_lost_downstream_percent"),
101
+ jitterUp: siteVec.Gauge("site_jitter_upstream_ms"),
102
+ jitterDown: siteVec.Gauge("site_jitter_downstream_ms"),
103
+ discardUp: siteVec.Gauge("site_packets_discarded_upstream"),
104
+ discardDown: siteVec.Gauge("site_packets_discarded_downstream"),
105
+ rtt: siteVec.Gauge("site_rtt_ms"),
106
+ lastMileLatency: siteVec.Gauge("site_last_mile_latency_ms"),
107
+ lastMileLoss: siteVec.Gauge("site_last_mile_packet_loss_percent"),
108
+ },
109
+ },
110
+ device: deviceMetricInstruments{
111
+ connectionStatus: newStateSetVec(deviceVec, "device_connection_status", deviceConnectionStates),
112
+ },
113
+ iface: interfaceMetricInstruments{
114
+ connectionStatus: newStateSetVec(ifaceVec, "interface_connection_status", interfaceConnectionStates),
115
+ tunnelUptime: ifaceVec.Gauge("interface_tunnel_uptime_seconds"),
116
+ traffic: trafficMetricWriters{
117
+ bytesUp: ifaceVec.Gauge("interface_bytes_upstream_max"),
118
+ bytesDown: ifaceVec.Gauge("interface_bytes_downstream_max"),
119
+ lostUp: ifaceVec.Gauge("interface_lost_upstream_percent"),
120
+ lostDown: ifaceVec.Gauge("interface_lost_downstream_percent"),
121
+ jitterUp: ifaceVec.Gauge("interface_jitter_upstream_ms"),
122
+ jitterDown: ifaceVec.Gauge("interface_jitter_downstream_ms"),
123
+ discardUp: ifaceVec.Gauge("interface_packets_discarded_upstream"),
124
+ discardDown: ifaceVec.Gauge("interface_packets_discarded_downstream"),
125
+ rtt: ifaceVec.Gauge("interface_rtt_ms"),
126
+ },
127
+ },
128
+ bgp: bgpMetricInstruments{
129
+ sessionStatus: newStateSetVec(bgpVec, "bgp_session_status", bgpSessionStates),
130
+ routes: bgpVec.Gauge("bgp_routes"),
131
+ routesLimit: bgpVec.Gauge("bgp_routes_limit"),
132
+ routesLimitState: newStateSetVec(bgpVec, "bgp_routes_limit_status", bgpRoutesLimitStates),
133
+ ribOutRoutes: bgpVec.Gauge("bgp_rib_out_routes"),
134
+ },
135
+ }
136
+}
137
+
138
+func newStateSetVec(meter metrix.SnapshotVecMeter, name string, states []string) metrix.SnapshotStateSetVec {
139
+ return meter.StateSet(name, metrix.WithStateSetMode(metrix.ModeEnum), metrix.WithStateSetStates(states...))
140
+}
src/go/plugin/go.d/collector/cato_networks/models.go
new
+110
@@ -0,0 +1,110 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+type siteState struct {
6
+ ID string
7
+ Name string
8
+ Description string
9
+ ConnectivityStatus string
10
+ OperationalStatus string
11
+ PopName string
12
+ SiteType string
13
+ ConnectionType string
14
+ CountryCode string
15
+ CountryName string
16
+ Region string
17
+ LastConnected string
18
+ ConnectedSince string
19
+ HostCount int64
20
+
21
+ Metrics trafficMetrics
22
+ Interfaces map[string]*interfaceState
23
+ Devices []deviceState
24
+ BGPPeers []bgpPeerState
25
+}
26
+
27
+type deviceState struct {
28
+ ID string
29
+ Identifier string
30
+ Name string
31
+ Type string
32
+ Connected bool
33
+ HaRole string
34
+ SocketID string
35
+ SocketPlatform string
36
+ SocketSerial string
37
+ SocketVersion string
38
+ InternalIP string
39
+ LastPopName string
40
+ ConnectedSince string
41
+}
42
+
43
+type interfaceState struct {
44
+ ID string
45
+ Name string
46
+ DeviceID string
47
+ DeviceName string
48
+ DeviceSocketID string
49
+ DeviceSocketSerial string
50
+ Type string
51
+ DestType string
52
+ Connected bool
53
+ PopName string
54
+ TunnelRemoteIP string
55
+ TunnelUptime int64
56
+ UpstreamBandwidth int64
57
+ DownstreamBandwidth int64
58
+ PhysicalPort int64
59
+ LinkUp bool
60
+ Metrics trafficMetrics
61
+}
62
+
63
+type trafficMetricPresence uint16
64
+
65
+const (
66
+ trafficMetricBytesUpstreamMax trafficMetricPresence = 1 << iota
67
+ trafficMetricBytesDownstreamMax
68
+ trafficMetricLostUpstreamPercent
69
+ trafficMetricLostDownstreamPercent
70
+ trafficMetricJitterUpstreamMS
71
+ trafficMetricJitterDownstreamMS
72
+ trafficMetricPacketsDiscardedUpstream
73
+ trafficMetricPacketsDiscardedDownstream
74
+ trafficMetricRTTMS
75
+ trafficMetricLastMileLatencyMS
76
+ trafficMetricLastMilePacketLossPercent
77
+)
78
+
79
+type trafficMetrics struct {
80
+ present trafficMetricPresence
81
+ BytesUpstreamMax float64
82
+ BytesDownstreamMax float64
83
+ LostUpstreamPercent float64
84
+ LostDownstreamPercent float64
85
+ JitterUpstreamMS float64
86
+ JitterDownstreamMS float64
87
+ PacketsDiscardedUpstream float64
88
+ PacketsDiscardedDownstream float64
89
+ RTTMS float64
90
+ LastMileLatencyMS float64
91
+ LastMilePacketLossPercent float64
92
+}
93
+
94
+func (m trafficMetrics) has(metric trafficMetricPresence) bool {
95
+ return m.present&metric != 0
96
+}
97
+
98
+type bgpPeerState struct {
99
+ RemoteIP string
100
+ RemoteASN string
101
+ LocalIP string
102
+ LocalASN string
103
+ BGPSession string
104
+ IncomingState string
105
+ OutgoingState string
106
+ RoutesCount int64
107
+ RoutesCountLimit int64
108
+ RoutesCountLimitExceeded bool
109
+ RIBOutRoutes int64
110
+}
src/go/plugin/go.d/collector/cato_networks/normalize.go
new
+123
@@ -0,0 +1,123 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "strings"
7
+
8
+ catomodels "github.com/catonetworks/cato-go-sdk/models"
9
+ catoscalars "github.com/catonetworks/cato-go-sdk/scalars"
10
+)
11
+
12
+func derefZero[T any](v *T) T {
13
+ if v == nil {
14
+ var zero T
15
+ return zero
16
+ }
17
+ return *v
18
+}
19
+
20
+func normalizeName(v string) string {
21
+ return strings.TrimSpace(v)
22
+}
23
+
24
+func normalizeStatus(v string) string {
25
+ v = strings.TrimSpace(v)
26
+ if v == "" {
27
+ return "unknown"
28
+ }
29
+ return strings.ToLower(strings.ReplaceAll(v, " ", "_"))
30
+}
31
+
32
+func connectivityStatusString(v *catomodels.ConnectivityStatus) string {
33
+ if v == nil {
34
+ return ""
35
+ }
36
+ return string(*v)
37
+}
38
+
39
+func operationalStatusString(v *catoscalars.OperationalStatus) string {
40
+ if v == nil {
41
+ return ""
42
+ }
43
+ return v.GetString()
44
+}
45
+
46
+func siteDisplayName(siteID string, siteNames map[string]string, infoName, fallbackName string) string {
47
+ switch {
48
+ case normalizeName(infoName) != "":
49
+ return normalizeName(infoName)
50
+ case normalizeName(fallbackName) != "":
51
+ return normalizeName(fallbackName)
52
+ case normalizeName(siteNames[siteID]) != "":
53
+ return normalizeName(siteNames[siteID])
54
+ default:
55
+ return siteID
56
+ }
57
+}
58
+
59
+func interfaceKey(id, name string) string {
60
+ if strings.TrimSpace(id) != "" {
61
+ return strings.TrimSpace(id)
62
+ }
63
+ if strings.TrimSpace(name) != "" {
64
+ return strings.TrimSpace(name)
65
+ }
66
+ return "all"
67
+}
68
+
69
+func snapshotInterfaceKey(deviceID, id, name string) string {
70
+ key := interfaceKey(id, name)
71
+ if strings.TrimSpace(id) != "" || strings.TrimSpace(deviceID) == "" {
72
+ return key
73
+ }
74
+ return strings.TrimSpace(deviceID) + "/" + key
75
+}
76
+
77
+func firstNonEmpty(values ...string) string {
78
+ for _, v := range values {
79
+ if v = strings.TrimSpace(v); v != "" {
80
+ return v
81
+ }
82
+ }
83
+ return ""
84
+}
85
+
86
+func stableDeviceID(dev deviceState) string {
87
+ return firstNonEmpty(dev.ID, dev.Identifier, dev.SocketID, dev.SocketSerial)
88
+}
89
+
90
+func deviceDisplayName(dev deviceState) string {
91
+ return firstNonEmpty(dev.Name, dev.SocketSerial, dev.Identifier, dev.ID, dev.SocketID)
92
+}
93
+
94
+func resolveMetricInterfaceDevice(site *siteState, iface *interfaceState) {
95
+ if site == nil || iface == nil {
96
+ return
97
+ }
98
+
99
+ metricDeviceID := firstNonEmpty(iface.DeviceSocketID, iface.DeviceSocketSerial)
100
+ if metricDeviceID == "" {
101
+ return
102
+ }
103
+ for _, dev := range site.Devices {
104
+ if deviceMatchesMetricInterface(dev, iface.DeviceSocketID, iface.DeviceSocketSerial) {
105
+ iface.DeviceID = stableDeviceID(dev)
106
+ iface.DeviceName = deviceDisplayName(dev)
107
+ return
108
+ }
109
+ }
110
+ iface.DeviceID = metricDeviceID
111
+}
112
+
113
+func deviceMatchesMetricInterface(dev deviceState, socketID, socketSerial string) bool {
114
+ if socketID = strings.TrimSpace(socketID); socketID != "" {
115
+ if socketID == strings.TrimSpace(dev.SocketID) || socketID == strings.TrimSpace(dev.ID) || socketID == strings.TrimSpace(dev.Identifier) {
116
+ return true
117
+ }
118
+ }
119
+ if socketSerial = strings.TrimSpace(socketSerial); socketSerial != "" && socketSerial == strings.TrimSpace(dev.SocketSerial) {
120
+ return true
121
+ }
122
+ return false
123
+}
src/go/plugin/go.d/collector/cato_networks/normalize_bgp.go
new
+89
@@ -0,0 +1,89 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "strconv"
7
+ "strings"
8
+
9
+ catosdk "github.com/catonetworks/cato-go-sdk"
10
+)
11
+
12
+func normalizeBGP(raw []*catosdk.SiteBgpStatusResult) ([]bgpPeerState, []string) {
13
+ peers := make([]bgpPeerState, 0, len(raw))
14
+ peerIndexes := make(map[string]int)
15
+ var issues []string
16
+ for _, v := range raw {
17
+ if v == nil {
18
+ issues = append(issues, normalizationIssueEmptyPeer)
19
+ continue
20
+ }
21
+ if isEmptyBGPPeerResult(v) {
22
+ issues = append(issues, normalizationIssueEmptyPeer)
23
+ continue
24
+ }
25
+ if !hasBGPPeerRemoteIdentity(v) {
26
+ issues = append(issues, normalizationIssueEmptyPeer)
27
+ continue
28
+ }
29
+ routesCount, ok := parseInt64(v.RoutesCount)
30
+ if !ok {
31
+ issues = append(issues, normalizationIssueParseInt)
32
+ }
33
+ routesCountLimit, ok := parseInt64(v.RoutesCountLimit)
34
+ if !ok {
35
+ issues = append(issues, normalizationIssueParseInt)
36
+ }
37
+ peer := bgpPeerState{
38
+ RemoteIP: v.RemoteIP,
39
+ RemoteASN: v.RemoteASN,
40
+ LocalIP: v.LocalIP,
41
+ LocalASN: v.LocalASN,
42
+ BGPSession: normalizeStatus(v.BGPSession),
43
+ IncomingState: normalizeStatus(v.IncomingConnection.State),
44
+ OutgoingState: normalizeStatus(v.OutgoingConnection.State),
45
+ RoutesCount: routesCount,
46
+ RoutesCountLimit: routesCountLimit,
47
+ RoutesCountLimitExceeded: v.RoutesCountLimitExceeded,
48
+ RIBOutRoutes: int64(len(v.RIBOut)),
49
+ }
50
+ key := bgpPeerKey(peer.RemoteIP, peer.RemoteASN)
51
+ if idx, ok := peerIndexes[key]; ok {
52
+ peers[idx] = peer
53
+ continue
54
+ }
55
+ peerIndexes[key] = len(peers)
56
+ peers = append(peers, peer)
57
+ }
58
+ return peers, issues
59
+}
60
+
61
+func bgpPeerKey(remoteIP, remoteASN string) string {
62
+ return strings.TrimSpace(remoteIP) + "\x00" + strings.TrimSpace(remoteASN)
63
+}
64
+
65
+func hasBGPPeerRemoteIdentity(v *catosdk.SiteBgpStatusResult) bool {
66
+ return strings.TrimSpace(v.RemoteIP) != "" || strings.TrimSpace(v.RemoteASN) != ""
67
+}
68
+
69
+func isEmptyBGPPeerResult(v *catosdk.SiteBgpStatusResult) bool {
70
+ return strings.TrimSpace(v.RemoteIP) == "" &&
71
+ strings.TrimSpace(v.RemoteASN) == "" &&
72
+ strings.TrimSpace(v.LocalIP) == "" &&
73
+ strings.TrimSpace(v.LocalASN) == "" &&
74
+ strings.TrimSpace(v.BGPSession) == "" &&
75
+ strings.TrimSpace(v.IncomingConnection.State) == "" &&
76
+ strings.TrimSpace(v.OutgoingConnection.State) == "" &&
77
+ strings.TrimSpace(v.RoutesCount) == "" &&
78
+ strings.TrimSpace(v.RoutesCountLimit) == "" &&
79
+ len(v.RIBOut) == 0
80
+}
81
+
82
+func parseInt64(v string) (int64, bool) {
83
+ v = strings.TrimSpace(v)
84
+ if v == "" {
85
+ return 0, true
86
+ }
87
+ n, err := strconv.ParseInt(v, 10, 64)
88
+ return n, err == nil
89
+}
src/go/plugin/go.d/collector/cato_networks/normalize_metrics.go
new
+232
@@ -0,0 +1,232 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "fmt"
7
+ "strings"
8
+
9
+ catosdk "github.com/catonetworks/cato-go-sdk"
10
+)
11
+
12
+func mergeMetrics(metrics *catosdk.AccountMetrics, sites map[string]*siteState) []string {
13
+ var issues []string
14
+ for _, rawSite := range metrics.GetAccountMetrics().GetSites() {
15
+ siteID := derefZero(rawSite.GetID())
16
+ if siteID == "" {
17
+ continue
18
+ }
19
+
20
+ site := sites[siteID]
21
+ if site == nil {
22
+ site = &siteState{
23
+ ID: siteID,
24
+ Name: siteDisplayName(siteID, nil, "", derefZero(rawSite.GetName())),
25
+ Interfaces: make(map[string]*interfaceState),
26
+ }
27
+ sites[siteID] = site
28
+ }
29
+ if site.Name == "" {
30
+ site.Name = siteDisplayName(siteID, nil, "", derefZero(rawSite.GetName()))
31
+ }
32
+
33
+ site.Metrics = mergeCatoTrafficMetrics(site.Metrics, rawSite.GetMetrics())
34
+ for _, rawIface := range rawSite.GetInterfaces() {
35
+ iface, ifaceIssues := normalizeMetricsInterface(rawIface)
36
+ issues = append(issues, ifaceIssues...)
37
+ if iface.Name == "" {
38
+ iface.Name = "all"
39
+ }
40
+ if strings.EqualFold(iface.Name, "all") {
41
+ site.Metrics, _ = mergeRawInterfaceTrafficMetrics(site.Metrics, rawIface)
42
+ }
43
+ key := interfaceKey(iface.ID, iface.Name)
44
+ resolveMetricInterfaceDevice(site, &iface)
45
+ if iface.ID == "" && iface.DeviceID != "" {
46
+ key = snapshotInterfaceKey(iface.DeviceID, iface.ID, iface.Name)
47
+ }
48
+ existing := site.Interfaces[key]
49
+ if existing == nil {
50
+ site.Interfaces[key] = &iface
51
+ continue
52
+ }
53
+ if iface.DeviceID == "" {
54
+ iface.DeviceID = existing.DeviceID
55
+ }
56
+ if iface.DeviceName == "" {
57
+ iface.DeviceName = existing.DeviceName
58
+ }
59
+ existing.Metrics = iface.Metrics
60
+ existing.DeviceID = iface.DeviceID
61
+ existing.DeviceName = iface.DeviceName
62
+ existing.DeviceSocketID = iface.DeviceSocketID
63
+ existing.DeviceSocketSerial = iface.DeviceSocketSerial
64
+ if existing.PopName == "" {
65
+ existing.PopName = iface.PopName
66
+ }
67
+ if existing.Type == "" {
68
+ existing.Type = iface.Type
69
+ }
70
+ if existing.TunnelRemoteIP == "" {
71
+ existing.TunnelRemoteIP = iface.TunnelRemoteIP
72
+ }
73
+ }
74
+ }
75
+ return issues
76
+}
77
+
78
+func normalizeMetricsInterface(raw *catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces) (interfaceState, []string) {
79
+ iface := interfaceState{
80
+ Name: derefZero(raw.GetName()),
81
+ TunnelRemoteIP: derefZero(raw.GetRemoteIP()),
82
+ }
83
+ var issues []string
84
+ if info := raw.GetInterfaceInfo(); info != nil {
85
+ iface.ID = info.GetID()
86
+ if iface.Name == "" {
87
+ iface.Name = derefZero(info.GetName())
88
+ }
89
+ iface.DestType = derefZero(info.GetDestType())
90
+ iface.UpstreamBandwidth = derefZero(info.GetUpstreamBandwidth())
91
+ iface.DownstreamBandwidth = derefZero(info.GetDownstreamBandwidth())
92
+ }
93
+ if socket := raw.GetSocketInfo(); socket != nil {
94
+ iface.DeviceSocketID = derefZero(socket.GetID())
95
+ iface.DeviceSocketSerial = derefZero(socket.GetSerial())
96
+ if iface.Type == "" && socket.GetPlatform() != nil {
97
+ iface.Type = fmt.Sprint(*socket.GetPlatform())
98
+ }
99
+ }
100
+ if remote := raw.GetRemoteIPInfo(); remote != nil && iface.TunnelRemoteIP == "" {
101
+ iface.TunnelRemoteIP = derefZero(remote.GetIP())
102
+ }
103
+
104
+ var metricIssues []string
105
+ iface.Metrics, metricIssues = mergeRawInterfaceTrafficMetrics(iface.Metrics, raw)
106
+ issues = append(issues, metricIssues...)
107
+
108
+ return iface, issues
109
+}
110
+
111
+func mergeRawInterfaceTrafficMetrics(base trafficMetrics, raw *catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces) (trafficMetrics, []string) {
112
+ if raw == nil {
113
+ return base, nil
114
+ }
115
+ var issues []string
116
+ base = mergeCatoTrafficMetrics(base, raw.GetMetrics())
117
+ for _, ts := range raw.GetTimeseries() {
118
+ value, ok := latestTimeseriesValue(ts.GetData())
119
+ if !applyTimeseriesMetric(&base, ts.GetLabel(), value, ok) {
120
+ issues = append(issues, normalizationIssueUnknownTimeseriesLabel)
121
+ }
122
+ }
123
+ return base, issues
124
+}
125
+
126
+type catoTrafficMetrics interface {
127
+ GetBytesUpstream() *float64
128
+ GetBytesDownstream() *float64
129
+ GetLostUpstreamPcnt() *float64
130
+ GetLostDownstreamPcnt() *float64
131
+ GetJitterUpstream() *float64
132
+ GetJitterDownstream() *float64
133
+ GetPacketsDiscardedUpstream() *float64
134
+ GetPacketsDiscardedDownstream() *float64
135
+ GetRtt() *int64
136
+}
137
+
138
+func mergeCatoTrafficMetrics(base trafficMetrics, raw catoTrafficMetrics) trafficMetrics {
139
+ if raw == nil {
140
+ return base
141
+ }
142
+ if raw.GetBytesUpstream() != nil {
143
+ base.present |= trafficMetricBytesUpstreamMax
144
+ base.BytesUpstreamMax = *raw.GetBytesUpstream()
145
+ }
146
+ if raw.GetBytesDownstream() != nil {
147
+ base.present |= trafficMetricBytesDownstreamMax
148
+ base.BytesDownstreamMax = *raw.GetBytesDownstream()
149
+ }
150
+ if raw.GetLostUpstreamPcnt() != nil {
151
+ base.present |= trafficMetricLostUpstreamPercent
152
+ base.LostUpstreamPercent = *raw.GetLostUpstreamPcnt()
153
+ }
154
+ if raw.GetLostDownstreamPcnt() != nil {
155
+ base.present |= trafficMetricLostDownstreamPercent
156
+ base.LostDownstreamPercent = *raw.GetLostDownstreamPcnt()
157
+ }
158
+ if raw.GetJitterUpstream() != nil {
159
+ base.present |= trafficMetricJitterUpstreamMS
160
+ base.JitterUpstreamMS = *raw.GetJitterUpstream()
161
+ }
162
+ if raw.GetJitterDownstream() != nil {
163
+ base.present |= trafficMetricJitterDownstreamMS
164
+ base.JitterDownstreamMS = *raw.GetJitterDownstream()
165
+ }
166
+ if raw.GetPacketsDiscardedUpstream() != nil {
167
+ base.present |= trafficMetricPacketsDiscardedUpstream
168
+ base.PacketsDiscardedUpstream = *raw.GetPacketsDiscardedUpstream()
169
+ }
170
+ if raw.GetPacketsDiscardedDownstream() != nil {
171
+ base.present |= trafficMetricPacketsDiscardedDownstream
172
+ base.PacketsDiscardedDownstream = *raw.GetPacketsDiscardedDownstream()
173
+ }
174
+ if raw.GetRtt() != nil {
175
+ base.present |= trafficMetricRTTMS
176
+ base.RTTMS = float64(*raw.GetRtt())
177
+ }
178
+ return base
179
+}
180
+
181
+func latestTimeseriesValue(data [][]float64) (float64, bool) {
182
+ for i := len(data) - 1; i >= 0; i-- {
183
+ if len(data[i]) >= 2 {
184
+ return data[i][1], true
185
+ }
186
+ }
187
+ return 0, false
188
+}
189
+
190
+func applyTimeseriesMetric(m *trafficMetrics, label string, value float64, ok bool) bool {
191
+ if !ok {
192
+ return true
193
+ }
194
+ switch strings.TrimSpace(label) {
195
+ case "bytesUpstream", "bytesUpstreamMax":
196
+ m.present |= trafficMetricBytesUpstreamMax
197
+ m.BytesUpstreamMax = value
198
+ case "bytesDownstream", "bytesDownstreamMax":
199
+ m.present |= trafficMetricBytesDownstreamMax
200
+ m.BytesDownstreamMax = value
201
+ case "lostUpstreamPcnt":
202
+ m.present |= trafficMetricLostUpstreamPercent
203
+ m.LostUpstreamPercent = value
204
+ case "lostDownstreamPcnt":
205
+ m.present |= trafficMetricLostDownstreamPercent
206
+ m.LostDownstreamPercent = value
207
+ case "jitterUpstream":
208
+ m.present |= trafficMetricJitterUpstreamMS
209
+ m.JitterUpstreamMS = value
210
+ case "jitterDownstream":
211
+ m.present |= trafficMetricJitterDownstreamMS
212
+ m.JitterDownstreamMS = value
213
+ case "packetsDiscardedUpstream":
214
+ m.present |= trafficMetricPacketsDiscardedUpstream
215
+ m.PacketsDiscardedUpstream = value
216
+ case "packetsDiscardedDownstream":
217
+ m.present |= trafficMetricPacketsDiscardedDownstream
218
+ m.PacketsDiscardedDownstream = value
219
+ case "rtt":
220
+ m.present |= trafficMetricRTTMS
221
+ m.RTTMS = value
222
+ case "lastMileLatency":
223
+ m.present |= trafficMetricLastMileLatencyMS
224
+ m.LastMileLatencyMS = value
225
+ case "lastMilePacketLoss":
226
+ m.present |= trafficMetricLastMilePacketLossPercent
227
+ m.LastMilePacketLossPercent = value
228
+ default:
229
+ return false
230
+ }
231
+ return true
232
+}
src/go/plugin/go.d/collector/cato_networks/normalize_snapshot.go
new
+141
@@ -0,0 +1,141 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "fmt"
7
+
8
+ catosdk "github.com/catonetworks/cato-go-sdk"
9
+)
10
+
11
+func normalizeSnapshot(snapshot *catosdk.AccountSnapshot, siteNames map[string]string) (map[string]*siteState, []string) {
12
+ out := make(map[string]*siteState)
13
+ var order []string
14
+
15
+ for _, raw := range snapshot.GetAccountSnapshot().GetSites() {
16
+ siteID := derefZero(raw.GetID())
17
+ if siteID == "" {
18
+ continue
19
+ }
20
+
21
+ var (
22
+ infoName string
23
+ description string
24
+ countryCode string
25
+ countryName string
26
+ region string
27
+ siteType string
28
+ connType string
29
+ )
30
+ if info := raw.GetInfoSiteSnapshot(); info != nil {
31
+ infoName = derefZero(info.GetName())
32
+ description = derefZero(info.GetDescription())
33
+ countryCode = derefZero(info.GetCountryCode())
34
+ countryName = derefZero(info.GetCountryName())
35
+ region = derefZero(info.GetRegion())
36
+ if info.GetType() != nil {
37
+ siteType = fmt.Sprint(*info.GetType())
38
+ }
39
+ if info.GetConnType() != nil {
40
+ connType = fmt.Sprint(*info.GetConnType())
41
+ }
42
+ }
43
+ site := &siteState{
44
+ ID: siteID,
45
+ Name: siteDisplayName(siteID, siteNames, infoName, ""),
46
+ Description: description,
47
+ ConnectivityStatus: normalizeStatus(connectivityStatusString(raw.GetConnectivityStatusSiteSnapshot())),
48
+ OperationalStatus: normalizeStatus(operationalStatusString(raw.GetOperationalStatusSiteSnapshot())),
49
+ PopName: derefZero(raw.GetPopName()),
50
+ CountryCode: countryCode,
51
+ CountryName: countryName,
52
+ Region: region,
53
+ SiteType: siteType,
54
+ ConnectionType: connType,
55
+ LastConnected: derefZero(raw.GetLastConnected()),
56
+ ConnectedSince: derefZero(raw.GetConnectedSince()),
57
+ HostCount: derefZero(raw.GetHostCount()),
58
+ Interfaces: make(map[string]*interfaceState),
59
+ }
60
+
61
+ for _, dev := range raw.GetDevices() {
62
+ device := deviceState{
63
+ ID: derefZero(dev.GetID()),
64
+ Identifier: derefZero(dev.GetIdentifier()),
65
+ Name: derefZero(dev.GetName()),
66
+ Type: derefZero(dev.GetType()),
67
+ Connected: derefZero(dev.GetConnected()),
68
+ HaRole: derefZero(dev.GetHaRole()),
69
+ InternalIP: derefZero(dev.GetInternalIP()),
70
+ LastPopName: derefZero(dev.GetLastPopName()),
71
+ ConnectedSince: derefZero(dev.GetConnectedSince()),
72
+ }
73
+ if socket := dev.GetSocketInfo(); socket != nil {
74
+ device.SocketID = derefZero(socket.GetID())
75
+ if platform := socket.GetPlatformSocketInfo(); platform != nil {
76
+ device.SocketPlatform = fmt.Sprint(*platform)
77
+ if device.Type == "" {
78
+ device.Type = device.SocketPlatform
79
+ }
80
+ }
81
+ device.SocketSerial = derefZero(socket.GetSerial())
82
+ device.SocketVersion = derefZero(socket.GetVersion())
83
+ }
84
+ site.Devices = append(site.Devices, device)
85
+ deviceID := stableDeviceID(device)
86
+ deviceName := deviceDisplayName(device)
87
+
88
+ linkStateByID := make(map[string]*catosdk.AccountSnapshot_AccountSnapshot_Sites_Devices_InterfacesLinkState)
89
+ for _, linkState := range dev.GetInterfacesLinkState() {
90
+ if id := derefZero(linkState.GetID()); id != "" {
91
+ linkStateByID[id] = linkState
92
+ }
93
+ }
94
+ for _, rawIface := range dev.GetInterfaces() {
95
+ iface := normalizeSnapshotInterface(rawIface)
96
+ if iface.ID == "" && iface.Name == "" {
97
+ continue
98
+ }
99
+ iface.DeviceID = deviceID
100
+ iface.DeviceName = deviceName
101
+ iface.DeviceSocketID = device.SocketID
102
+ iface.DeviceSocketSerial = device.SocketSerial
103
+ if linkState := linkStateByID[iface.ID]; linkState != nil {
104
+ iface.LinkUp = derefZero(linkState.GetUp())
105
+ }
106
+ key := snapshotInterfaceKey(iface.DeviceID, iface.ID, iface.Name)
107
+ site.Interfaces[key] = &iface
108
+ }
109
+ }
110
+
111
+ out[siteID] = site
112
+ order = append(order, siteID)
113
+ }
114
+
115
+ return out, order
116
+}
117
+
118
+func normalizeSnapshotInterface(raw *catosdk.AccountSnapshot_AccountSnapshot_Sites_Devices_Interfaces) interfaceState {
119
+ iface := interfaceState{
120
+ ID: derefZero(raw.GetID()),
121
+ Name: derefZero(raw.GetName()),
122
+ Type: derefZero(raw.GetType()),
123
+ Connected: derefZero(raw.GetConnected()),
124
+ PopName: derefZero(raw.GetPopName()),
125
+ TunnelRemoteIP: derefZero(raw.GetTunnelRemoteIP()),
126
+ TunnelUptime: derefZero(raw.GetTunnelUptime()),
127
+ PhysicalPort: derefZero(raw.GetPhysicalPort()),
128
+ }
129
+ if info := raw.GetInfoInterfaceSnapshot(); info != nil {
130
+ if iface.ID == "" {
131
+ iface.ID = info.GetID()
132
+ }
133
+ if iface.Name == "" {
134
+ iface.Name = derefZero(info.GetName())
135
+ }
136
+ iface.DestType = derefZero(info.GetDestType())
137
+ iface.UpstreamBandwidth = derefZero(info.GetUpstreamBandwidth())
138
+ iface.DownstreamBandwidth = derefZero(info.GetDownstreamBandwidth())
139
+ }
140
+ return iface
141
+}
src/go/plugin/go.d/collector/cato_networks/normalize_test.go
new
+227
@@ -0,0 +1,227 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "testing"
7
+
8
+ catosdk "github.com/catonetworks/cato-go-sdk"
9
+ "github.com/stretchr/testify/require"
10
+)
11
+
12
+func TestNormalizeBGP(t *testing.T) {
13
+ tests := map[string]struct {
14
+ peers []*catosdk.SiteBgpStatusResult
15
+ wantIssues []string
16
+ check func(*testing.T, []bgpPeerState)
17
+ }{
18
+ "drops peers without remote identity": {
19
+ peers: []*catosdk.SiteBgpStatusResult{
20
+ {IncomingConnection: catosdk.IncomingConnection{State: "Established"}},
21
+ },
22
+ wantIssues: []string{normalizationIssueEmptyPeer},
23
+ check: func(t *testing.T, peers []bgpPeerState) {
24
+ require.Empty(t, peers)
25
+ },
26
+ },
27
+ "deduplicates peer metric labels": {
28
+ peers: []*catosdk.SiteBgpStatusResult{
29
+ {RemoteIP: "192.0.2.10", RemoteASN: "64512", RoutesCount: "1"},
30
+ {RemoteIP: "192.0.2.10", RemoteASN: "64512", RoutesCount: "2"},
31
+ },
32
+ check: func(t *testing.T, peers []bgpPeerState) {
33
+ require.Len(t, peers, 1)
34
+ require.Equal(t, int64(2), peers[0].RoutesCount)
35
+ },
36
+ },
37
+ }
38
+
39
+ for name, tc := range tests {
40
+ t.Run(name, func(t *testing.T) {
41
+ peers, issues := normalizeBGP(tc.peers)
42
+
43
+ require.Equal(t, tc.wantIssues, issues)
44
+ tc.check(t, peers)
45
+ })
46
+ }
47
+}
48
+
49
+func TestNormalizeSnapshot(t *testing.T) {
50
+ tests := map[string]struct {
51
+ snapshot *catosdk.AccountSnapshot
52
+ names map[string]string
53
+ check func(*testing.T, map[string]*siteState, []string)
54
+ }{
55
+ "defaults nil info and statuses": {
56
+ snapshot: &catosdk.AccountSnapshot{AccountSnapshot: &catosdk.AccountSnapshot_AccountSnapshot{
57
+ Sites: []*catosdk.AccountSnapshot_AccountSnapshot_Sites{
58
+ {ID: new("1001")},
59
+ },
60
+ }},
61
+ names: map[string]string{"1001": "Site One"},
62
+ check: func(t *testing.T, sites map[string]*siteState, order []string) {
63
+ require.Equal(t, []string{"1001"}, order)
64
+ require.Equal(t, "Site One", sites["1001"].Name)
65
+ require.Empty(t, sites["1001"].Description)
66
+ require.Empty(t, sites["1001"].CountryCode)
67
+ require.Empty(t, sites["1001"].CountryName)
68
+ require.Empty(t, sites["1001"].Region)
69
+ require.Empty(t, sites["1001"].SiteType)
70
+ require.Empty(t, sites["1001"].ConnectionType)
71
+ require.Equal(t, "unknown", sites["1001"].ConnectivityStatus)
72
+ require.Equal(t, "unknown", sites["1001"].OperationalStatus)
73
+ },
74
+ },
75
+ "keeps same-named empty-id interfaces per device": {
76
+ snapshot: &catosdk.AccountSnapshot{AccountSnapshot: &catosdk.AccountSnapshot_AccountSnapshot{
77
+ Sites: []*catosdk.AccountSnapshot_AccountSnapshot_Sites{
78
+ {
79
+ ID: new("1001"),
80
+ Devices: []*catosdk.AccountSnapshot_AccountSnapshot_Sites_Devices{
81
+ {
82
+ ID: new("socket-a"),
83
+ Name: new("Socket A"),
84
+ Interfaces: []*catosdk.AccountSnapshot_AccountSnapshot_Sites_Devices_Interfaces{
85
+ {Name: new("WAN 1"), Connected: new(true)},
86
+ },
87
+ },
88
+ {
89
+ ID: new("socket-b"),
90
+ Name: new("Socket B"),
91
+ Interfaces: []*catosdk.AccountSnapshot_AccountSnapshot_Sites_Devices_Interfaces{
92
+ {Name: new("WAN 1"), Connected: new(false)},
93
+ },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ }},
99
+ check: func(t *testing.T, sites map[string]*siteState, _ []string) {
100
+ site := sites["1001"]
101
+ require.Len(t, site.Interfaces, 2)
102
+ require.Equal(t, "socket-a", site.Interfaces[snapshotInterfaceKey("socket-a", "", "WAN 1")].DeviceID)
103
+ require.Equal(t, "socket-b", site.Interfaces[snapshotInterfaceKey("socket-b", "", "WAN 1")].DeviceID)
104
+ },
105
+ },
106
+ }
107
+
108
+ for name, tc := range tests {
109
+ t.Run(name, func(t *testing.T) {
110
+ sites, order := normalizeSnapshot(tc.snapshot, tc.names)
111
+ tc.check(t, sites, order)
112
+ })
113
+ }
114
+}
115
+
116
+func TestMergeMetrics(t *testing.T) {
117
+ tests := map[string]struct {
118
+ metrics *catosdk.AccountMetrics
119
+ sites map[string]*siteState
120
+ check func(*testing.T, map[string]*siteState, []string)
121
+ }{
122
+ "merges all-interface metrics into site metrics": {
123
+ metrics: func() *catosdk.AccountMetrics {
124
+ siteBytesUpstream := float64(100)
125
+ siteRTT := int64(42)
126
+ return &catosdk.AccountMetrics{AccountMetrics: &catosdk.AccountMetrics_AccountMetrics{
127
+ Sites: []*catosdk.AccountMetrics_AccountMetrics_Sites{
128
+ {
129
+ ID: new("1001"),
130
+ Metrics: &catosdk.AccountMetrics_AccountMetrics_Sites_Metrics{
131
+ BytesUpstream: &siteBytesUpstream,
132
+ Rtt: &siteRTT,
133
+ },
134
+ Interfaces: []*catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces{
135
+ {
136
+ Name: new("all"),
137
+ Timeseries: []*catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces_Timeseries{
138
+ {Label: "bytesDownstreamMax", Data: [][]float64{{1, 200}}},
139
+ },
140
+ },
141
+ },
142
+ },
143
+ },
144
+ }}
145
+ }(),
146
+ sites: map[string]*siteState{
147
+ "1001": {ID: "1001", Interfaces: make(map[string]*interfaceState)},
148
+ },
149
+ check: func(t *testing.T, sites map[string]*siteState, issues []string) {
150
+ require.Empty(t, issues)
151
+ require.Equal(t, float64(100), sites["1001"].Metrics.BytesUpstreamMax)
152
+ require.Equal(t, float64(200), sites["1001"].Metrics.BytesDownstreamMax)
153
+ require.Equal(t, float64(42), sites["1001"].Metrics.RTTMS)
154
+ },
155
+ },
156
+ "matches metrics interfaces to snapshot devices by socket info": {
157
+ metrics: &catosdk.AccountMetrics{AccountMetrics: &catosdk.AccountMetrics_AccountMetrics{
158
+ Sites: []*catosdk.AccountMetrics_AccountMetrics_Sites{
159
+ {
160
+ ID: new("1001"),
161
+ Interfaces: []*catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces{
162
+ {
163
+ Name: new("WAN 1"),
164
+ SocketInfo: &catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces_SocketInfo{
165
+ ID: new("socket-a"),
166
+ },
167
+ Metrics: &catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces_Metrics{
168
+ BytesUpstream: new(float64(100)),
169
+ },
170
+ },
171
+ },
172
+ },
173
+ },
174
+ }},
175
+ sites: map[string]*siteState{
176
+ "1001": {
177
+ ID: "1001",
178
+ Name: "Paris Office",
179
+ Devices: []deviceState{
180
+ {ID: "dev-a", SocketID: "socket-a", Name: "Socket A"},
181
+ },
182
+ Interfaces: map[string]*interfaceState{
183
+ snapshotInterfaceKey("dev-a", "", "WAN 1"): {
184
+ Name: "WAN 1",
185
+ DeviceID: "dev-a",
186
+ },
187
+ },
188
+ },
189
+ },
190
+ check: func(t *testing.T, sites map[string]*siteState, issues []string) {
191
+ require.Empty(t, issues)
192
+ require.Len(t, sites["1001"].Interfaces, 1)
193
+ iface := sites["1001"].Interfaces[snapshotInterfaceKey("dev-a", "", "WAN 1")]
194
+ require.Equal(t, "dev-a", iface.DeviceID)
195
+ require.Equal(t, "Socket A", iface.DeviceName)
196
+ require.Equal(t, float64(100), iface.Metrics.BytesUpstreamMax)
197
+ },
198
+ },
199
+ }
200
+
201
+ for name, tc := range tests {
202
+ t.Run(name, func(t *testing.T) {
203
+ issues := mergeMetrics(tc.metrics, tc.sites)
204
+ tc.check(t, tc.sites, issues)
205
+ })
206
+ }
207
+}
208
+
209
+func TestBGPSessionState(t *testing.T) {
210
+ tests := map[string]struct {
211
+ status string
212
+ want string
213
+ }{
214
+ "established": {status: "Established", want: "up"},
215
+ "up": {status: "up", want: "up"},
216
+ "not established": {status: "not_established", want: "down"},
217
+ "idle": {status: "idle", want: "down"},
218
+ "empty": {status: "", want: "unknown"},
219
+ "unknown": {status: "unknown", want: "unknown"},
220
+ }
221
+
222
+ for name, tc := range tests {
223
+ t.Run(name, func(t *testing.T) {
224
+ require.Equal(t, tc.want, bgpSessionState(tc.status))
225
+ })
226
+ }
227
+}
src/go/plugin/go.d/collector/cato_networks/taxonomy.yaml
new
+46
@@ -0,0 +1,46 @@
1
+taxonomy_version: 1
2
+plugin_name: go.d.plugin
3
+module_name: cato_networks
4
+placements:
5
+ - id: cato-networks
6
+ section_id: remote-devices
7
+ title: Cato Networks
8
+ icon: snmp
9
+ properties: { important: false, grouping: true }
10
+ items:
11
+ - type: group
12
+ id: sites
13
+ title: Sites
14
+ items:
15
+ - cato_networks.site_connectivity_status
16
+ - cato_networks.site_operational_status
17
+ - cato_networks.site_hosts
18
+ - cato_networks.site_traffic
19
+ - cato_networks.site_packet_loss
20
+ - cato_networks.site_discarded_packets
21
+ - cato_networks.site_jitter
22
+ - cato_networks.site_latency
23
+ - cato_networks.site_last_mile_packet_loss
24
+ - type: group
25
+ id: devices
26
+ title: Devices
27
+ items:
28
+ - cato_networks.device_connection_status
29
+ - type: group
30
+ id: interfaces
31
+ title: Interfaces
32
+ items:
33
+ - cato_networks.interface_connection_status
34
+ - cato_networks.interface_tunnel_uptime
35
+ - cato_networks.interface_traffic
36
+ - cato_networks.interface_packet_loss
37
+ - cato_networks.interface_discarded_packets
38
+ - cato_networks.interface_jitter
39
+ - cato_networks.interface_latency
40
+ - type: group
41
+ id: bgp
42
+ title: BGP
43
+ items:
44
+ - cato_networks.bgp_session_status
45
+ - cato_networks.bgp_routes
46
+ - cato_networks.bgp_routes_limit_status
src/go/plugin/go.d/collector/cato_networks/test_helpers_test.go
new
+550
@@ -0,0 +1,550 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "io"
10
+ "maps"
11
+ "net/http"
12
+ "net/http/httptest"
13
+ "os"
14
+ "path/filepath"
15
+ "sort"
16
+ "strconv"
17
+ "strings"
18
+ "sync"
19
+ "testing"
20
+ "time"
21
+
22
+ catosdk "github.com/catonetworks/cato-go-sdk"
23
+ catomodels "github.com/catonetworks/cato-go-sdk/models"
24
+ catoscalars "github.com/catonetworks/cato-go-sdk/scalars"
25
+ "github.com/stretchr/testify/require"
26
+
27
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
28
+)
29
+
30
+type fakeAPIClient struct {
31
+ mu sync.Mutex
32
+
33
+ lookup *catosdk.EntityLookup
34
+ lookupErr error
35
+ lookupPages map[int64]*catosdk.EntityLookup
36
+ snapshot *catosdk.AccountSnapshot
37
+ snapshotErr error
38
+ metrics *catosdk.AccountMetrics
39
+ bgp map[string][]*catosdk.SiteBgpStatusResult
40
+ metricsErrSites map[string]error
41
+ metricsHook func(context.Context, []string)
42
+ bgpErrSites map[string]error
43
+ groupInterfaces []*bool
44
+ metricsSiteIDs [][]string
45
+ probeErr error
46
+ probeCalls int
47
+ lookupCalls int
48
+ snapshotCalls int
49
+ metricsCalls int
50
+ bgpCalls int
51
+}
52
+
53
+func (f *fakeAPIClient) Probe(context.Context, string) error {
54
+ f.probeCalls++
55
+ return f.probeErr
56
+}
57
+
58
+func (f *fakeAPIClient) LookupSites(_ context.Context, _ string, _ int64, from int64) (*catosdk.EntityLookup, error) {
59
+ f.lookupCalls++
60
+ if f.lookupErr != nil {
61
+ return nil, f.lookupErr
62
+ }
63
+ if f.lookupPages != nil {
64
+ return f.lookupPages[from], nil
65
+ }
66
+ return f.lookup, nil
67
+}
68
+
69
+func (f *fakeAPIClient) AccountSnapshot(context.Context, string, []string) (*catosdk.AccountSnapshot, error) {
70
+ f.snapshotCalls++
71
+ if f.snapshotErr != nil {
72
+ return nil, f.snapshotErr
73
+ }
74
+ return f.snapshot, nil
75
+}
76
+
77
+func (f *fakeAPIClient) AccountMetrics(ctx context.Context, _ string, siteIDs []string, _ string, _ int64, groupInterfaces *bool) (*catosdk.AccountMetrics, error) {
78
+ f.mu.Lock()
79
+
80
+ f.metricsCalls++
81
+ siteIDs = append([]string(nil), siteIDs...)
82
+ f.metricsSiteIDs = append(f.metricsSiteIDs, siteIDs)
83
+ if groupInterfaces == nil {
84
+ f.groupInterfaces = append(f.groupInterfaces, nil)
85
+ } else {
86
+ v := *groupInterfaces
87
+ f.groupInterfaces = append(f.groupInterfaces, &v)
88
+ }
89
+ hook := f.metricsHook
90
+ metrics := f.metrics
91
+ var err error
92
+ if len(siteIDs) > 0 && f.metricsErrSites != nil {
93
+ err = f.metricsErrSites[siteIDs[0]]
94
+ }
95
+ f.mu.Unlock()
96
+
97
+ if hook != nil {
98
+ hook(ctx, siteIDs)
99
+ }
100
+ if err != nil {
101
+ return nil, err
102
+ }
103
+ return metrics, nil
104
+}
105
+
106
+func (f *fakeAPIClient) SiteBgpStatus(_ context.Context, _ string, siteID string) ([]*catosdk.SiteBgpStatusResult, error) {
107
+ f.bgpCalls++
108
+ if f.bgpErrSites != nil {
109
+ if err := f.bgpErrSites[siteID]; err != nil {
110
+ return nil, err
111
+ }
112
+ }
113
+ return f.bgp[siteID], nil
114
+}
115
+
116
+type rawCatoResponse struct {
117
+ status int
118
+ body string
119
+}
120
+
121
+const (
122
+ operationDiscovery = "entityLookup"
123
+ operationSnapshot = "accountSnapshot"
124
+ operationMetrics = "accountMetrics"
125
+ operationBGP = "siteBgpStatus"
126
+)
127
+
128
+func newTestCollector() (*Collector, *fakeAPIClient) {
129
+ c := New()
130
+ c.AccountID = "12345"
131
+ c.APIKey = "secret"
132
+ c.now = fixedCatoTestNow
133
+ fake := newFixtureAPIClient()
134
+ c.client = fake
135
+ return c, fake
136
+}
137
+
138
+func fixedCatoTestNow() time.Time {
139
+ return time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
140
+}
141
+
142
+func initCollector(t *testing.T, c *Collector) {
143
+ t.Helper()
144
+ require.NoError(t, c.Init(context.Background()))
145
+}
146
+
147
+func collectOnce(t *testing.T, c *Collector) {
148
+ t.Helper()
149
+ _, err := collectScalarSeries(t, c)
150
+ require.NoError(t, err)
151
+}
152
+
153
+func collectScalarSeries(t *testing.T, c *Collector) (map[string]metrix.SampleValue, error) {
154
+ t.Helper()
155
+ cc := mustCycleController(t, c.store)
156
+ committed := false
157
+ cc.BeginCycle()
158
+ defer func() {
159
+ if !committed {
160
+ cc.AbortCycle()
161
+ }
162
+ }()
163
+ if err := c.Collect(context.Background()); err != nil {
164
+ return nil, err
165
+ }
166
+ if err := cc.CommitCycleSuccess(); err != nil {
167
+ return nil, err
168
+ }
169
+ committed = true
170
+ return scalarSeriesFromAllHostScopes(c.store), nil
171
+}
172
+
173
+func collectError(t *testing.T, c *Collector) error {
174
+ t.Helper()
175
+ cc := mustCycleController(t, c.store)
176
+ cc.BeginCycle()
177
+ err := c.Collect(context.Background())
178
+ cc.AbortCycle()
179
+ return err
180
+}
181
+
182
+func newRawCatoFixtureServer(t *testing.T) *httptest.Server {
183
+ t.Helper()
184
+ return newRawCatoFixtureServerWithResponses(t, nil)
185
+}
186
+
187
+func newRawCatoFixtureServerWithResponses(t *testing.T, overrides map[string]rawCatoResponse) *httptest.Server {
188
+ t.Helper()
189
+
190
+ responses := map[string]rawCatoResponse{
191
+ operationDiscovery: {body: loadSDKCompatibleMockoonResponseBody(t, "entityLookup")},
192
+ operationSnapshot: {body: loadSDKCompatibleMockoonResponseBody(t, "accountSnapshot")},
193
+ operationMetrics: {body: loadSDKCompatibleMockoonResponseBody(t, "accountMetrics")},
194
+ operationBGP: {body: loadTestdata(t, "cato-site-bgp-status.schema-shaped.json")},
195
+ }
196
+ maps.Copy(responses, overrides)
197
+
198
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
199
+ if r.Method != http.MethodPost {
200
+ t.Errorf("unexpected method: %s", r.Method)
201
+ http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
202
+ return
203
+ }
204
+ if r.Header.Get("x-api-key") != "secret" || r.Header.Get("x-account-id") != "12345" {
205
+ t.Errorf("unexpected auth headers: x-api-key present=%v x-account-id present=%v", r.Header.Get("x-api-key") != "", r.Header.Get("x-account-id") != "")
206
+ http.Error(w, "unexpected auth headers", http.StatusUnauthorized)
207
+ return
208
+ }
209
+
210
+ body, err := io.ReadAll(r.Body)
211
+ if err != nil {
212
+ t.Errorf("read request body: %v", err)
213
+ http.Error(w, "read request body", http.StatusInternalServerError)
214
+ return
215
+ }
216
+ request := string(body)
217
+
218
+ var operation string
219
+ switch {
220
+ case strings.Contains(request, "siteBgpStatus"):
221
+ operation = operationBGP
222
+ case strings.Contains(request, "entityLookup"):
223
+ operation = operationDiscovery
224
+ case strings.Contains(request, "accountSnapshot"):
225
+ operation = operationSnapshot
226
+ case strings.Contains(request, "accountMetrics"):
227
+ operation = operationMetrics
228
+ default:
229
+ t.Errorf("unexpected Cato GraphQL request: %s", request)
230
+ http.Error(w, "unexpected request", http.StatusBadRequest)
231
+ return
232
+ }
233
+
234
+ response := responses[operation]
235
+ status := response.status
236
+ if status == 0 {
237
+ status = http.StatusOK
238
+ }
239
+ w.Header().Set("Content-Type", "application/json")
240
+ w.WriteHeader(status)
241
+ if _, err := w.Write([]byte(response.body)); err != nil {
242
+ t.Errorf("write response: %v", err)
243
+ }
244
+ }))
245
+}
246
+
247
+func loadMockoonResponseBody(t *testing.T, label string) string {
248
+ t.Helper()
249
+
250
+ var env struct {
251
+ Routes []struct {
252
+ Responses []struct {
253
+ Label string `json:"label"`
254
+ Body string `json:"body"`
255
+ } `json:"responses"`
256
+ } `json:"routes"`
257
+ }
258
+ require.NoError(t, json.Unmarshal([]byte(loadTestdata(t, "centreon-cato-api.mockoon.json")), &env))
259
+ for _, route := range env.Routes {
260
+ for _, response := range route.Responses {
261
+ if response.Label == label {
262
+ return response.Body
263
+ }
264
+ }
265
+ }
266
+ t.Fatalf("Mockoon response %q not found", label)
267
+ return ""
268
+}
269
+
270
+func loadSDKCompatibleMockoonResponseBody(t *testing.T, label string) string {
271
+ t.Helper()
272
+
273
+ body := loadMockoonResponseBody(t, label)
274
+ switch label {
275
+ case "accountSnapshot":
276
+ body = adaptAccountSnapshotFixtureForSDK(t, body)
277
+ }
278
+ return body
279
+}
280
+
281
+func adaptAccountSnapshotFixtureForSDK(t *testing.T, body string) string {
282
+ t.Helper()
283
+
284
+ var payload map[string]any
285
+ require.NoError(t, json.Unmarshal([]byte(body), &payload))
286
+ sites := payload["data"].(map[string]any)["accountSnapshot"].(map[string]any)["sites"].([]any)
287
+ for _, rawSite := range sites {
288
+ site := rawSite.(map[string]any)
289
+ if v, ok := site["connectivityStatus"]; ok {
290
+ site["connectivityStatusSiteSnapshot"] = strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
291
+ delete(site, "connectivityStatus")
292
+ }
293
+ if v, ok := site["operationalStatus"]; ok {
294
+ site["operationalStatusSiteSnapshot"] = strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
295
+ delete(site, "operationalStatus")
296
+ }
297
+ if v, ok := site["info"]; ok {
298
+ site["infoSiteSnapshot"] = v
299
+ delete(site, "info")
300
+ }
301
+ delete(site, "operationalStats")
302
+ }
303
+ data, err := json.Marshal(payload)
304
+ require.NoError(t, err)
305
+ return string(data)
306
+}
307
+
308
+func loadTestdata(t *testing.T, name string) string {
309
+ t.Helper()
310
+
311
+ data, err := os.ReadFile(filepath.Join("testdata", name))
312
+ require.NoError(t, err)
313
+ return string(data)
314
+}
315
+
316
+func newFixtureAPIClient() *fakeAPIClient {
317
+ return &fakeAPIClient{
318
+ lookup: fixtureLookup(),
319
+ snapshot: fixtureSnapshot(),
320
+ metrics: fixtureMetrics(),
321
+ bgp: map[string][]*catosdk.SiteBgpStatusResult{
322
+ "1001": {
323
+ {
324
+ RemoteIP: "192.0.2.10",
325
+ RemoteASN: "64512",
326
+ LocalIP: "198.51.100.10",
327
+ LocalASN: "65000",
328
+ BGPSession: "Established",
329
+ RoutesCount: "12",
330
+ RoutesCountLimit: "100",
331
+ RIBOut: []catosdk.RIBOut{{Subnet: "10.0.0.0/8"}},
332
+ },
333
+ },
334
+ "1002": nil,
335
+ },
336
+ }
337
+}
338
+
339
+func fixtureLookup() *catosdk.EntityLookup {
340
+ total := int64(2)
341
+ siteType := catomodels.EntityTypeSite
342
+ return &catosdk.EntityLookup{EntityLookup: catosdk.EntityLookup_EntityLookup{
343
+ Total: &total,
344
+ Items: []*catosdk.EntityLookup_EntityLookup_Items{
345
+ {Entity: catosdk.EntityLookup_EntityLookup_Items_Entity{ID: "1001", Name: new("Paris Office"), Type: siteType}},
346
+ {Entity: catosdk.EntityLookup_EntityLookup_Items_Entity{ID: "1002", Name: new("Toulouse Office"), Type: siteType}},
347
+ },
348
+ }}
349
+}
350
+
351
+func fixtureLookupPage(total int64, ids ...string) *catosdk.EntityLookup {
352
+ siteType := catomodels.EntityTypeSite
353
+ items := make([]*catosdk.EntityLookup_EntityLookup_Items, 0, len(ids))
354
+ for _, id := range ids {
355
+ items = append(items, &catosdk.EntityLookup_EntityLookup_Items{
356
+ Entity: catosdk.EntityLookup_EntityLookup_Items_Entity{ID: id, Name: new("Site " + id), Type: siteType},
357
+ })
358
+ }
359
+ return &catosdk.EntityLookup{EntityLookup: catosdk.EntityLookup_EntityLookup{
360
+ Total: &total,
361
+ Items: items,
362
+ }}
363
+}
364
+
365
+func numberedSiteIDs(start, count int) []string {
366
+ ids := make([]string, 0, count)
367
+ for id := start; id < start+count; id++ {
368
+ ids = append(ids, fmt.Sprint(id))
369
+ }
370
+ return ids
371
+}
372
+
373
+func fixtureSnapshotForSiteIDs(ids ...string) *catosdk.AccountSnapshot {
374
+ sites := make([]*catosdk.AccountSnapshot_AccountSnapshot_Sites, 0, len(ids))
375
+ for _, siteID := range ids {
376
+ sites = append(sites, &catosdk.AccountSnapshot_AccountSnapshot_Sites{
377
+ ID: new(siteID),
378
+ ConnectivityStatusSiteSnapshot: connectivityPtr("connected"),
379
+ OperationalStatusSiteSnapshot: operationalPtr("active"),
380
+ PopName: new("POP-" + siteID),
381
+ InfoSiteSnapshot: &catosdk.AccountSnapshot_AccountSnapshot_Sites_InfoSiteSnapshot{
382
+ Name: new("Site " + siteID),
383
+ },
384
+ })
385
+ }
386
+ return &catosdk.AccountSnapshot{AccountSnapshot: &catosdk.AccountSnapshot_AccountSnapshot{Sites: sites}}
387
+}
388
+
389
+func fixtureSnapshot() *catosdk.AccountSnapshot {
390
+ return &catosdk.AccountSnapshot{AccountSnapshot: &catosdk.AccountSnapshot_AccountSnapshot{
391
+ Sites: []*catosdk.AccountSnapshot_AccountSnapshot_Sites{
392
+ {
393
+ ID: new("1001"),
394
+ ConnectivityStatusSiteSnapshot: connectivityPtr("connected"),
395
+ OperationalStatusSiteSnapshot: operationalPtr("active"),
396
+ PopName: new("POP-Paris"),
397
+ HostCount: new(int64(42)),
398
+ ConnectedSince: new("2026-05-01T10:00:00Z"),
399
+ InfoSiteSnapshot: &catosdk.AccountSnapshot_AccountSnapshot_Sites_InfoSiteSnapshot{
400
+ Name: new("Paris Office"),
401
+ Description: new("Main site"),
402
+ CountryCode: new("FR"),
403
+ CountryName: new("France"),
404
+ },
405
+ Devices: []*catosdk.AccountSnapshot_AccountSnapshot_Sites_Devices{
406
+ {
407
+ ID: new("dev-1"),
408
+ Name: new("Socket 1"),
409
+ Type: new("socket"),
410
+ Connected: new(true),
411
+ Interfaces: []*catosdk.AccountSnapshot_AccountSnapshot_Sites_Devices_Interfaces{
412
+ {
413
+ ID: new("wan1"),
414
+ Name: new("WAN 1"),
415
+ Connected: new(true),
416
+ PopName: new("POP-Paris"),
417
+ TunnelRemoteIP: new("203.0.113.10"),
418
+ TunnelUptime: new(int64(3600)),
419
+ },
420
+ },
421
+ },
422
+ },
423
+ },
424
+ {
425
+ ID: new("1002"),
426
+ ConnectivityStatusSiteSnapshot: connectivityPtr("Degraded"),
427
+ OperationalStatusSiteSnapshot: operationalPtr("locked"),
428
+ PopName: new("POP-Toulouse"),
429
+ HostCount: new(int64(7)),
430
+ InfoSiteSnapshot: &catosdk.AccountSnapshot_AccountSnapshot_Sites_InfoSiteSnapshot{
431
+ Name: new("Toulouse Office"),
432
+ },
433
+ },
434
+ },
435
+ }}
436
+}
437
+
438
+func fixtureMetrics() *catosdk.AccountMetrics {
439
+ return &catosdk.AccountMetrics{AccountMetrics: &catosdk.AccountMetrics_AccountMetrics{
440
+ Sites: []*catosdk.AccountMetrics_AccountMetrics_Sites{
441
+ {
442
+ ID: new("1001"),
443
+ Name: new("Paris Office"),
444
+ Interfaces: []*catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces{
445
+ {
446
+ Name: new("all"),
447
+ Timeseries: []*catosdk.AccountMetrics_AccountMetrics_Sites_Interfaces_Timeseries{
448
+ {Label: "bytesUpstreamMax", Data: [][]float64{{1, 6008}, {2, 7168}}},
449
+ {Label: "bytesDownstreamMax", Data: [][]float64{{1, 12008}, {2, 11168}}},
450
+ {Label: "lostUpstreamPcnt", Data: [][]float64{{1, 0.2}}},
451
+ {Label: "packetsDiscardedUpstream", Data: [][]float64{{1, 1}, {2, 2}}},
452
+ {Label: "packetsDiscardedDownstream", Data: [][]float64{{1, 3}, {2, 4}}},
453
+ {Label: "rtt", Data: [][]float64{{1, 15}}},
454
+ },
455
+ },
456
+ },
457
+ },
458
+ },
459
+ }}
460
+}
461
+
462
+func requireMetricValues(t *testing.T, got map[string]metrix.SampleValue, want map[string]metrix.SampleValue) {
463
+ t.Helper()
464
+ for key, wantValue := range want {
465
+ gotValue, ok := got[key]
466
+ require.True(t, ok, "missing metric %s", key)
467
+ require.Equal(t, wantValue, gotValue, "metric %s", key)
468
+ }
469
+}
470
+
471
+func requireMetricsMissing(t *testing.T, got map[string]metrix.SampleValue, keys []string) {
472
+ t.Helper()
473
+ for _, key := range keys {
474
+ require.NotContains(t, got, key)
475
+ }
476
+}
477
+
478
+func scalarSeriesFromAllHostScopes(store metrix.CollectorStore) map[string]metrix.SampleValue {
479
+ out := make(map[string]metrix.SampleValue)
480
+ reader := store.Read(metrix.ReadFlatten())
481
+ for _, scope := range reader.HostScopes() {
482
+ maps.Copy(out, scalarSeriesFromReader(store.Read(metrix.ReadFlatten(), metrix.ReadHostScope(scope.ScopeKey))))
483
+ }
484
+ return out
485
+}
486
+
487
+func scalarSeriesFromReader(reader metrix.Reader) map[string]metrix.SampleValue {
488
+ out := make(map[string]metrix.SampleValue)
489
+ if reader == nil {
490
+ return out
491
+ }
492
+ reader.ForEachSeries(func(name string, labels metrix.LabelView, value metrix.SampleValue) {
493
+ out[metricKeyFromLabelView(name, labels)] = value
494
+ })
495
+ return out
496
+}
497
+
498
+func metricKey(name string, labels metrix.Labels) string {
499
+ name = strings.TrimSpace(name)
500
+ if name == "" {
501
+ return ""
502
+ }
503
+ if len(labels) == 0 {
504
+ return name
505
+ }
506
+
507
+ keys := make([]string, 0, len(labels))
508
+ for key := range labels {
509
+ keys = append(keys, key)
510
+ }
511
+ sort.Strings(keys)
512
+
513
+ var b strings.Builder
514
+ b.WriteString(name)
515
+ b.WriteByte('{')
516
+ for i, key := range keys {
517
+ if i > 0 {
518
+ b.WriteByte(',')
519
+ }
520
+ b.WriteString(key)
521
+ b.WriteByte('=')
522
+ b.WriteString(strconv.Quote(labels[key]))
523
+ }
524
+ b.WriteByte('}')
525
+ return b.String()
526
+}
527
+
528
+func stateMetricKey(name, state string, labels metrix.Labels) string {
529
+ out := make(metrix.Labels, len(labels)+1)
530
+ maps.Copy(out, labels)
531
+ out[name] = state
532
+ return metricKey(name, out)
533
+}
534
+
535
+func mustCycleController(t *testing.T, s metrix.CollectorStore) metrix.CycleController {
536
+ t.Helper()
537
+ managed, ok := metrix.AsCycleManagedStore(s)
538
+ require.True(t, ok)
539
+ return managed.CycleController()
540
+}
541
+
542
+func connectivityPtr(v string) *catomodels.ConnectivityStatus {
543
+ status := catomodels.ConnectivityStatus(v)
544
+ return &status
545
+}
546
+
547
+func operationalPtr(v string) *catoscalars.OperationalStatus {
548
+ status := catoscalars.OperationalStatus(v)
549
+ return &status
550
+}
src/go/plugin/go.d/collector/cato_networks/testdata/README.md
new
+16
@@ -0,0 +1,16 @@
1
+# Cato Networks Test Fixtures
2
+
3
+`centreon-cato-api.mockoon.json` is a compact JSON copy of the mirrored
4
+Centreon plugins repository file:
5
+
6
+```text
7
+tests/network/security/cato/networks/api/cato-api.json
8
+```
9
+
10
+Source commit: `a4f99c7763517778099681d873609ea7d203a751`
11
+
12
+Upstream URL:
13
+https://github.com/centreon/centreon-plugins/blob/a4f99c7763517778099681d873609ea7d203a751/tests/network/security/cato/networks/api/cato-api.json
14
+
15
+The source repository is Apache-2.0 licensed. The fixture provides raw Mockoon
16
+GraphQL responses for `entityLookup`, `accountSnapshot`, and `accountMetrics`.
src/go/plugin/go.d/collector/cato_networks/testdata/cato-site-bgp-status.schema-shaped.json
new
+21
@@ -0,0 +1,21 @@
1
+{
2
+ "data": {
3
+ "site": {
4
+ "siteBgpStatus": {
5
+ "status": [
6
+ {
7
+ "remoteIp": "192.0.2.10",
8
+ "bgpSession": "Established",
9
+ "bfdSession": "Up",
10
+ "routesFromPeer": ["10.0.0.0/8"],
11
+ "routesToPeer": ["0.0.0.0/0"],
12
+ "rejectedRoutesFromPeer": []
13
+ }
14
+ ],
15
+ "rawStatus": [
16
+ "{\"Remote_IP\":\"192.0.2.10\",\"Remote_ASN\":\"64512\",\"Local_IP\":\"198.51.100.10\",\"Local_ASN\":\"65000\",\"BGP_Session\":\"Established\",\"Incoming_Connection\":{\"State\":\"Established\",\"Transport\":\"TCP\"},\"Outgoing_Connection\":{\"State\":\"Established\",\"Transport\":\"TCP\"},\"routes_count\":\"12\",\"routes_count_limit\":\"100\",\"routes_count_limit_exceeded\":false,\"RIB_out\":[{\"subnet\":\"10.0.0.0/8\"}]}"
17
+ ]
18
+ }
19
+ }
20
+ }
21
+}
src/go/plugin/go.d/collector/cato_networks/testdata/centreon-cato-api.mockoon.json
new
+1
@@ -0,0 +1 @@
1
+{"uuid":"4bfa9382-ab42-4931-b2d6-144f9be7db6b","lastMigration":33,"name":"Cato","endpointPrefix":"","latency":0,"port":3010,"hostname":"","folders":[],"routes":[{"uuid":"624ec454-44af-4de2-8f0c-2a19805b9286","type":"http","documentation":"","method":"post","endpoint":"api/v1/graphql2","responses":[{"uuid":"12792914-4b5f-48f8-931f-a48be1d79c74","body":"{\n \"data\": {\n \"entityLookup\": {\n \"total\": 3,\n \"items\": [\n {\n \"entity\": {\n \"id\": \"1001\",\n \"name\": \"Site Paris\"\n }\n },\n {\n \"entity\": {\n \"id\": \"1002\",\n \"name\": \"Site Toulouse\"\n }\n },\n {\n \"entity\": {\n \"id\": \"1003\",\n \"name\": \"Site Saint Girons\"\n }\n }\n ]\n }\n }\n}","latency":0,"statusCode":200,"label":"entityLookup","headers":[{"key":"access-control-allow-headers","value":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"},{"key":"access-control-allow-methods","value":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},{"key":"access-control-allow-origin","value":"*"},{"key":"content-security-policy","value":"default-src 'none'"},{"key":"content-type","value":"text/html; charset=utf-8"},{"key":"x-content-type-options","value":"nosniff"}],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[{"target":"body","modifier":"","value":"entityLookup","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"search","invert":true,"operator":"regex"},{"target":"body","modifier":"","value":"entityIDs","invert":true,"operator":"regex"}],"rulesOperator":"AND","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]},{"uuid":"bb550ebd-bccc-4f5c-a7c9-96f54011759f","body":"{\n \"data\": {\n \"entityLookup\": {\n \"total\": 1,\n \"items\": [\n {\n \"entity\": {\n \"id\": \"1001\",\n \"name\": \"Site Paris\"\n }\n }\n ]\n }\n }\n}","latency":0,"statusCode":200,"label":"entityLookup filter-site-name","headers":[{"key":"access-control-allow-headers","value":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"},{"key":"access-control-allow-methods","value":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},{"key":"access-control-allow-origin","value":"*"},{"key":"content-security-policy","value":"default-src 'none'"},{"key":"content-type","value":"text/html; charset=utf-8"},{"key":"x-content-type-options","value":"nosniff"}],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[{"target":"body","modifier":"","value":"entityLookup","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"search","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"Site Paris","invert":false,"operator":"regex"}],"rulesOperator":"AND","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]},{"uuid":"ce84abc5-82e1-4b1a-bff8-1effc30f632c","body":"{\n \"data\": {\n \"entityLookup\": {\n \"total\": 1,\n \"items\": [\n {\n \"entity\": {\n \"id\": \"1002\",\n \"name\": \"Site Toulouse\"\n }\n }\n ]\n }\n }\n}","latency":0,"statusCode":200,"label":"entityLookup filter-site-id","headers":[{"key":"access-control-allow-headers","value":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"},{"key":"access-control-allow-methods","value":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},{"key":"access-control-allow-origin","value":"*"},{"key":"content-security-policy","value":"default-src 'none'"},{"key":"content-type","value":"text/html; charset=utf-8"},{"key":"x-content-type-options","value":"nosniff"}],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[{"target":"body","modifier":"","value":"entityLookup","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":" entityIDs","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"1002","invert":false,"operator":"regex"}],"rulesOperator":"AND","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]},{"uuid":"bb2a2298-4f2b-40a4-80bb-0658ef4c3748","body":"{\n \"data\": {\n \"accountSnapshot\": {\n \"sites\": [\n {\n \"id\": \"1001\",\n \"connectivityStatus\": \"Connected\",\n \"operationalStatus\": \"active\",\n \"info\": {\n \"name\": \"Paris Office\",\n \"description\": \"HQ in Paris - main datacenter\"\n },\n \"operationalStats\": {\n \"latencyMs\": 15,\n \"packetLossPercent\": 0\n },\n \"lastConnected\": \"2025-09-04T08:15:00Z\",\n \"popName\": \"POP-Paris\",\n \"connectedSince\": \"2025-09-03T20:00:00Z\"\n },\n {\n \"id\": \"1002\",\n \"connectivityStatus\": \"Disconnected\",\n \"operationalStatus\": \"disabled\",\n \"info\": {\n \"name\": \"Toulouse Office\",\n \"description\": \"Sud !\"\n },\n \"operationalStats\": {\n \"latencyMs\": null,\n \"packetLossPercent\": null\n },\n \"lastConnected\": \"2025-09-03T23:45:00Z\",\n \"popName\": \"POP-Toulouse\",\n \"connectedSince\": null\n },\n {\n \"id\": \"1003\",\n \"connectivityStatus\": \"Degraded\",\n \"operationalStatus\": \"locked\",\n \"info\": {\n \"name\": \"Saint Girons Office\",\n \"description\": \"Ariege\"\n },\n \"operationalStats\": {\n \"latencyMs\": 120,\n \"packetLossPercent\": 5\n },\n \"lastConnected\": \"2025-09-04T07:50:00Z\",\n \"popName\": \"POP-Ariege\",\n \"connectedSince\": \"2025-09-04T01:30:00Z\"\n }\n ]\n }\n }\n}","latency":0,"statusCode":200,"label":"accountSnapshot","headers":[],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[{"target":"body","modifier":"","value":"accountSnapshot","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"1001","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"1002","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"1003","invert":false,"operator":"regex"}],"rulesOperator":"AND","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]},{"uuid":"aa9fa4c7-92c8-4bd0-97fa-afcaad94e165","body":"{ \"errors\": \"unsupported\" }","latency":0,"statusCode":200,"label":"Unknown","headers":[],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[],"rulesOperator":"OR","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]},{"uuid":"7d3247b7-a54c-418e-be97-9cd0a0f9e259","body":"{\n \"data\": {\n \"accountMetrics\": {\n \"from\": \"2024-02-06T15:00:00Z\",\n \"to\": \"2024-02-06T15:10:00Z\",\n \"sites\": [\n {\n \"id\": \"1001\",\n \"interfaces\": [\n {\n \"name\": \"all\",\n \"timeseries\": [\n {\n \"label\": \"bytesUpstreamMax\",\n \"units\": \"bytes\",\n \"data\": [\n [1707231600000, 6008],\n [1707231660000, 12802],\n [1707231720000, 6557],\n [1707231780000, 3467],\n [1707231840000, 7168],\n [1707231900000, 3660],\n [1707231960000, 6791],\n [1707232020000, 5839],\n [1707232080000, 4183],\n [1707232140000, 4684]\n ]\n },\n {\n \"label\": \"bytesDownstreamMax\",\n \"units\": \"bytes\",\n \"data\": [\n [1707231600000, 12008],\n [1707231660000, 14802],\n [1707231720000, 9557],\n [1707231780000, 7467],\n [1707231840000, 11168],\n [1707231900000, 9660],\n [1707231960000, 11791],\n [1707232020000, 8839],\n [1707232080000, 7183],\n [1707232140000, 8684]\n ]\n },\n {\n \"label\": \"lostUpstreamPcnt\",\n \"units\": \"percent\",\n \"data\": [\n [1707231600000, 0.1],\n [1707231660000, 0.3],\n [1707231720000, 0.0],\n [1707231780000, 0.2],\n [1707231840000, 0.1],\n [1707231900000, 0.5],\n [1707231960000, 0.0],\n [1707232020000, 0.2],\n [1707232080000, 0.1],\n [1707232140000, 0.0]\n ]\n },\n {\n \"label\": \"lostDownstreamPcnt\",\n \"units\": \"percent\",\n \"data\": [\n [1707231600000, 0.0],\n [1707231660000, 0.2],\n [1707231720000, 0.1],\n [1707231780000, 0.0],\n [1707231840000, 0.3],\n [1707231900000, 0.1],\n [1707231960000, 0.0],\n [1707232020000, 0.2],\n [1707232080000, 0.0],\n [1707232140000, 0.1]\n ]\n },\n {\n \"label\": \"packetsDiscardedDownstream\",\n \"units\": \"packets\",\n \"data\": [\n [1707231600000, 2],\n [1707231660000, 4],\n [1707231720000, 1],\n [1707231780000, 3],\n [1707231840000, 2],\n [1707231900000, 5],\n [1707231960000, 1],\n [1707232020000, 3],\n [1707232080000, 2],\n [1707232140000, 1]\n ]\n },\n {\n \"label\": \"packetsDiscardedUpstream\",\n \"units\": \"packets\",\n \"data\": [\n [1707231600000, 1],\n [1707231660000, 3],\n [1707231720000, 0],\n [1707231780000, 2],\n [1707231840000, 1],\n [1707231900000, 4],\n [1707231960000, 0],\n [1707232020000, 2],\n [1707232080000, 1],\n [1707232140000, 0]\n ]\n },\n {\n \"label\": \"jitterUpstream\",\n \"units\": \"ms\",\n \"data\": [\n [1707231600000, 5],\n [1707231660000, 7],\n [1707231720000, 6],\n [1707231780000, 8],\n [1707231840000, 5],\n [1707231900000, 9],\n [1707231960000, 7],\n [1707232020000, 6],\n [1707232080000, 5],\n [1707232140000, 7]\n ]\n },\n {\n \"label\": \"jitterDownstream\",\n \"units\": \"ms\",\n \"data\": [\n [1707231600000, 6],\n [1707231660000, 8],\n [1707231720000, 7],\n [1707231780000, 9],\n [1707231840000, 6],\n [1707231900000, 10],\n [1707231960000, 8],\n [1707232020000, 7],\n [1707232080000, 6],\n [1707232140000, 8]\n ]\n },\n {\n \"label\": \"lastMilePacketLoss\",\n \"units\": \"percent\",\n \"data\": [\n [1707231600000, 0.1],\n [1707231660000, 0.4],\n [1707231720000, 0.0],\n [1707231780000, 0.3],\n [1707231840000, 0.2],\n [1707231900000, 0.5],\n [1707231960000, 0.1],\n [1707232020000, 0.2],\n [1707232080000, 0.0],\n [1707232140000, 0.1]\n ]\n },\n {\n \"label\": \"lastMileLatency\",\n \"units\": \"ms\",\n \"data\": [\n [1707231600000, 25],\n [1707231660000, 27],\n [1707231720000, 24],\n [1707231780000, 26],\n [1707231840000, 28],\n [1707231900000, 30],\n [1707231960000, 27],\n [1707232020000, 25],\n [1707232080000, 26],\n [1707232140000, 24]\n ]\n }\n ]\n }\n ]\n }\n ]\n }\n }\n}\n","latency":0,"statusCode":200,"label":"accountMetrics","headers":[],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[{"target":"body","modifier":"","value":"accountMetrics","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"lastMile","invert":false,"operator":"regex"}],"rulesOperator":"AND","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]},{"uuid":"ef2eb7d1-2abd-4a64-adec-9876b87bbaab","body":"{\n \"data\": {\n \"accountSnapshot\": {\n \"sites\": [\n {\n \"id\": \"1001\",\n \"connectivityStatus\": \"Connected\",\n \"operationalStatus\": \"active\",\n \"info\": {\n \"name\": \"Paris Office\",\n \"description\": \"HQ in Paris - main datacenter\"\n },\n \"operationalStats\": {\n \"latencyMs\": 15,\n \"packetLossPercent\": 0\n },\n \"lastConnected\": \"2025-09-04T08:15:00Z\",\n \"popName\": \"POP-Paris\",\n \"connectedSince\": \"2025-09-03T20:00:00Z\"\n }\n ]\n }\n }\n}","latency":0,"statusCode":200,"label":"accountSnapshot site","headers":[],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[{"target":"body","modifier":"","value":"accountSnapshot","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"siteIDs","invert":false,"operator":"regex"}],"rulesOperator":"AND","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]},{"uuid":"5a00f703-b7d8-4823-b199-4e2caf5966c6","body":"{\n \"data\": {\n \"accountMetrics\": {\n \"from\": \"2024-02-06T15:00:00Z\",\n \"to\": \"2024-02-06T15:10:00Z\",\n \"sites\": [\n {\n \"id\": \"1001\",\n \"interfaces\": [\n {\n \"name\": \"all\",\n \"timeseries\": [\n {\n \"label\": \"jitterUpstream\",\n \"units\": \"ms\",\n \"data\": [\n [1707231600000, 5],\n [1707231660000, 7],\n [1707231720000, 6],\n [1707231780000, 8],\n [1707231840000, 5],\n [1707231900000, 9],\n [1707231960000, 7],\n [1707232020000, 6],\n [1707232080000, 5],\n [1707232140000, 7]\n ]\n },\n {\n \"label\": \"jitterDownstream\",\n \"units\": \"ms\",\n \"data\": [\n [1707231600000, 6],\n [1707231660000, 8],\n [1707231720000, 7],\n [1707231780000, 9],\n [1707231840000, 6],\n [1707231900000, 10],\n [1707231960000, 8],\n [1707232020000, 7],\n [1707232080000, 6],\n [1707232140000, 8]\n ]\n }\n ]\n }\n ]\n }\n ]\n }\n }\n}\n","latency":0,"statusCode":200,"label":"accountsMetrics jitterUpstream","headers":[],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[{"target":"body","modifier":"","value":"accountMetrics","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"jitter","invert":false,"operator":"regex"}],"rulesOperator":"AND","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]},{"uuid":"015ace5b-e696-40dd-bc8e-d97d844eaf38","body":"{\n \"data\": {\n \"eventsFeed\": {\n \"marker\": \"eyJmZXRjaGVkQ291bnQiOjIwfQ==\",\n \"fetchedCount\": 2,\n \"accounts\": [\n {\n \"id\": 111,\n \"errorString\": null,\n \"records\": [\n {\n \"time\": \"2025-09-17T09:10:12Z\",\n \"fieldsMap\": {\n \"event_id\": \"123456\",\n \"time_str\": \"2025-09-17 09:10:12\",\n \"event_type\": \"Sockets Management\",\n \"event_sub_type\": \"Socket Firmware Upgrade\",\n \"severity\": \"INFO\",\n \"title\": \"Socket upgraded successfully\",\n \"event_message\": \"Socket upgraded from version 4.0.3 to 4.0.4\",\n \"pop_name\": \"Paris POP\",\n \"src_site_name\": \"Headquarters\",\n \"dest_site_name\": \"Branch-1\",\n \"status\": \"Open\"\n }\n },\n {\n \"time\": \"2025-09-17T09:25:44Z\",\n \"fieldsMap\": {\n \"event_id\": \"987654\",\n \"time_str\": \"2025-09-17 09:25:44\",\n \"event_type\": \"Security\",\n \"event_sub_type\": \"Threat Prevention\",\n \"severity\": \"HIGH\",\n \"title\": \"Malware blocked\",\n \"event_message\": \"Suspicious file transfer blocked by IPS\",\n \"pop_name\": \"Frankfurt POP\",\n \"src_site_name\": \"Branch-2\",\n \"dest_site_name\": \"Internet\",\n \"status\": \"Closed\"\n }\n },\n {\n \"time\": \"2025-09-18T09:25:44Z\",\n \"fieldsMap\": {\n \"event_id\": \"987659\",\n \"time_str\": \"2025-09-18 09:25:44\",\n \"event_type\": \"Security\",\n \"event_sub_type\": \"Threat Prevention\",\n \"severity\": \"HIGH\",\n \"title\": \"Malware blocked\",\n \"event_message\": \"Suspicious file transfer blocked by IPS\",\n \"pop_name\": \"Frankfurt POP\",\n \"src_site_name\": \"Branch-2\",\n \"dest_site_name\": \"Internet\",\n \"status\": \"Reopened\"\n }\n } \n ]\n }\n ]\n }\n }\n}","latency":0,"statusCode":200,"label":"eventsFeed","headers":[],"bodyType":"INLINE","filePath":"","databucketID":"","sendFileAsBody":false,"rules":[{"target":"body","modifier":"","value":"eventsFeed","invert":false,"operator":"regex"},{"target":"body","modifier":"","value":"entityLookup","invert":true,"operator":"regex"},{"target":"body","modifier":"","value":"accountSnapshot","invert":true,"operator":"regex"},{"target":"body","modifier":"","value":"accountMetrics","invert":true,"operator":"regex"}],"rulesOperator":"AND","disableTemplating":false,"fallbackTo404":false,"default":false,"crudKey":"id","callbacks":[]}],"responseMode":null,"streamingMode":null,"streamingInterval":0}],"rootChildren":[{"type":"route","uuid":"624ec454-44af-4de2-8f0c-2a19805b9286"}],"proxyMode":false,"proxyHost":"","proxyRemovePrefix":false,"tlsOptions":{"enabled":false,"type":"CERT","pfxPath":"","certPath":"","keyPath":"","caPath":"","passphrase":""},"cors":true,"headers":[{"key":"Content-Type","value":"application/json"},{"key":"Access-Control-Allow-Origin","value":"*"},{"key":"Access-Control-Allow-Methods","value":"GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS"},{"key":"Access-Control-Allow-Headers","value":"Content-Type, Origin, Accept, Authorization, Content-Length, X-Requested-With"}],"proxyReqHeaders":[{"key":"","value":""}],"proxyResHeaders":[{"key":"","value":""}],"data":[],"callbacks":[]}
src/go/plugin/go.d/collector/cato_networks/testdata/config.json
new
+27
@@ -0,0 +1,27 @@
1
+{
2
+ "vnode": "cato-account",
3
+ "update_every": 60,
4
+ "autodetection_retry": 5,
5
+ "account_id": "12345",
6
+ "api_key": "secret",
7
+ "url": "https://api.catonetworks.com/api/v1/graphql2",
8
+ "username": "netdata",
9
+ "password": "secret",
10
+ "bearer_token_file": "/tmp/cato-token",
11
+ "proxy_username": "proxy-user",
12
+ "proxy_password": "proxy-secret",
13
+ "method": "POST",
14
+ "headers": {
15
+ "X-Test": "test"
16
+ },
17
+ "body": "{\"query\":\"query\"}",
18
+ "timeout": 30,
19
+ "not_follow_redirects": true,
20
+ "proxy_url": "http://proxy.local:8080",
21
+ "tls_ca": "/tmp/ca.pem",
22
+ "tls_cert": "/tmp/cert.pem",
23
+ "tls_key": "/tmp/key.pem",
24
+ "tls_skip_verify": true,
25
+ "force_http2": true,
26
+ "site_selector": "!lab-* *"
27
+}
src/go/plugin/go.d/collector/cato_networks/testdata/config.yaml
new
+24
@@ -0,0 +1,24 @@
1
+vnode: cato-account
2
+update_every: 60
3
+autodetection_retry: 5
4
+account_id: "12345"
5
+api_key: secret
6
+url: https://api.catonetworks.com/api/v1/graphql2
7
+username: netdata
8
+password: secret
9
+bearer_token_file: /tmp/cato-token
10
+proxy_username: proxy-user
11
+proxy_password: proxy-secret
12
+method: POST
13
+headers:
14
+ X-Test: test
15
+body: '{"query":"query"}'
16
+timeout: 30
17
+not_follow_redirects: true
18
+proxy_url: http://proxy.local:8080
19
+tls_ca: /tmp/ca.pem
20
+tls_cert: /tmp/cert.pem
21
+tls_key: /tmp/key.pem
22
+tls_skip_verify: true
23
+force_http2: true
24
+site_selector: "!lab-* *"
src/go/plugin/go.d/collector/cato_networks/topology.go
new
+559
@@ -0,0 +1,559 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "fmt"
7
+ "sort"
8
+ "time"
9
+
10
+ topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
11
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/cato_networks/catofunc"
12
+)
13
+
14
+const (
15
+ topologySource = "cato_networks"
16
+ topologyLayer = "network"
17
+)
18
+
19
+func buildTopology(accountID string, sites map[string]*siteState, order []string, collectedAt time.Time) (*topologyv1.Data, error) {
20
+ stringsDict := topologyv1.NewStringDictionary()
21
+ actors := newTopologyTableBuilder(catoTopologyActorColumns()...)
22
+ links := newTopologyTableBuilder(catoTopologyLinkColumns()...)
23
+ interfaces := newTopologyTableBuilder(catoTopologyInterfaceColumns()...)
24
+
25
+ actorIndexes := make(map[string]int, len(order)*2)
26
+ popSeen := make(map[string]bool)
27
+
28
+ for _, siteID := range order {
29
+ site := sites[siteID]
30
+ if site == nil {
31
+ continue
32
+ }
33
+
34
+ siteActorID := catoSiteActorID(site.ID)
35
+ siteActorIndex := addSiteActor(actors, stringsDict, accountID, site, siteActorID)
36
+ actorIndexes[siteActorID] = siteActorIndex
37
+ deviceActors := addDeviceTopology(actors, links, stringsDict, actorIndexes, popSeen, accountID, site)
38
+ addInterfaceTopologyTable(interfaces, siteActorIndex, deviceActors, site)
39
+
40
+ if len(deviceActors) == 0 && site.PopName != "" {
41
+ popActorID := catoPopActorID(site.PopName)
42
+ if !popSeen[site.PopName] {
43
+ popSeen[site.PopName] = true
44
+ actorIndexes[popActorID] = addPopActor(actors, stringsDict, accountID, site.PopName, popActorID)
45
+ }
46
+ addSitePopLink(links, stringsDict, site, siteActorIndex, actorIndexes[popActorID])
47
+ }
48
+
49
+ addBGPPeerTopology(actors, links, stringsDict, actorIndexes, site, siteActorIndex)
50
+ }
51
+
52
+ actorTable, err := actors.table()
53
+ if err != nil {
54
+ return nil, fmt.Errorf("actors table: %w", err)
55
+ }
56
+ linkTable, err := links.table()
57
+ if err != nil {
58
+ return nil, fmt.Errorf("links table: %w", err)
59
+ }
60
+ interfaceTable, err := interfaces.table()
61
+ if err != nil {
62
+ return nil, fmt.Errorf("interfaces table: %w", err)
63
+ }
64
+
65
+ data := &topologyv1.Data{
66
+ SchemaVersion: topologyv1.SchemaVersion,
67
+ Producer: topologyv1.Producer{
68
+ Source: topologySource,
69
+ Instance: accountID,
70
+ Plugin: "go.d/cato_networks",
71
+ Capabilities: []string{"sites", "devices", "interfaces", "bgp"},
72
+ },
73
+ CollectedAt: collectedAt,
74
+ View: &topologyv1.View{
75
+ ID: "summary",
76
+ Scope: "network",
77
+ Mode: "detailed",
78
+ },
79
+ Dictionaries: topologyv1.Dictionaries{
80
+ "strings": stringsDict.Values(),
81
+ },
82
+ Types: catoTopologyTypes(),
83
+ Presentation: catofunc.TopologyPresentation(),
84
+ Actors: actorTable,
85
+ Links: linkTable,
86
+ Tables: &topologyv1.DetailTables{
87
+ Actor: map[string]topologyv1.DetailTable{
88
+ catofunc.ActorTableInterfaces: {
89
+ Type: catofunc.ActorTableInterfaces,
90
+ Table: interfaceTable,
91
+ },
92
+ },
93
+ },
94
+ Stats: map[string]any{
95
+ "sites": len(order),
96
+ "links": links.rows(),
97
+ },
98
+ }
99
+ return data, nil
100
+}
101
+
102
+func addSiteActor(table *topologyTableBuilder, dict *topologyv1.StringDictionary, accountID string, site *siteState, siteActorID string) int {
103
+ return table.add(
104
+ dict.Ref(catofunc.ActorTypeSite),
105
+ dict.Ref(topologyLayer),
106
+ siteActorID,
107
+ site.Name,
108
+ accountID,
109
+ site.ID,
110
+ "",
111
+ site.PopName,
112
+ "",
113
+ "",
114
+ site.ConnectivityStatus,
115
+ site.OperationalStatus,
116
+ site.SiteType,
117
+ site.ConnectionType,
118
+ site.CountryCode,
119
+ site.CountryName,
120
+ site.Region,
121
+ site.HostCount,
122
+ nil,
123
+ "",
124
+ "",
125
+ "",
126
+ nil,
127
+ )
128
+}
129
+
130
+func addPopActor(table *topologyTableBuilder, dict *topologyv1.StringDictionary, accountID, popName, popActorID string) int {
131
+ return table.add(
132
+ dict.Ref(catofunc.ActorTypePop),
133
+ dict.Ref(topologyLayer),
134
+ popActorID,
135
+ popName,
136
+ accountID,
137
+ "",
138
+ "",
139
+ popName,
140
+ "",
141
+ "",
142
+ "",
143
+ "",
144
+ "",
145
+ "",
146
+ "",
147
+ "",
148
+ "",
149
+ int64(0),
150
+ nil,
151
+ "",
152
+ "",
153
+ "",
154
+ nil,
155
+ )
156
+}
157
+
158
+func addSitePopLink(table *topologyTableBuilder, dict *topologyv1.StringDictionary, site *siteState, siteActor, popActor int) {
159
+ table.add(
160
+ dict.Ref(catofunc.LinkTypeTunnel),
161
+ siteActor,
162
+ popActor,
163
+ dict.Ref("cato"),
164
+ dict.Ref(site.ConnectivityStatus),
165
+ dict.Ref("bidirectional"),
166
+ 1,
167
+ trafficMetricValue(site.Metrics, trafficMetricBytesUpstreamMax, site.Metrics.BytesUpstreamMax),
168
+ trafficMetricValue(site.Metrics, trafficMetricBytesDownstreamMax, site.Metrics.BytesDownstreamMax),
169
+ trafficMetricValue(site.Metrics, trafficMetricLostUpstreamPercent, site.Metrics.LostUpstreamPercent),
170
+ trafficMetricValue(site.Metrics, trafficMetricRTTMS, site.Metrics.RTTMS),
171
+ nil,
172
+ nil,
173
+ nil,
174
+ nil,
175
+ nil,
176
+ nil,
177
+ )
178
+}
179
+
180
+func addDeviceTopology(actors, links *topologyTableBuilder, dict *topologyv1.StringDictionary, actorIndexes map[string]int, popSeen map[string]bool, accountID string, site *siteState) map[string]int {
181
+ deviceActors := make(map[string]int, len(site.Devices))
182
+
183
+ sortedDevices := append([]deviceState(nil), site.Devices...)
184
+ sort.Slice(sortedDevices, func(i, j int) bool {
185
+ leftName := deviceDisplayName(sortedDevices[i])
186
+ rightName := deviceDisplayName(sortedDevices[j])
187
+ if leftName != rightName {
188
+ return leftName < rightName
189
+ }
190
+ return stableDeviceID(sortedDevices[i]) < stableDeviceID(sortedDevices[j])
191
+ })
192
+
193
+ for _, dev := range sortedDevices {
194
+ deviceID := stableDeviceID(dev)
195
+ if deviceID == "" {
196
+ continue
197
+ }
198
+ deviceActorID := catoDeviceActorID(site.ID, deviceID)
199
+ deviceActor := addDeviceActor(actors, dict, accountID, site, dev, deviceID, deviceActorID)
200
+ actorIndexes[deviceActorID] = deviceActor
201
+ deviceActors[deviceID] = deviceActor
202
+
203
+ popName := dev.LastPopName
204
+ if popName == "" {
205
+ popName = site.PopName
206
+ }
207
+ if popName == "" {
208
+ continue
209
+ }
210
+ popActorID := catoPopActorID(popName)
211
+ if !popSeen[popName] {
212
+ popSeen[popName] = true
213
+ actorIndexes[popActorID] = addPopActor(actors, dict, accountID, popName, popActorID)
214
+ }
215
+ addDevicePopLink(links, dict, dev, deviceActor, actorIndexes[popActorID])
216
+ }
217
+
218
+ return deviceActors
219
+}
220
+
221
+func addDeviceActor(table *topologyTableBuilder, dict *topologyv1.StringDictionary, accountID string, site *siteState, dev deviceState, deviceID, deviceActorID string) int {
222
+ displayName := deviceDisplayName(dev)
223
+ if displayName == "" {
224
+ displayName = deviceID
225
+ }
226
+ popName := dev.LastPopName
227
+ if popName == "" {
228
+ popName = site.PopName
229
+ }
230
+ return table.add(
231
+ dict.Ref(catofunc.ActorTypeDevice),
232
+ dict.Ref(topologyLayer),
233
+ deviceActorID,
234
+ displayName,
235
+ accountID,
236
+ site.ID,
237
+ deviceID,
238
+ popName,
239
+ "",
240
+ "",
241
+ boolState(dev.Connected, "connected", "disconnected"),
242
+ "",
243
+ "",
244
+ "",
245
+ "",
246
+ "",
247
+ "",
248
+ int64(0),
249
+ dev.Connected,
250
+ dev.HaRole,
251
+ dev.SocketSerial,
252
+ dev.SocketVersion,
253
+ dev.InternalIP,
254
+ )
255
+}
256
+
257
+func addDevicePopLink(table *topologyTableBuilder, dict *topologyv1.StringDictionary, dev deviceState, deviceActor, popActor int) {
258
+ table.add(
259
+ dict.Ref(catofunc.LinkTypeTunnel),
260
+ deviceActor,
261
+ popActor,
262
+ dict.Ref("cato"),
263
+ dict.Ref(boolState(dev.Connected, "connected", "disconnected")),
264
+ dict.Ref("bidirectional"),
265
+ 1,
266
+ nil,
267
+ nil,
268
+ nil,
269
+ nil,
270
+ nil,
271
+ nil,
272
+ nil,
273
+ nil,
274
+ nil,
275
+ nil,
276
+ )
277
+}
278
+
279
+func addBGPPeerTopology(actors, links *topologyTableBuilder, dict *topologyv1.StringDictionary, actorIndexes map[string]int, site *siteState, siteActor int) {
280
+ seen := make(map[string]bool)
281
+
282
+ for _, peer := range site.BGPPeers {
283
+ if peer.RemoteIP == "" && peer.RemoteASN == "" {
284
+ continue
285
+ }
286
+ peerActorID := catoBGPPeerActorID(site.ID, peer.RemoteIP, peer.RemoteASN)
287
+ if seen[peerActorID] {
288
+ continue
289
+ }
290
+ seen[peerActorID] = true
291
+ peerActor := addBGPPeerActor(actors, dict, site, peer, peerActorID)
292
+ actorIndexes[peerActorID] = peerActor
293
+ addBGPLink(links, dict, siteActor, peerActor, peer)
294
+ }
295
+}
296
+
297
+func addBGPPeerActor(table *topologyTableBuilder, dict *topologyv1.StringDictionary, site *siteState, peer bgpPeerState, peerActorID string) int {
298
+ displayName := peer.RemoteIP
299
+ if displayName == "" {
300
+ displayName = peer.RemoteASN
301
+ }
302
+ return table.add(
303
+ dict.Ref(catofunc.ActorTypeBGPPeer),
304
+ dict.Ref(topologyLayer),
305
+ peerActorID,
306
+ displayName,
307
+ "",
308
+ site.ID,
309
+ "",
310
+ site.PopName,
311
+ peer.RemoteIP,
312
+ peer.RemoteASN,
313
+ peer.BGPSession,
314
+ "",
315
+ "",
316
+ "",
317
+ "",
318
+ "",
319
+ "",
320
+ int64(0),
321
+ nil,
322
+ "",
323
+ "",
324
+ "",
325
+ nil,
326
+ )
327
+}
328
+
329
+func addBGPLink(table *topologyTableBuilder, dict *topologyv1.StringDictionary, siteActor, peerActor int, peer bgpPeerState) {
330
+ table.add(
331
+ dict.Ref(catofunc.LinkTypeBGP),
332
+ siteActor,
333
+ peerActor,
334
+ dict.Ref("bgp"),
335
+ dict.Ref(peer.BGPSession),
336
+ dict.Ref("bidirectional"),
337
+ 1,
338
+ nil,
339
+ nil,
340
+ nil,
341
+ nil,
342
+ peer.RoutesCount,
343
+ peer.RoutesCountLimit,
344
+ peer.RoutesCountLimitExceeded,
345
+ peer.RIBOutRoutes,
346
+ peer.IncomingState,
347
+ peer.OutgoingState,
348
+ )
349
+}
350
+
351
+func addInterfaceTopologyTable(interfaces *topologyTableBuilder, siteActor int, deviceActors map[string]int, site *siteState) {
352
+ ifaceKeys := make([]string, 0, len(site.Interfaces))
353
+ for key := range site.Interfaces {
354
+ ifaceKeys = append(ifaceKeys, key)
355
+ }
356
+ sort.Slice(ifaceKeys, func(i, j int) bool {
357
+ left := site.Interfaces[ifaceKeys[i]]
358
+ right := site.Interfaces[ifaceKeys[j]]
359
+ var leftName, rightName string
360
+ if left != nil {
361
+ leftName = left.Name
362
+ }
363
+ if right != nil {
364
+ rightName = right.Name
365
+ }
366
+ if leftName != rightName {
367
+ return leftName < rightName
368
+ }
369
+ return ifaceKeys[i] < ifaceKeys[j]
370
+ })
371
+ for _, key := range ifaceKeys {
372
+ iface := site.Interfaces[key]
373
+ if iface == nil {
374
+ continue
375
+ }
376
+ actor := siteActor
377
+ if iface.DeviceID != "" {
378
+ if devActor, ok := deviceActors[iface.DeviceID]; ok {
379
+ actor = devActor
380
+ }
381
+ }
382
+ interfaces.add(
383
+ actor,
384
+ iface.ID,
385
+ iface.Name,
386
+ iface.Type,
387
+ iface.Connected || iface.LinkUp,
388
+ iface.PopName,
389
+ iface.TunnelRemoteIP,
390
+ iface.TunnelUptime,
391
+ iface.UpstreamBandwidth,
392
+ iface.DownstreamBandwidth,
393
+ )
394
+ }
395
+}
396
+
397
+func trafficMetricValue(metrics trafficMetrics, metric trafficMetricPresence, value float64) any {
398
+ if !metrics.has(metric) {
399
+ return nil
400
+ }
401
+ return value
402
+}
403
+
404
+func catoSiteActorID(siteID string) string {
405
+ return "cato:site:" + siteID
406
+}
407
+
408
+func catoPopActorID(popName string) string {
409
+ return "cato:pop:" + popName
410
+}
411
+
412
+func catoDeviceActorID(siteID, deviceID string) string {
413
+ return fmt.Sprintf("cato:device:%s:%s", siteID, deviceID)
414
+}
415
+
416
+func catoBGPPeerActorID(siteID, remoteIP, remoteASN string) string {
417
+ return fmt.Sprintf("cato:bgp:%s:%s:%s", siteID, remoteIP, remoteASN)
418
+}
419
+
420
+func catoTopologyTypes() topologyv1.TypeRegistry {
421
+ return topologyv1.TypeRegistry{
422
+ ActorTypes: catofunc.TopologyActorTypes(),
423
+ LinkTypes: catofunc.TopologyLinkTypes(),
424
+ TableTypes: map[string]topologyv1.TableType{
425
+ catofunc.ActorTableInterfaces: {
426
+ Role: "actor_detail",
427
+ Owner: "actor",
428
+ Aggregation: "append",
429
+ Columns: catoTopologyInterfaceColumns(),
430
+ },
431
+ },
432
+ AggregationScopes: map[string]topologyv1.AggregationScope{
433
+ "site": {
434
+ Columns: []string{"site_id"},
435
+ EvidencePolicy: "preserve",
436
+ },
437
+ "pop": {
438
+ Columns: []string{"pop_name"},
439
+ EvidencePolicy: "preserve",
440
+ },
441
+ "network": {
442
+ Columns: []string{"type"},
443
+ EvidencePolicy: "preserve",
444
+ },
445
+ },
446
+ }
447
+}
448
+
449
+func catoTopologyActorColumns() []topologyv1.Column {
450
+ return []topologyv1.Column{
451
+ topologyv1.NewColumn("type", "string_ref", topologyv1.WithDictionary("strings")),
452
+ topologyv1.NewColumn("layer", "string_ref", topologyv1.WithDictionary("strings")),
453
+ topologyv1.NewColumn("id", "string", topologyv1.WithRole("identity")),
454
+ topologyv1.NewColumn("display_name", "string"),
455
+ topologyv1.NewColumn("account_id", "string", topologyv1.WithRole("merge_identity")),
456
+ topologyv1.NewColumn("site_id", "string", topologyv1.WithRole("merge_identity")),
457
+ topologyv1.NewColumn("device_id", "string", topologyv1.WithRole("merge_identity")),
458
+ topologyv1.NewColumn("pop_name", "string", topologyv1.WithRole("merge_identity")),
459
+ topologyv1.NewColumn("remote_ip", "ip", topologyv1.WithRole("merge_identity")),
460
+ topologyv1.NewColumn("remote_asn", "string", topologyv1.WithRole("merge_identity")),
461
+ topologyv1.NewColumn("connectivity_status", "string"),
462
+ topologyv1.NewColumn("operational_status", "string"),
463
+ topologyv1.NewColumn("site_type", "string"),
464
+ topologyv1.NewColumn("connection_type", "string"),
465
+ topologyv1.NewColumn("country_code", "string"),
466
+ topologyv1.NewColumn("country_name", "string"),
467
+ topologyv1.NewColumn("region", "string"),
468
+ topologyv1.NewColumn("host_count", "uint", topologyv1.WithAggregation("sum")),
469
+ topologyv1.NewColumn("connected", "bool", topologyv1.WithNullable()),
470
+ topologyv1.NewColumn("ha_role", "string"),
471
+ topologyv1.NewColumn("socket_serial", "string"),
472
+ topologyv1.NewColumn("socket_version", "string"),
473
+ topologyv1.NewColumn("internal_ip", "ip", topologyv1.WithNullable()),
474
+ }
475
+}
476
+
477
+func catoTopologyLinkColumns() []topologyv1.Column {
478
+ return []topologyv1.Column{
479
+ topologyv1.NewColumn("type", "string_ref", topologyv1.WithDictionary("strings")),
480
+ topologyv1.NewColumn("src_actor", "actor_ref"),
481
+ topologyv1.NewColumn("dst_actor", "actor_ref"),
482
+ topologyv1.NewColumn("protocol", "string_ref", topologyv1.WithDictionary("strings")),
483
+ topologyv1.NewColumn("state", "string_ref", topologyv1.WithDictionary("strings")),
484
+ topologyv1.NewColumn("direction", "string_ref", topologyv1.WithDictionary("strings")),
485
+ topologyv1.NewColumn("evidence_count", "uint", topologyv1.WithAggregation("sum")),
486
+ topologyv1.NewColumn("bytes_upstream_max", "float", topologyv1.WithNullable(), topologyv1.WithRole("metric"), topologyv1.WithAggregation("max")),
487
+ topologyv1.NewColumn("bytes_downstream_max", "float", topologyv1.WithNullable(), topologyv1.WithRole("metric"), topologyv1.WithAggregation("max")),
488
+ topologyv1.NewColumn("lost_upstream_percent", "float", topologyv1.WithNullable(), topologyv1.WithRole("metric"), topologyv1.WithAggregation("avg")),
489
+ topologyv1.NewColumn("rtt_ms", "float", topologyv1.WithNullable(), topologyv1.WithRole("metric"), topologyv1.WithUnit("ms"), topologyv1.WithAggregation("avg")),
490
+ topologyv1.NewColumn("routes", "int", topologyv1.WithNullable(), topologyv1.WithRole("metric"), topologyv1.WithAggregation("sum")),
491
+ topologyv1.NewColumn("routes_limit", "int", topologyv1.WithNullable(), topologyv1.WithRole("metric"), topologyv1.WithAggregation("max")),
492
+ topologyv1.NewColumn("routes_limit_exceeded", "bool", topologyv1.WithNullable()),
493
+ topologyv1.NewColumn("rib_out_routes", "int", topologyv1.WithNullable(), topologyv1.WithRole("metric"), topologyv1.WithAggregation("sum")),
494
+ topologyv1.NewColumn("incoming_connection_state", "string", topologyv1.WithNullable()),
495
+ topologyv1.NewColumn("outgoing_connection_state", "string", topologyv1.WithNullable()),
496
+ }
497
+}
498
+
499
+func catoTopologyInterfaceColumns() []topologyv1.Column {
500
+ return []topologyv1.Column{
501
+ topologyv1.NewColumn("actor", "actor_ref"),
502
+ topologyv1.NewColumn("id", "string"),
503
+ topologyv1.NewColumn("name", "string"),
504
+ topologyv1.NewColumn("type", "string"),
505
+ topologyv1.NewColumn("connected", "bool"),
506
+ topologyv1.NewColumn("pop_name", "string"),
507
+ topologyv1.NewColumn("tunnel_remote_ip", "ip"),
508
+ topologyv1.NewColumn("tunnel_uptime", "duration"),
509
+ topologyv1.NewColumn("upstream_bandwidth", "int"),
510
+ topologyv1.NewColumn("downstream_bandwidth", "int"),
511
+ }
512
+}
513
+
514
+type topologyTableBuilder struct {
515
+ columns []topologyv1.Column
516
+ values [][]any
517
+ rowsErr error
518
+}
519
+
520
+func newTopologyTableBuilder(columns ...topologyv1.Column) *topologyTableBuilder {
521
+ return &topologyTableBuilder{
522
+ columns: append([]topologyv1.Column(nil), columns...),
523
+ values: make([][]any, len(columns)),
524
+ }
525
+}
526
+
527
+func (b *topologyTableBuilder) add(values ...any) int {
528
+ row := b.rows()
529
+ if len(values) != len(b.columns) {
530
+ b.rowsErr = fmt.Errorf("row has %d values for %d columns", len(values), len(b.columns))
531
+ return row
532
+ }
533
+ for i, value := range values {
534
+ b.values[i] = append(b.values[i], value)
535
+ }
536
+ return row
537
+}
538
+
539
+func (b *topologyTableBuilder) rows() int {
540
+ if len(b.values) == 0 {
541
+ return 0
542
+ }
543
+ return len(b.values[0])
544
+}
545
+
546
+func (b *topologyTableBuilder) table() (topologyv1.Table, error) {
547
+ if b.rowsErr != nil {
548
+ return topologyv1.Table{}, b.rowsErr
549
+ }
550
+ encodings := make([]topologyv1.ColumnEncoding, len(b.values))
551
+ for i, values := range b.values {
552
+ encoding := topologyv1.Values(values...)
553
+ if encoding.Values == nil {
554
+ encoding.Values = []any{}
555
+ }
556
+ encodings[i] = encoding
557
+ }
558
+ return topologyv1.NewTable(b.rows(), b.columns, encodings)
559
+}
src/go/plugin/go.d/collector/cato_networks/topology_store.go
new
+27
@@ -0,0 +1,27 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "sync/atomic"
7
+
8
+ topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
9
+)
10
+
11
+type topologyStore struct {
12
+ data atomic.Pointer[topologyv1.Data]
13
+}
14
+
15
+func (s *topologyStore) Publish(data *topologyv1.Data) {
16
+ // Published topology snapshots are immutable; Function handlers load and
17
+ // marshal the pointer concurrently with later collection cycles.
18
+ s.data.Store(data)
19
+}
20
+
21
+func (s *topologyStore) CurrentTopology() (*topologyv1.Data, bool) {
22
+ data := s.data.Load()
23
+ if data == nil {
24
+ return nil, false
25
+ }
26
+ return data, true
27
+}
src/go/plugin/go.d/collector/cato_networks/topology_store_test.go
new
+115
@@ -0,0 +1,115 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "fmt"
7
+ "sync"
8
+ "sync/atomic"
9
+ "testing"
10
+
11
+ "github.com/stretchr/testify/require"
12
+
13
+ topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
14
+)
15
+
16
+func TestTopologyStorePublishesCurrentSnapshot(t *testing.T) {
17
+ tests := map[string]struct {
18
+ setup func(*topologyStore) *topologyv1.Data
19
+ check func(*testing.T, *topologyStore, *topologyv1.Data)
20
+ }{
21
+ "empty store has no topology": {
22
+ check: func(t *testing.T, store *topologyStore, _ *topologyv1.Data) {
23
+ got, ok := store.CurrentTopology()
24
+ require.Nil(t, got)
25
+ require.False(t, ok)
26
+ },
27
+ },
28
+ "load returns published snapshot": {
29
+ setup: func(store *topologyStore) *topologyv1.Data {
30
+ data := &topologyv1.Data{Producer: topologyv1.Producer{Source: topologySource}}
31
+ store.Publish(data)
32
+ return data
33
+ },
34
+ check: func(t *testing.T, store *topologyStore, data *topologyv1.Data) {
35
+ got, ok := store.CurrentTopology()
36
+ require.True(t, ok)
37
+ require.Same(t, data, got)
38
+ },
39
+ },
40
+ }
41
+
42
+ for name, tc := range tests {
43
+ t.Run(name, func(t *testing.T) {
44
+ var store topologyStore
45
+ var data *topologyv1.Data
46
+ if tc.setup != nil {
47
+ data = tc.setup(&store)
48
+ }
49
+ tc.check(t, &store, data)
50
+ })
51
+ }
52
+}
53
+
54
+func TestTopologyStoreConcurrentPublishLoad(t *testing.T) {
55
+ var store topologyStore
56
+ var wg sync.WaitGroup
57
+ var nilLoads atomic.Int64
58
+
59
+ for i := range 100 {
60
+ wg.Add(1)
61
+ go func(i int) {
62
+ defer wg.Done()
63
+ store.Publish(&topologyv1.Data{Producer: topologyv1.Producer{Source: fmt.Sprintf("source-%d", i)}})
64
+ }(i)
65
+ }
66
+
67
+ for range 100 {
68
+ wg.Go(func() {
69
+ if data, ok := store.CurrentTopology(); ok {
70
+ if data == nil {
71
+ nilLoads.Add(1)
72
+ }
73
+ }
74
+ })
75
+ }
76
+
77
+ wg.Wait()
78
+
79
+ data, ok := store.CurrentTopology()
80
+ require.True(t, ok)
81
+ require.NotEmpty(t, data.Producer.Source)
82
+ require.Zero(t, nilLoads.Load())
83
+}
84
+
85
+func TestFuncDepsAdapterWithoutTopologyStore(t *testing.T) {
86
+ tests := map[string]struct {
87
+ deps funcDepsAdapter
88
+ check func(*testing.T, *topologyv1.Data, bool)
89
+ }{
90
+ "nil store reports unavailable": {
91
+ check: func(t *testing.T, data *topologyv1.Data, ok bool) {
92
+ require.Nil(t, data)
93
+ require.False(t, ok)
94
+ },
95
+ },
96
+ "store delegates current topology": {
97
+ deps: func() funcDepsAdapter {
98
+ var store topologyStore
99
+ store.Publish(&topologyv1.Data{Producer: topologyv1.Producer{Source: topologySource}})
100
+ return funcDepsAdapter{store: &store}
101
+ }(),
102
+ check: func(t *testing.T, data *topologyv1.Data, ok bool) {
103
+ require.True(t, ok)
104
+ require.Equal(t, topologySource, data.Producer.Source)
105
+ },
106
+ },
107
+ }
108
+
109
+ for name, tc := range tests {
110
+ t.Run(name, func(t *testing.T) {
111
+ data, ok := tc.deps.CurrentTopology()
112
+ tc.check(t, data, ok)
113
+ })
114
+ }
115
+}
src/go/plugin/go.d/collector/cato_networks/topology_test.go
new
+294
@@ -0,0 +1,294 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "context"
7
+ "encoding/json"
8
+ "os"
9
+ "path/filepath"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/santhosh-tekuri/jsonschema/v6"
14
+ "github.com/stretchr/testify/require"
15
+
16
+ topologyv1 "github.com/netdata/netdata/go/plugins/pkg/topology/v1"
17
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/cato_networks/catofunc"
18
+)
19
+
20
+func TestTopologyFunction(t *testing.T) {
21
+ tests := map[string]struct {
22
+ setup func(*testing.T, *Collector)
23
+ check func(*testing.T, *Collector)
24
+ }{
25
+ "returns current topology": {
26
+ setup: func(t *testing.T, c *Collector) {
27
+ c.client = newFixtureAPIClient()
28
+ c.now = fixedCatoTestNow
29
+ initCollector(t, c)
30
+ collectOnce(t, c)
31
+ },
32
+ check: func(t *testing.T, c *Collector) {
33
+ resp := c.funcRouter.Handle(context.Background(), catofunc.TopologyMethodID, nil)
34
+ require.Equal(t, 200, resp.Status)
35
+ require.Equal(t, "topology", resp.ResponseType)
36
+ data, ok := resp.Data.(*topologyv1.Data)
37
+ require.True(t, ok)
38
+ validateCatoTopologyV1Data(t, data)
39
+ require.Equal(t, topologyv1.SchemaVersion, data.SchemaVersion)
40
+ require.Equal(t, topologySource, data.Producer.Source)
41
+ require.Greater(t, data.Actors.Rows, 0)
42
+ require.Greater(t, data.Links.Rows, 0)
43
+ },
44
+ },
45
+ "requires job selection": {
46
+ check: func(t *testing.T, _ *Collector) {
47
+ cfg := catofunc.Methods(defaultUpdateEvery)[0]
48
+ require.False(t, cfg.AgentWide)
49
+ },
50
+ },
51
+ }
52
+
53
+ for name, tc := range tests {
54
+ t.Run(name, func(t *testing.T) {
55
+ c := New()
56
+ c.AccountID = "12345"
57
+ c.APIKey = "secret"
58
+ if tc.setup != nil {
59
+ tc.setup(t, c)
60
+ }
61
+ tc.check(t, c)
62
+ })
63
+ }
64
+}
65
+
66
+func TestBuildTopology(t *testing.T) {
67
+ tests := map[string]struct {
68
+ build func(*testing.T) *topologyv1.Data
69
+ check func(*testing.T, *topologyv1.Data)
70
+ }{
71
+ "omits unavailable tunnel metrics": {
72
+ build: func(t *testing.T) *topologyv1.Data {
73
+ return mustBuildTopology(t, "12345", map[string]*siteState{
74
+ "1001": {
75
+ ID: "1001",
76
+ Name: "Paris Office",
77
+ ConnectivityStatus: "connected",
78
+ PopName: "POP-Paris",
79
+ Interfaces: make(map[string]*interfaceState),
80
+ },
81
+ }, []string{"1001"}, fixedCatoTestNow())
82
+ },
83
+ check: func(t *testing.T, data *topologyv1.Data) {
84
+ rows := topologyTableRows(t, data.Links, data.Dictionaries)
85
+ require.Len(t, rows, 1)
86
+ require.Equal(t, catofunc.LinkTypeTunnel, rows[0]["type"])
87
+ require.Nil(t, rows[0]["bytes_upstream_max"])
88
+ require.Nil(t, rows[0]["bytes_downstream_max"])
89
+ require.Nil(t, rows[0]["lost_upstream_percent"])
90
+ require.Nil(t, rows[0]["rtt_ms"])
91
+ },
92
+ },
93
+ "preserves empty BGP peer IP as an empty attribute": {
94
+ build: func(t *testing.T) *topologyv1.Data {
95
+ site := &siteState{
96
+ ID: "1001",
97
+ Name: "Paris Office",
98
+ BGPPeers: []bgpPeerState{
99
+ {
100
+ RemoteASN: "64512",
101
+ BGPSession: "Established",
102
+ },
103
+ },
104
+ }
105
+ return mustBuildTopology(t, "12345", map[string]*siteState{site.ID: site}, []string{site.ID}, time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC))
106
+ },
107
+ check: func(t *testing.T, data *topologyv1.Data) {
108
+ actors := topologyTableRows(t, data.Actors, data.Dictionaries)
109
+ peerActor := requireTopologyRow(t, actors, "type", catofunc.ActorTypeBGPPeer)
110
+ require.Empty(t, peerActor["remote_ip"])
111
+
112
+ links := topologyTableRows(t, data.Links, data.Dictionaries)
113
+ bgpLink := requireTopologyRow(t, links, "type", catofunc.LinkTypeBGP)
114
+ require.Equal(t, peerActor["_row"], bgpLink["dst_actor"])
115
+ },
116
+ },
117
+ "deduplicates BGP peers": {
118
+ build: func(t *testing.T) *topologyv1.Data {
119
+ site := &siteState{
120
+ ID: "1001",
121
+ Name: "Paris Office",
122
+ BGPPeers: []bgpPeerState{
123
+ {RemoteIP: "192.0.2.10", RemoteASN: "64512", BGPSession: "established"},
124
+ {RemoteIP: "192.0.2.10", RemoteASN: "64512", BGPSession: "established"},
125
+ },
126
+ }
127
+ return mustBuildTopology(t, "12345", map[string]*siteState{site.ID: site}, []string{site.ID}, time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC))
128
+ },
129
+ check: func(t *testing.T, data *topologyv1.Data) {
130
+ actors := topologyTableRows(t, data.Actors, data.Dictionaries)
131
+ links := topologyTableRows(t, data.Links, data.Dictionaries)
132
+ require.Equal(t, 1, countTopologyRows(actors, "type", catofunc.ActorTypeBGPPeer))
133
+ require.Equal(t, 1, countTopologyRows(links, "type", catofunc.LinkTypeBGP))
134
+ },
135
+ },
136
+ "device actors own their interfaces": {
137
+ build: func(t *testing.T) *topologyv1.Data {
138
+ site := &siteState{
139
+ ID: "1001",
140
+ Name: "Paris Office",
141
+ PopName: "POP-Paris",
142
+ Interfaces: map[string]*interfaceState{
143
+ "z": {Name: "WAN 2", DeviceID: "socket-z"},
144
+ "a": {Name: "WAN 1", DeviceID: "socket-a"},
145
+ },
146
+ Devices: []deviceState{
147
+ {ID: "socket-z", Name: "Socket 2", Connected: true, LastPopName: "POP-Paris"},
148
+ {ID: "socket-a", Name: "Socket 1", Connected: true, LastPopName: "POP-Paris"},
149
+ },
150
+ }
151
+ return mustBuildTopology(t, "12345", map[string]*siteState{site.ID: site}, []string{site.ID}, time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC))
152
+ },
153
+ check: func(t *testing.T, data *topologyv1.Data) {
154
+ require.NotNil(t, data.Tables)
155
+ interfaces := topologyTableRows(t, data.Tables.Actor[catofunc.ActorTableInterfaces].Table, data.Dictionaries)
156
+ actors := topologyTableRows(t, data.Actors, data.Dictionaries)
157
+ device1 := requireTopologyRow(t, actors, "display_name", "Socket 1")
158
+ device2 := requireTopologyRow(t, actors, "display_name", "Socket 2")
159
+ require.Equal(t, "WAN 1", interfaces[0]["name"])
160
+ require.Equal(t, "WAN 2", interfaces[1]["name"])
161
+ require.Equal(t, device1["_row"], interfaces[0]["actor"])
162
+ require.Equal(t, device2["_row"], interfaces[1]["actor"])
163
+ require.Equal(t, 2, countTopologyRows(actors, "type", catofunc.ActorTypeDevice))
164
+ },
165
+ },
166
+ }
167
+
168
+ for name, tc := range tests {
169
+ t.Run(name, func(t *testing.T) {
170
+ data := tc.build(t)
171
+ validateCatoTopologyV1Data(t, data)
172
+ tc.check(t, data)
173
+ })
174
+ }
175
+}
176
+
177
+func mustBuildTopology(t *testing.T, accountID string, sites map[string]*siteState, order []string, collectedAt time.Time) *topologyv1.Data {
178
+ t.Helper()
179
+
180
+ data, err := buildTopology(accountID, sites, order, collectedAt)
181
+ require.NoError(t, err)
182
+ return data
183
+}
184
+
185
+func validateCatoTopologyV1Data(t *testing.T, data *topologyv1.Data) {
186
+ t.Helper()
187
+
188
+ payload, err := json.Marshal(data)
189
+ require.NoError(t, err)
190
+
191
+ var decodedData map[string]any
192
+ require.NoError(t, json.Unmarshal(payload, &decodedData))
193
+ require.NoError(t, topologyv1.ValidateDecodedData(decodedData))
194
+
195
+ schemaBytes, err := os.ReadFile(filepath.Clean(filepath.Join("..", "..", "..", "..", "..", "plugins.d", "FUNCTION_TOPOLOGY_SCHEMA.json")))
196
+ require.NoError(t, err)
197
+
198
+ var schemaDoc any
199
+ require.NoError(t, json.Unmarshal(schemaBytes, &schemaDoc))
200
+
201
+ compiler := jsonschema.NewCompiler()
202
+ require.NoError(t, compiler.AddResource("schema.json", schemaDoc))
203
+ schema, err := compiler.Compile("schema.json")
204
+ require.NoError(t, err)
205
+
206
+ response := map[string]any{
207
+ "status": 200,
208
+ "type": topologyv1.ResponseType,
209
+ "data": decodedData,
210
+ }
211
+ require.NoError(t, schema.Validate(response))
212
+}
213
+
214
+func topologyTableRows(t *testing.T, table topologyv1.Table, dictionaries topologyv1.Dictionaries) []map[string]any {
215
+ t.Helper()
216
+
217
+ rows := make([]map[string]any, table.Rows)
218
+ for row := range rows {
219
+ rows[row] = map[string]any{"_row": row}
220
+ }
221
+
222
+ for columnIndex, column := range table.Columns {
223
+ values := topologyColumnValues(t, table.Rows, table.Values[columnIndex])
224
+ for rowIndex, value := range values {
225
+ if column.Type == "string_ref" {
226
+ value = resolveTopologyStringRef(t, dictionaries, column.Dictionary, value)
227
+ }
228
+ rows[rowIndex][column.ID] = value
229
+ }
230
+ }
231
+ return rows
232
+}
233
+
234
+func topologyColumnValues(t *testing.T, rows int, encoding topologyv1.ColumnEncoding) []any {
235
+ t.Helper()
236
+
237
+ switch value := encoding.(type) {
238
+ case topologyv1.ConstEncoding:
239
+ values := make([]any, rows)
240
+ for i := range values {
241
+ values[i] = value.Value
242
+ }
243
+ return values
244
+ case topologyv1.ValuesEncoding:
245
+ require.Len(t, value.Values, rows)
246
+ return value.Values
247
+ case topologyv1.DictEncoding:
248
+ require.Len(t, value.Indexes, rows)
249
+ values := make([]any, rows)
250
+ for i, index := range value.Indexes {
251
+ require.GreaterOrEqual(t, index, 0)
252
+ require.Less(t, index, len(value.Values))
253
+ values[i] = value.Values[index]
254
+ }
255
+ return values
256
+ default:
257
+ t.Fatalf("unsupported topology column encoding %T", encoding)
258
+ return nil
259
+ }
260
+}
261
+
262
+func resolveTopologyStringRef(t *testing.T, dictionaries topologyv1.Dictionaries, name string, value any) string {
263
+ t.Helper()
264
+
265
+ index, ok := value.(int)
266
+ require.True(t, ok)
267
+ values := dictionaries[name]
268
+ require.Less(t, index, len(values))
269
+ text, ok := values[index].(string)
270
+ require.True(t, ok)
271
+ return text
272
+}
273
+
274
+func requireTopologyRow(t *testing.T, rows []map[string]any, key string, value any) map[string]any {
275
+ t.Helper()
276
+
277
+ for _, row := range rows {
278
+ if row[key] == value {
279
+ return row
280
+ }
281
+ }
282
+ t.Fatalf("topology row with %s=%v not found", key, value)
283
+ return nil
284
+}
285
+
286
+func countTopologyRows(rows []map[string]any, key string, value any) int {
287
+ var count int
288
+ for _, row := range rows {
289
+ if row[key] == value {
290
+ count++
291
+ }
292
+ }
293
+ return count
294
+}
src/go/plugin/go.d/collector/cato_networks/warning_gates.go
new
+23
@@ -0,0 +1,23 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import "time"
6
+
7
+const recurringLogEvery = time.Hour
8
+
9
+const (
10
+ warningKeyDiscoveryCache = "discovery_cache"
11
+ warningKeyMetrics = "metrics"
12
+ warningKeyBGP = "bgp"
13
+ warningKeyNormalization = "normalization"
14
+)
15
+
16
+func (c *Collector) warnRecoverable(key, class, format string, args ...any) {
17
+ c.Limit("cato:"+key+":"+class, 1, recurringLogEvery).Warningf(format, args...)
18
+}
19
+
20
+func (c *Collector) logNormalizationIssue(surface, issue string) {
21
+ c.Limit("cato:"+warningKeyNormalization+":"+surface+":"+issue, 1, recurringLogEvery).
22
+ Warningf("Cato API payload normalization issue, surface=%s issue=%s", surface, issue)
23
+}
src/go/plugin/go.d/collector/cato_networks/write_metrics.go
new
+149
@@ -0,0 +1,149 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package cato_networks
4
+
5
+import (
6
+ "strings"
7
+
8
+ "github.com/netdata/netdata/go/plugins/pkg/metrix"
9
+)
10
+
11
+func (c *Collector) writeMetrics(sites map[string]*siteState, order []string) {
12
+ for _, siteID := range order {
13
+ site := sites[siteID]
14
+ if site == nil {
15
+ continue
16
+ }
17
+ scope := c.siteHostScope(site)
18
+ labels := []string{site.ID, site.Name, site.PopName}
19
+
20
+ observeStateSetVec(c.metrics.site.connectivityStatus.WithHostScope(scope), c.siteConnectivityState(site.ConnectivityStatus), labels...)
21
+ observeStateSetVec(c.metrics.site.operationalStatus.WithHostScope(scope), c.siteOperationalState(site.OperationalStatus), labels...)
22
+ c.metrics.site.hosts.WithHostScope(scope).WithLabelValues(labels...).Observe(float64(site.HostCount))
23
+ writeTrafficMetrics(site.Metrics, labels, c.metrics.site.traffic.withHostScope(scope))
24
+
25
+ for _, dev := range site.Devices {
26
+ deviceID := stableDeviceID(dev)
27
+ if deviceID == "" {
28
+ continue
29
+ }
30
+ deviceName := deviceDisplayName(dev)
31
+ if deviceName == "" {
32
+ deviceName = deviceID
33
+ }
34
+ deviceLabels := []string{site.ID, site.Name, deviceID, deviceName}
35
+ observeStateSetVec(c.metrics.device.connectionStatus.WithHostScope(scope), boolState(dev.Connected, "connected", "disconnected"), deviceLabels...)
36
+ }
37
+
38
+ for _, iface := range site.Interfaces {
39
+ ifaceLabels := []string{site.ID, site.Name, iface.DeviceID, iface.DeviceName, iface.ID, iface.Name}
40
+ observeStateSetVec(c.metrics.iface.connectionStatus.WithHostScope(scope), boolState(iface.Connected || iface.LinkUp, "connected", "disconnected"), ifaceLabels...)
41
+ writeTrafficMetrics(iface.Metrics, ifaceLabels, c.metrics.iface.traffic.withHostScope(scope))
42
+ c.metrics.iface.tunnelUptime.WithHostScope(scope).WithLabelValues(ifaceLabels...).Observe(float64(iface.TunnelUptime))
43
+ }
44
+
45
+ for _, peer := range site.BGPPeers {
46
+ peerLabels := []string{site.ID, site.Name, peer.RemoteIP, peer.RemoteASN}
47
+ observeStateSetVec(c.metrics.bgp.sessionStatus.WithHostScope(scope), bgpSessionState(peer.BGPSession), peerLabels...)
48
+ c.metrics.bgp.routes.WithHostScope(scope).WithLabelValues(peerLabels...).Observe(float64(peer.RoutesCount))
49
+ c.metrics.bgp.routesLimit.WithHostScope(scope).WithLabelValues(peerLabels...).Observe(float64(peer.RoutesCountLimit))
50
+ observeStateSetVec(c.metrics.bgp.routesLimitState.WithHostScope(scope), boolState(peer.RoutesCountLimitExceeded, "exceeded", "ok"), peerLabels...)
51
+ c.metrics.bgp.ribOutRoutes.WithHostScope(scope).WithLabelValues(peerLabels...).Observe(float64(peer.RIBOutRoutes))
52
+ }
53
+ }
54
+
55
+}
56
+
57
+func writeTrafficMetrics(m trafficMetrics, labels []string, writers trafficMetricWriters) {
58
+ if m.has(trafficMetricBytesUpstreamMax) {
59
+ writers.bytesUp.WithLabelValues(labels...).Observe(m.BytesUpstreamMax)
60
+ }
61
+ if m.has(trafficMetricBytesDownstreamMax) {
62
+ writers.bytesDown.WithLabelValues(labels...).Observe(m.BytesDownstreamMax)
63
+ }
64
+ if m.has(trafficMetricLostUpstreamPercent) {
65
+ writers.lostUp.WithLabelValues(labels...).Observe(m.LostUpstreamPercent)
66
+ }
67
+ if m.has(trafficMetricLostDownstreamPercent) {
68
+ writers.lostDown.WithLabelValues(labels...).Observe(m.LostDownstreamPercent)
69
+ }
70
+ if m.has(trafficMetricJitterUpstreamMS) {
71
+ writers.jitterUp.WithLabelValues(labels...).Observe(m.JitterUpstreamMS)
72
+ }
73
+ if m.has(trafficMetricJitterDownstreamMS) {
74
+ writers.jitterDown.WithLabelValues(labels...).Observe(m.JitterDownstreamMS)
75
+ }
76
+ if m.has(trafficMetricPacketsDiscardedUpstream) {
77
+ writers.discardUp.WithLabelValues(labels...).Observe(m.PacketsDiscardedUpstream)
78
+ }
79
+ if m.has(trafficMetricPacketsDiscardedDownstream) {
80
+ writers.discardDown.WithLabelValues(labels...).Observe(m.PacketsDiscardedDownstream)
81
+ }
82
+ if m.has(trafficMetricRTTMS) {
83
+ writers.rtt.WithLabelValues(labels...).Observe(m.RTTMS)
84
+ }
85
+ if writers.lastMileLatency != nil && m.has(trafficMetricLastMileLatencyMS) {
86
+ writers.lastMileLatency.WithLabelValues(labels...).Observe(m.LastMileLatencyMS)
87
+ }
88
+ if writers.lastMileLoss != nil && m.has(trafficMetricLastMilePacketLossPercent) {
89
+ writers.lastMileLoss.WithLabelValues(labels...).Observe(m.LastMilePacketLossPercent)
90
+ }
91
+}
92
+
93
+func observeStateSetVec(vec metrix.SnapshotStateSetVec, active string, labels ...string) {
94
+ if active == "" {
95
+ return
96
+ }
97
+ vec.WithLabelValues(labels...).Enable(active)
98
+}
99
+
100
+func boolState(ok bool, trueState, falseState string) string {
101
+ if ok {
102
+ return trueState
103
+ }
104
+ return falseState
105
+}
106
+
107
+func bgpSessionState(status string) string {
108
+ status = strings.ToLower(strings.TrimSpace(status))
109
+ switch status {
110
+ case "up", "established":
111
+ return "up"
112
+ case "", "unknown":
113
+ return "unknown"
114
+ default:
115
+ return "down"
116
+ }
117
+}
118
+
119
+func (c *Collector) siteConnectivityState(status string) string {
120
+ switch strings.ToLower(strings.TrimSpace(status)) {
121
+ case "connected":
122
+ return "connected"
123
+ case "disconnected":
124
+ return "disconnected"
125
+ case "degraded":
126
+ return "degraded"
127
+ case "", "unknown":
128
+ return "unknown"
129
+ default:
130
+ c.logNormalizationIssue(normalizationSurfaceSiteConnectivity, normalizationIssueUnknownStatus)
131
+ return "unknown"
132
+ }
133
+}
134
+
135
+func (c *Collector) siteOperationalState(status string) string {
136
+ switch strings.ToLower(strings.TrimSpace(status)) {
137
+ case "active":
138
+ return "active"
139
+ case "disabled":
140
+ return "disabled"
141
+ case "locked":
142
+ return "locked"
143
+ case "", "unknown":
144
+ return "unknown"
145
+ default:
146
+ c.logNormalizationIssue(normalizationSurfaceSiteOperational, normalizationIssueUnknownStatus)
147
+ return "unknown"
148
+ }
149
+}
src/go/plugin/go.d/collector/init.go
+1
@@ -14,6 +14,7 @@ import (
14
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/bind"
15
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/boinc"
16
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/cassandra"
17
+ _ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/cato_networks"
18
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/ceph"
19
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/chrony"
20
_ "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/clickhouse"
src/go/plugin/go.d/config/go.d.conf
+1
@@ -24,6 +24,7 @@ modules:
24
# beanstalk: yes
25
# bind: yes
26
# boinc: yes
27
+# cato_networks: yes
28
# ceph: yes
29
# chrony: yes
30
# clickhouse: yes
src/go/plugin/go.d/config/go.d/cato_networks.conf
new
+20
@@ -0,0 +1,20 @@
1
+# netdata go.d.plugin configuration for Cato Networks
2
+#
3
+# This file is in YAML format.
4
+
5
+# jobs:
6
+# - name: account
7
+# account_id: "12345"
8
+# api_key: "replace-with-cato-api-key"
9
+#
10
+# # Cato GraphQL endpoint. Production endpoints must use HTTPS.
11
+# url: https://api.catonetworks.com/api/v1/graphql2
12
+#
13
+# # Collection interval. Cato API collection is intentionally not run faster
14
+# # than once per minute.
15
+# update_every: 60
16
+# timeout: 30
17
+#
18
+# # Space-separated simple patterns matched against the Cato site name when present, otherwise the site ID.
19
+# # Patterns use first-match ordering; put exclusions before broad includes, for example "!lab-* *".
20
+# site_selector: "*"
src/go/plugin/go.d/pkg/collecttest/chart_coverage.go
+49
-11
@@ -25,6 +25,11 @@ type chartCoverage struct {
25
ExpectedByContext map[string][]string
26
}
27
28
+type scopedChartCoverage struct {
29
+ ScopeKey string
30
+ Coverage chartCoverage
31
+}
32
+
33
// ChartCoverageExpectation defines per-scenario chart coverage assertions.
34
type ChartCoverageExpectation struct {
35
// ExcludeContextPatterns are glob patterns for contexts ignored from
@@ -58,23 +63,48 @@ func AssertChartCoverage(
63
}
64
templateYAML := collector.ChartTemplateYAML()
65
61
- coverage, err := buildChartCoverage(
62
- templateYAML,
63
- 1,
64
- store.Read(metrix.ReadRaw(), metrix.ReadFlatten()),
65
- exp.ExcludeContextPatterns,
66
- )
66
+ coverages, err := buildChartCoveragesFromStore(templateYAML, 1, store, exp.ExcludeContextPatterns)
67
if err != nil {
68
t.Fatalf("collecttest: build chart coverage: %v", err)
69
return
70
}
71
72
- for contextName, dims := range coverage.ExpectedByContext {
73
- requireContextDims(t, coverage.ActualByContext, contextName, dims)
72
+ for _, scoped := range coverages {
73
+ for contextName, dims := range scoped.Coverage.ExpectedByContext {
74
+ requireContextDims(t, scoped.Coverage.ActualByContext, contextName, dims, scoped.ScopeKey)
75
+ }
76
+ for contextName, dims := range exp.RequiredContexts {
77
+ requireContextDims(t, scoped.Coverage.ActualByContext, contextName, dims, scoped.ScopeKey)
78
+ }
79
}
75
- for contextName, dims := range exp.RequiredContexts {
76
- requireContextDims(t, coverage.ActualByContext, contextName, dims)
80
+}
81
+
82
+func buildChartCoveragesFromStore(
83
+ templateYAML string,
84
+ revision uint64,
85
+ store metrix.CollectorStore,
86
+ excludeContextPatterns []string,
87
+) ([]scopedChartCoverage, error) {
88
+ reader := store.Read(metrix.ReadRaw(), metrix.ReadFlatten())
89
+ scopes := reader.HostScopes()
90
+ if len(scopes) == 0 {
91
+ coverage, err := buildChartCoverage(templateYAML, revision, reader, excludeContextPatterns)
92
+ if err != nil {
93
+ return nil, err
94
+ }
95
+ return []scopedChartCoverage{{Coverage: coverage}}, nil
96
+ }
97
+
98
+ out := make([]scopedChartCoverage, 0, len(scopes))
99
+ for _, scope := range scopes {
100
+ reader := store.Read(metrix.ReadRaw(), metrix.ReadFlatten(), metrix.ReadHostScope(scope.ScopeKey))
101
+ coverage, err := buildChartCoverage(templateYAML, revision, reader, excludeContextPatterns)
102
+ if err != nil {
103
+ return nil, err
104
+ }
105
+ out = append(out, scopedChartCoverage{ScopeKey: scope.ScopeKey, Coverage: coverage})
106
}
107
+ return out, nil
108
}
109
110
func buildChartCoverage(
@@ -393,16 +423,24 @@ func filterEmptyString(values []string) []string {
423
return out
424
}
425
396
-func requireContextDims(t *testing.T, byContext map[string]map[string]struct{}, contextName string, dimNames []string) {
426
+func requireContextDims(t *testing.T, byContext map[string]map[string]struct{}, contextName string, dimNames []string, scopeKey string) {
427
t.Helper()
428
429
dims, ok := byContext[contextName]
430
if !ok {
431
+ if scopeKey != "" {
432
+ t.Fatalf("collecttest: missing chart context %q in host scope %q", contextName, scopeKey)
433
+ return
434
+ }
435
t.Fatalf("collecttest: missing chart context %q", contextName)
436
return
437
}
438
for _, name := range dimNames {
439
if _, exists := dims[name]; !exists {
440
+ if scopeKey != "" {
441
+ t.Fatalf("collecttest: missing dimension %q in context %q in host scope %q", name, contextName, scopeKey)
442
+ return
443
+ }
444
t.Fatalf("collecttest: missing dimension %q in context %q", name, contextName)
445
return
446
}
src/go/plugin/go.d/pkg/collecttest/collecttest_test.go
+44
@@ -215,6 +215,50 @@ groups:
215
}
216
}
217
218
+func TestBuildChartCoveragesFromStoreKeepsHostScopesSeparate(t *testing.T) {
219
+ scopeA := metrix.HostScope{ScopeKey: "scope-a", GUID: "guid-a", Hostname: "host-a"}
220
+ scopeB := metrix.HostScope{ScopeKey: "scope-b", GUID: "guid-b", Hostname: "host-b"}
221
+ store := newTestCollectorStore(t, func(m metrix.SnapshotMeter) {
222
+ m.WithHostScope(scopeA).Gauge("metric_a").Observe(1)
223
+ m.WithHostScope(scopeB).Gauge("metric_b").Observe(2)
224
+ })
225
+
226
+ templateYAML := `
227
+version: v1
228
+context_namespace: test
229
+groups:
230
+ - family: Root
231
+ metrics: [metric_a, metric_b]
232
+ charts:
233
+ - title: A
234
+ context: a
235
+ units: "1"
236
+ dimensions:
237
+ - selector: metric_a
238
+ name: x
239
+ - title: B
240
+ context: b
241
+ units: "1"
242
+ dimensions:
243
+ - selector: metric_b
244
+ name: y
245
+`
246
+
247
+ coverages, err := buildChartCoveragesFromStore(templateYAML, 1, store, nil)
248
+ require.NoError(t, err)
249
+ require.Len(t, coverages, 2)
250
+
251
+ byScope := make(map[string]chartCoverage, len(coverages))
252
+ for _, scoped := range coverages {
253
+ byScope[scoped.ScopeKey] = scoped.Coverage
254
+ }
255
+
256
+ require.Equal(t, map[string][]string{"test.a": {"x"}}, normalizeCoverageDimsList(byScope[scopeA.ScopeKey].ExpectedByContext))
257
+ require.Equal(t, map[string][]string{"test.a": {"x"}}, normalizeCoverageDims(byScope[scopeA.ScopeKey].ActualByContext))
258
+ require.Equal(t, map[string][]string{"test.b": {"y"}}, normalizeCoverageDimsList(byScope[scopeB.ScopeKey].ExpectedByContext))
259
+ require.Equal(t, map[string][]string{"test.b": {"y"}}, normalizeCoverageDims(byScope[scopeB.ScopeKey].ActualByContext))
260
+}
261
+
262
func TestCollectOnceAbortsCycleOnPanic(t *testing.T) {
263
store := metrix.NewCollectorStore()
264
src/health/health.d/cato_networks.conf
new
+60
@@ -0,0 +1,60 @@
1
+# you can disable an alarm notification by setting the 'to' line to: silent
2
+
3
+ template: cato_networks_site_disconnected
4
+ on: cato_networks.site_connectivity_status
5
+ class: Errors
6
+ type: Network
7
+component: Cato Networks
8
+ lookup: average -5m unaligned of disconnected
9
+ units: status
10
+ every: 1m
11
+ crit: $this > 0
12
+ delay: down 15m multiplier 1.5 max 1h
13
+ summary: Cato site ${label:site_name} disconnected
14
+ info: Cato site ${label:site_name} reports disconnected connectivity status
15
+ to: sysadmin
16
+
17
+ template: cato_networks_site_degraded
18
+ on: cato_networks.site_connectivity_status
19
+ class: Errors
20
+ type: Network
21
+component: Cato Networks
22
+ lookup: average -10m unaligned of degraded
23
+ units: status
24
+ every: 1m
25
+ warn: $this > 0
26
+ delay: down 15m multiplier 1.5 max 1h
27
+ summary: Cato site ${label:site_name} degraded
28
+ info: Cato site ${label:site_name} reports degraded connectivity status
29
+ to: sysadmin
30
+
31
+ template: cato_networks_site_packet_loss
32
+ on: cato_networks.site_packet_loss
33
+ class: Errors
34
+ type: Network
35
+component: Cato Networks
36
+ lookup: average -10m unaligned of upstream,downstream
37
+ units: %
38
+ every: 1m
39
+ green: 1
40
+ red: 5
41
+ warn: $this > $green
42
+ crit: $this > $red
43
+ delay: down 15m multiplier 1.5 max 1h
44
+ summary: Cato site ${label:site_name} packet loss
45
+ info: Cato site ${label:site_name} packet loss over the last 10 minutes
46
+ to: sysadmin
47
+
48
+ template: cato_networks_bgp_session_down
49
+ on: cato_networks.bgp_session_status
50
+ class: Errors
51
+ type: Network
52
+component: Cato Networks
53
+ lookup: average -10m unaligned of down
54
+ units: status
55
+ every: 1m
56
+ crit: $this > 0
57
+ delay: down 15m multiplier 1.5 max 1h
58
+ summary: Cato BGP session down for ${label:site_name}
59
+ info: Cato BGP session to peer ${label:peer_ip} (${label:peer_asn}) is down
60
+ to: sysadmin