| 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 | } |