diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd43ae2..dc8079a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: - name: Setup Go environment uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.23" - name: Test backend uses: magefile/mage-action@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9eecd2..e95591e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Go environment uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.23" - name: Get yarn cache directory path id: yarn-cache-dir-path diff --git a/.gitignore b/.gitignore index 3012531..afbba7e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,4 @@ e2e-results/ .DS_Store .bra.toml - +.eslintcache diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ab7ff..f6eb94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,19 @@ ### ⭐ Added - Add a query tag that includes relevant Grafana context information. +- Add support of OAuth authentication. ### 🐞 Bug Fixes - Source query variables are replaced with hardcoded values in the query editor UI. +### 🔨 Changed +- Rewrite the datasource configuration UI (ease authentication selection). +- Support non encoded private key in the datasource configuration. +- Update deprecated APIs +- Upgrade grafana-plugin-sdk-go to version v0.265.0. +- Upgrade gosnowflake to version v1.13.0. +- Upgrade go to version 1.23. + ## 1.9.1 ### 🔨 Changed diff --git a/README.md b/README.md index cf71720..b63a404 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,15 @@ docker run -d \ grafana/grafana ``` +> [!NOTE] +> Please refer to the documentation for more details. +https://grafana.com/docs/grafana/latest/administration/plugin-management/#allow-unsigned-plugins + 3. Restart grafana +Restart the Grafana server to apply the changes: +``` bash +service grafana-server restart +``` #### Configure the Datasource @@ -57,17 +65,43 @@ Add your authentication and [configuration details](https://docs.snowflake.com/e Available configuration fields are as follows: - Name | Description -------------------------- | ------------ - Account Name | Specifies the full name of your account (provided by Snowflake) - Username | Specifies the login name of the user for the connection. - Password | Specifies the password for the specified user. - Private key | Specifies the the private key. Must be encoded in base 64 URL encoded pkcs8.**Command :** `egrep -v '^(-----BEGIN PRIVATE KEY\|-----END PRIVATE KEY)' rsa_key.p8 \| tr -d '\n' \| sed 's/+/-/g; s/\//_/g' > rsa_key_urlbase64.p8` - Role (Optional) | Specifies the default access control role to use in the Snowflake session initiated by Grafana. - Warehouse (Optional) | Specifies the virtual warehouse to use once connected. - Database (Optional) | Specifies the default database to use once connected. - Schema (Optional) | Specifies the default schema to use for the specified database once connected. - Extra Options (Optional) | Specifies a series of one or more parameters, in the form of `=`, with each parameter separated by the ampersand character (&), and no spaces anywhere in the connection string. +| Name | Description | +|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Account Name | Specifies the full name of your account (provided by Snowflake) | +| Username | Specifies the login name of the user for the connection. | +| Password | Specifies the password for the specified user. | +| Private key | Specifies the private key. | +| Client Id | Specifies the Oauth client ID. | +| Client Secret | Specifies the Oauth client Secret. | +| Token Endpoint | Specifies the Oauth Token endpoint. | +| Role (Optional) | Specifies the default access control role to use in the Snowflake session initiated by Grafana. With Oauth, it's used to limit the access token to a single role that the user can consent to for the session. | +| Warehouse (Optional) | Specifies the virtual warehouse to use once connected. | +| Database (Optional) | Specifies the default database to use once connected. | +| Schema (Optional) | Specifies the default schema to use for the specified database once connected. | +| Extra Options (Optional) | Specifies a series of one or more parameters, in the form of `=`, with each parameter separated by the ampersand character (&), and no spaces anywhere in the connection string. | + +**External OAuth authentication** + +> [!NOTE] +> Snowflake oauth authentication is not supported without external service (like Okta, Azure Entra, Keycloak ...) because of the lack of support for oauth Client credentials flow in snowflake. +https://docs.snowflake.com/en/user-guide/oauth-intro + +The plugin supports OAuth authentication with snowflake only with external_service. +To use OAuth, you need to create an [external OAuth](https://docs.snowflake.com/en/user-guide/oauth-ext-custom) integration in your Snowflake account. +```sql +-- Create a security integration for external OAuth flow +CREATE OR REPLACE SECURITY INTEGRATION OAUTH_INTEGRATION +TYPE = EXTERNAL_OAUTH +ENABLED = TRUE +EXTERNAL_OAUTH_TYPE = CUSTOM +EXTERNAL_OAUTH_SCOPE_MAPPING_ATTRIBUTE = 'scope' +EXTERNAL_OAUTH_TOKEN_USER_MAPPING_CLAIM = 'name' +EXTERNAL_OAUTH_SNOWFLAKE_USER_MAPPING_ATTRIBUTE = 'login_name' +EXTERNAL_OAUTH_ALLOWED_ROLES_LIST = ('') +EXTERNAL_OAUTH_AUDIENCE_LIST =('https://xxxxx') +EXTERNAL_OAUTH_RSA_PUBLIC_KEY = 'MIIBIj' +EXTERNAL_OAUTH_ISSUER = 'https://xxxxx'; +``` #### Supported Macros @@ -108,6 +142,9 @@ For Time series query:  +> [!CAUTION] +> This plugin cannot identify malicious code in queries executed on Snowflake and assumes no responsibility for their execution. As a precaution, use a ROLE with minimal privileges, configured to grant read-only access + ##### Query Variables You can use query variable in your Snowflake queries by using [variable syntax](https://grafana.com/docs/grafana/latest/dashboards/variables/variable-syntax/). @@ -187,6 +224,11 @@ GROUP BY Annotations allow you to overlay events on a graph. To create an annotation, in the dashboard settings click "Annotations", and "New". +#### Oauth Configuration + +To use Oauth, you need to create an Oauth custom integration in your Snowflake account.< +You can follow the steps in the [Snowflake documentation](https://docs.snowflake.com/en/user-guide/oauth-custom). + ## Caching ### Snowflake caching @@ -194,6 +236,9 @@ Snowflake caches queries with the same footprint / hash in its own query-cache. To get more queries with the same hash use the two macros `$__timeRoundFrom(d)` and `$__timeRoundTo(d)` to create wider truncated timestamps. This is no problem for timeseries charts. Grafana cuts it's x-Axis to the selected dashboard time window. If a table is displayed the whole result will be presented and it could be slightly out of the time window.\ More info about snowflake-side caching: https://docs.snowflake.com/en/user-guide/querying-persisted-results#retrieval-optimization +## Supported Grafana Versions +This plugin supports only version with [Active Support from Grafana](https://grafana.com/docs/grafana/next/upgrade-guide/when-to-upgrade/?pg=blog&plcmt=body-txt#what-to-know-about-version-support). + ## Development The snowflake datasource is a data source backend plugin composed of both frontend and backend components. diff --git a/go.mod b/go.mod index 4ea6ff3..de5c20f 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,15 @@ module github.com/michelin/snowflake-grafana-datasource -go 1.22 +go 1.23.5 -toolchain go1.22.0 +toolchain go1.23.6 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 - github.com/grafana/grafana-plugin-sdk-go v0.260.1 + github.com/grafana/grafana-plugin-sdk-go v0.265.0 github.com/snowflakedb/gosnowflake v1.13.0 github.com/stretchr/testify v1.10.0 + golang.org/x/oauth2 v0.26.0 ) require ( @@ -19,7 +20,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c // indirect - github.com/apache/arrow/go/v15 v15.0.2 // indirect + github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30 // indirect github.com/apache/arrow/go/v16 v16.0.0 // indirect github.com/aws/aws-sdk-go-v2 v1.26.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect @@ -43,15 +44,15 @@ require ( github.com/danieljoos/wincred v1.1.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dvsekhvalnov/jose2go v1.6.0 // indirect - github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 // indirect + github.com/elazarl/goproxy v1.7.0 // indirect github.com/fatih/color v1.15.0 // indirect github.com/gabriel-vasile/mimetype v1.4.7 // indirect - github.com/getkin/kin-openapi v0.128.0 // indirect + github.com/getkin/kin-openapi v0.129.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.4 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect @@ -59,22 +60,21 @@ require ( github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/mux v1.8.0 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect - github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/yamux v0.1.1 // indirect - github.com/invopop/yaml v0.3.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.9 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/magefile/mage v1.15.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattetti/filebuffer v1.0.1 // indirect @@ -86,7 +86,9 @@ require ( github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oklog/run v1.1.0 // indirect + github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 // indirect + github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect @@ -94,7 +96,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.20.5 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.60.1 // indirect + github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -103,31 +105,32 @@ require ( github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect github.com/urfave/cli v1.22.16 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.57.0 // indirect - go.opentelemetry.io/contrib/propagators/jaeger v1.32.0 // indirect - go.opentelemetry.io/contrib/samplers/jaegerremote v0.26.0 // indirect - go.opentelemetry.io/otel v1.32.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 // indirect - go.opentelemetry.io/otel/metric v1.32.0 // indirect - go.opentelemetry.io/otel/sdk v1.32.0 // indirect - go.opentelemetry.io/otel/trace v1.32.0 // indirect - go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.27.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.59.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.34.0 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/sdk v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/mod v0.22.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/tools v0.28.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 // indirect - google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect + google.golang.org/grpc v1.70.0 // indirect + google.golang.org/protobuf v1.36.4 // indirect gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c5f597a..8be2f91 100644 --- a/go.sum +++ b/go.sum @@ -19,10 +19,14 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c h1:RGWPOewvKIROun94nF7v2cua9qP+thov/7M50KEoeSU= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= -github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE= -github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30 h1:hXVi7QKuCQ0E8Yujfu9b0f0RnzZ72efpWvPnZgnJPrE= +github.com/apache/arrow-go/v18 v18.0.1-0.20241212180703-82be143d7c30/go.mod h1:RNuWDIiGjq5nndL2PyQrndUy9nMLwheA3uWaAV7fe4U= github.com/apache/arrow/go/v16 v16.0.0 h1:qRLbJRPj4zaseZrjbDHa7mUoZDDIU+4pu+mE2Lucs5g= github.com/apache/arrow/go/v16 v16.0.0/go.mod h1:9wnc9mn6vEDTRIm4+27pEjQpRKuTvBaessPoEXQzxWA= +github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= +github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to= @@ -86,11 +90,8 @@ github.com/dnaeon/go-vcr v1.1.0 h1:ReYa/UBrRyQdant9B4fNHGoCNKw6qh6P0fsdGmZpR7c= github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY= github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= -github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMxETKpmQoWMBkeiuorElZIXoNmgiPE= -github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= -github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= -github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac h1:9yrT5tmn9Zc0ytWPASlaPwQfQMQYnRf0RSDe1XvHw0Q= -github.com/elazarl/goproxy/ext v0.0.0-20220115173737-adb46da277ac/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/elazarl/goproxy v1.7.0 h1:EXv2nV4EjM60ZtsEVLYJG4oBXhDGutMKperpHsZ/v+0= +github.com/elazarl/goproxy v1.7.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= @@ -100,8 +101,8 @@ github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= -github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4= -github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM= +github.com/getkin/kin-openapi v0.129.0 h1:QGYTNcmyP5X0AtFQ2Dkou9DGBJsUETeLH9rFrJXZh30= +github.com/getkin/kin-openapi v0.129.0/go.mod h1:gmWI+b/J45xqpyK5wJmRRZse5wefA5H0RDMK46kLUtI= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= @@ -114,8 +115,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= +github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -126,6 +127,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI= github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -135,30 +138,28 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/grafana-plugin-sdk-go v0.260.1 h1:KzbooQP9mv/9CPsn+SoUwGuomA8oUxO0iuIq6Rg/ekE= -github.com/grafana/grafana-plugin-sdk-go v0.260.1/go.mod h1:JriieK5Oc5v120QKhMs/LO55N0P3YI2ttEiVT1wfYsw= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/grafana/grafana-plugin-sdk-go v0.265.0 h1:XshoH8R23Jm9jRreW9R3aOrIVr9vxhCWFyrMe7BFSks= +github.com/grafana/grafana-plugin-sdk-go v0.265.0/go.mod h1:nkN6xI08YcX6CGsgvRA2+19nhXA/ZPuneLMUUElOD80= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg= github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0 h1:ad0vkEBuk23VJzZR9nkLVG0YAoN9coASF1GusYX6AlU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.23.0/go.mod h1:igFoXX2ELCW06bol23DWPB5BEWfZISOzSP5K2sbLea0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0 h1:kQ0NI7W1B3HwiN5gAYtY+XFItDPbLBwYRxAqbFTyDes= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0/go.mod h1:zrT2dxOAjNFPRGjTUe2Xmb4q4YdUwVvQFV6xiCSf+z0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= -github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= -github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -174,10 +175,12 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -203,6 +206,10 @@ github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APP github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -217,8 +224,12 @@ github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ib github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= -github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80 h1:nZspmSkneBbtxU9TopEAE0CY+SBJLxO8LPUlw2vG4pU= +github.com/oasdiff/yaml v0.0.0-20241210131133-6b86fb107d80/go.mod h1:7tFDb+Y51LcDpn26GccuUgQXUk6t0CXZsivKjyimYX8= +github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349 h1:t05Ww3DxZutOqbMN+7OIuqDwXbhl32HiZGpLy26BAPc= +github.com/oasdiff/yaml3 v0.0.0-20241210130736-a94c01f36349/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= @@ -233,11 +244,10 @@ github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+ github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= -github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -283,58 +293,64 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0 h1:yMkBS9yViCc7U7yeLzJPM2XizlfdVvBRSmsQDWu6qc0= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.56.0/go.mod h1:n8MR6/liuGB5EmTETUBeU5ZgqMOlqKRxUaqPQBOANZ8= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.57.0 h1:7F3XCD6WYzDkwbi8I8N+oYJWquPVScnRosKGgqjsR8c= -go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.57.0/go.mod h1:Dk3C0BfIlZDZ5c6eVS7TYiH2vssuyUU3vUsgbrR+5V4= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94= -go.opentelemetry.io/contrib/propagators/jaeger v1.32.0 h1:K/fOyTMD6GELKTIJBaJ9k3ppF2Njt8MeUGBOwfaWXXA= -go.opentelemetry.io/contrib/propagators/jaeger v1.32.0/go.mod h1:ISE6hda//MTWvtngG7p4et3OCngsrTVfl7c6DjN17f8= -go.opentelemetry.io/contrib/samplers/jaegerremote v0.26.0 h1:/SKXyZLAnuj981HVc8G5ZylYK3qD2W6AYR6cJx5kIHw= -go.opentelemetry.io/contrib/samplers/jaegerremote v0.26.0/go.mod h1:cOEzME0M2OKeHB45lJiOKfvUCdg/r75mf7YS5w0tbmE= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.59.0 h1:iQZYNQ7WwIcYXzOPR46FQv9O0dS1PW16RjvR0TjDOe8= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.59.0/go.mod h1:54CaSNqYEXvpzDh8KPjiMVoWm60t5R0dZRt0leEPgAs= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= +go.opentelemetry.io/contrib/propagators/jaeger v1.34.0 h1:D3htJISCUU/wOVlKwisVKancWm+2U4h9xDEaiMkiyRE= +go.opentelemetry.io/contrib/propagators/jaeger v1.34.0/go.mod h1:DAX1bsj+uDm2ZuOQH/RgZRx7RQZWyzV5W2WR/0UX8JA= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0 h1:Xx1N6cDr8iWy1Cz6OcY7oS0ACdt/6HDYTdu4KskuC7s= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.28.0/go.mod h1:iWS+NvC948FyfnJbVfPN9h/8+vr8CR2FPn6XsLRkvH8= go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= -go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= -go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= -go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= -go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= -go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= -go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= -go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= -go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= -golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= +golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191020152052-9984515f0562/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -348,13 +364,12 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= @@ -363,24 +378,24 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gonum.org/v1/gonum v0.15.0 h1:2lYxjRbTYyxkJxlhC+LvJIx3SsANPdRybu1tGj9/OrQ= -gonum.org/v1/gonum v0.15.0/go.mod h1:xzZVBJBtS+Mz4q0Yl2LJTk+OxOg4jiXZ7qBoM0uISGo= -google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28 h1:M0KvPgPmDZHPlbRbaNU1APr28TvwvvdUPlSv7PUvy8g= -google.golang.org/genproto/googleapis/api v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:dguCy7UOdZhTvLzDyt15+rOrawrpM4q7DD9dQ1P11P4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28 h1:XVhgTWWV3kGQlwJHR3upFWZeTsei6Oks1apkZSeonIE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241104194629-dd2ea8efbc28/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0= +gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o= +google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA= +google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= +google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= +google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/img/configuration.png b/img/configuration.png index e704427..71480e4 100644 Binary files a/img/configuration.png and b/img/configuration.png differ diff --git a/img/query.png b/img/query.png index 4783059..4d0ff23 100644 Binary files a/img/query.png and b/img/query.png differ diff --git a/pkg/check_health.go b/pkg/check_health.go index 26dfe88..f7df083 100644 --- a/pkg/check_health.go +++ b/pkg/check_health.go @@ -4,10 +4,10 @@ import ( "context" "database/sql" "fmt" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/michelin/snowflake-grafana-datasource/pkg/data" + _oauth "github.com/michelin/snowflake-grafana-datasource/pkg/oauth" "github.com/michelin/snowflake-grafana-datasource/pkg/utils" - - "github.com/grafana/grafana-plugin-sdk-go/backend" _ "github.com/snowflakedb/gosnowflake" ) @@ -26,20 +26,14 @@ func (td *SnowflakeDatasource) CheckHealth(ctx context.Context, req *backend.Che var err error td.db, err = sql.Open("snowflake", connectionString) if err != nil { - return &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: fmt.Sprintf("Connection issue : %s", err), - }, nil + return createHealthError(fmt.Sprintf("Connection issue : %s", err)), nil } } defer td.db.Close() row, err := td.db.QueryContext(utils.AddQueryTagInfos(ctx, &data.QueryConfigStruct{}), "SELECT 1") if err != nil { - return &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: fmt.Sprintf("Validation query error : %s", err), - }, nil + return createHealthError(fmt.Sprintf("Validation query error : %s", err)), nil } defer row.Close() @@ -50,37 +44,58 @@ func (td *SnowflakeDatasource) CheckHealth(ctx context.Context, req *backend.Che }, nil } +// createHealthError creates an error result +func createHealthError(message string) *backend.CheckHealthResult { + return &backend.CheckHealthResult{ + Status: backend.HealthStatusError, + Message: message, + } +} + +// validateAuthFields validates the authentication fields +func validateAuthFields(password, privateKey string, oauth _oauth.Oauth) *backend.CheckHealthResult { + if password == "" && privateKey == "" && oauth.ClientSecret == "" { + return createHealthError("Password or private key or Oauth fields are required.") + } + + if (password != "" && (privateKey != "" || oauth.ClientSecret != "")) || (privateKey != "" && oauth.ClientSecret != "") { + return createHealthError("Only one authentication method must be provided.") + } + + if password == "" && privateKey == "" && (oauth.ClientSecret == "" || oauth.ClientId == "" || oauth.TokenEndpoint == "") { + return createHealthError("All OAuth fields are mandatory.") + } + + return nil +} + +// createAndValidationConnectionString creates a connection string and validates the configuration func createAndValidationConnectionString(req *backend.CheckHealthRequest) (string, *backend.CheckHealthResult) { + + config, err := getConfig(req.PluginContext.DataSourceInstanceSettings) + if err != nil { + return "", createHealthError(fmt.Sprintf("Error getting config: %s", err)) + } + password := req.PluginContext.DataSourceInstanceSettings.DecryptedSecureJSONData["password"] privateKey := req.PluginContext.DataSourceInstanceSettings.DecryptedSecureJSONData["privateKey"] - if password == "" && privateKey == "" { - return "", &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: "Password or private key are required.", - } + oauth := _oauth.Oauth{ + ClientId: config.ClientId, + ClientSecret: req.PluginContext.DataSourceInstanceSettings.DecryptedSecureJSONData["clientSecret"], + TokenEndpoint: config.TokenEndpoint, } - config, err := getConfig(req.PluginContext.DataSourceInstanceSettings) - if err != nil { - return "", &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: fmt.Sprintf("Error getting config: %s", err), - } + if validationResult := validateAuthFields(password, privateKey, oauth); validationResult != nil { + return "", validationResult } if config.Account == "" { - return "", &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: "Account not provided", - } + return "", createHealthError("Account not provided") } - if config.Username == "" { - return "", &backend.CheckHealthResult{ - Status: backend.HealthStatusError, - Message: "Username not provided", - } + if config.Username == "" && (password != "" || privateKey != "") { + return "", createHealthError("Username not provided") } if len(config.ExtraConfig) > 0 { @@ -89,6 +104,13 @@ func createAndValidationConnectionString(req *backend.CheckHealthRequest) (strin config.ExtraConfig = "validateDefaultParameters=true" } - connectionString := getConnectionString(&config, password, privateKey) + token, err := _oauth.GetToken(oauth, true) + if err != nil { + return "", createHealthError(fmt.Sprintf("Error getting token: %s", err)) + } + + authenticationSecret := data.AuthenticationSecret{Password: password, PrivateKey: privateKey, Token: token} + + connectionString := getConnectionString(&config, authenticationSecret) return connectionString, nil } diff --git a/pkg/check_health_test.go b/pkg/check_health_test.go index 693a91b..d43f99a 100644 --- a/pkg/check_health_test.go +++ b/pkg/check_health_test.go @@ -7,6 +7,8 @@ import ( "github.com/DATA-DOG/go-sqlmock" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" "testing" ) @@ -70,7 +72,7 @@ func TestCheckHealthWithMissingPasswordAndPrivateKey(t *testing.T) { result, err := td.CheckHealth(ctx, req) require.NoError(t, err) require.Equal(t, backend.HealthStatusError, result.Status) - require.Equal(t, "Password or private key are required.", result.Message) + require.Equal(t, "Password or private key or Oauth fields are required.", result.Message) } func TestCheckHealthWithInvalidJSONData(t *testing.T) { @@ -93,21 +95,25 @@ func TestCheckHealthWithInvalidJSONData(t *testing.T) { func TestCreateAndValidationConnectionString(t *testing.T) { tcs := []struct { + name string request *backend.CheckHealthRequest result *backend.CheckHealthResult connectionString string }{ { + name: "Missing Authentication", request: &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{}"), DecryptedSecureJSONData: map[string]string{"password": ""}, }, }, }, - result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Password or private key are required."}, + result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Password or private key or Oauth fields are required."}, }, { + name: "Bad Json Configuration", request: &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ @@ -119,6 +125,7 @@ func TestCreateAndValidationConnectionString(t *testing.T) { result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Error getting config: unexpected end of JSON input"}, }, { + name: "missing Account", request: &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ @@ -130,6 +137,7 @@ func TestCreateAndValidationConnectionString(t *testing.T) { result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Account not provided"}, }, { + name: "missing Username", request: &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ @@ -141,6 +149,43 @@ func TestCreateAndValidationConnectionString(t *testing.T) { result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Username not provided"}, }, { + name: "multiple Auth Methods Pass And Key", + request: &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{\"account\":\"test\"}"), + DecryptedSecureJSONData: map[string]string{"password": "pass", "privateKey": "xxxxx"}, + }, + }, + }, + result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Only one authentication method must be provided."}, + }, + { + name: "multiple Auth Methods Pass And Oauth", + request: &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{\"account\":\"test\"}"), + DecryptedSecureJSONData: map[string]string{"password": "pass", "clientSecret": "s"}, + }, + }, + }, + result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Only one authentication method must be provided."}, + }, + { + name: "multiple Auth Methods Key And Oauth", + request: &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{\"account\":\"test\"}"), + DecryptedSecureJSONData: map[string]string{"clientSecret": "t", "privateKey": "xxxxx"}, + }, + }, + }, + result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "Only one authentication method must be provided."}, + }, + { + name: "valid User Password Auth", request: &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ @@ -152,6 +197,43 @@ func TestCreateAndValidationConnectionString(t *testing.T) { connectionString: "user:pass@test?database=&role=&schema=&warehouse=&validateDefaultParameters=true", }, { + name: "missing ClientId And Token Endpoint", + request: &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{\"account\":\"test\"}"), + DecryptedSecureJSONData: map[string]string{"clientSecret": "t"}, + }, + }, + }, + result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "All OAuth fields are mandatory."}, + }, + { + name: "missing Token Endpoint", + request: &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{\"account\":\"test\", \"clientId\": \"t\"}"), + DecryptedSecureJSONData: map[string]string{"clientSecret": "t"}, + }, + }, + }, + result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "All OAuth fields are mandatory."}, + }, + { + name: "missing ClientId", + request: &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{\"account\":\"test\", \"tokenEndpoint\": \"t\"}"), + DecryptedSecureJSONData: map[string]string{"clientSecret": "t"}, + }, + }, + }, + result: &backend.CheckHealthResult{Status: backend.HealthStatusError, Message: "All OAuth fields are mandatory."}, + }, + { + name: "valid User Password Auth And ExtraConfig", request: &backend.CheckHealthRequest{ PluginContext: backend.PluginContext{ DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ @@ -163,8 +245,8 @@ func TestCreateAndValidationConnectionString(t *testing.T) { connectionString: "user:pass@test?database=&role=&schema=&warehouse=&config=conf&validateDefaultParameters=true", }, } - for i, tc := range tcs { - t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) { + for _, tc := range tcs { + t.Run(fmt.Sprintf("testcase %s", tc.name), func(t *testing.T) { con, result := createAndValidationConnectionString(tc.request) if result == nil { require.Equal(t, tc.connectionString, con) @@ -174,3 +256,63 @@ func TestCreateAndValidationConnectionString(t *testing.T) { }) } } + +func TestCreateAndValidationConnectionStringWithOauth(t *testing.T) { + // Mock token endpoint + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer ts.Close() + + req := &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{\"account\":\"test\",\"extraConfig\":\"config=conf\",\"clientId\": \"t\", \"tokenEndpoint\": \"" + ts.URL + "\", \"redirectUrl\": \"redirect\"}"), + DecryptedSecureJSONData: map[string]string{"clientSecret": "t"}, + }, + }, + } + con, result := createAndValidationConnectionString(req) + require.Equal(t, "test?authenticator=oauth&database=&role=&schema=&token=test_access_token&warehouse=&config=conf&validateDefaultParameters=true", con) + require.Nil(t, result) +} + +func TestOauthTokenIssue(t *testing.T) { + // Mock token endpoint + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "invalid_request"}`)) + })) + defer ts.Close() + + req := &backend.CheckHealthRequest{ + PluginContext: backend.PluginContext{ + DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ + JSONData: []byte("{\"account\":\"test\",\"clientId\": \"t\", \"tokenEndpoint\": \"" + ts.URL + "\", \"redirectUrl\": \"redirect\"}"), + DecryptedSecureJSONData: map[string]string{"clientSecret": "t"}, + }, + }, + } + con, result := createAndValidationConnectionString(req) + require.Empty(t, con) + require.Equal(t, result.Status, backend.HealthStatusError) + require.Equal(t, "Error getting token: oauth2: \"invalid_request\"", result.Message) +} + +func TestErrorResultWithMessage(t *testing.T) { + result := createHealthError("Test error message") + require.Equal(t, backend.HealthStatusError, result.Status) + require.Equal(t, "Test error message", result.Message) +} + +func TestErrorResultWithEmptyMessage(t *testing.T) { + result := createHealthError("") + require.Equal(t, backend.HealthStatusError, result.Status) + require.Equal(t, "", result.Message) +} diff --git a/pkg/data/type.go b/pkg/data/type.go index 6a077ce..2b4c88b 100644 --- a/pkg/data/type.go +++ b/pkg/data/type.go @@ -10,7 +10,7 @@ type QueryResult struct { Tables []Table } -// DataTable structure containing columns and rows +// Table structure containing columns and rows type Table struct { Columns []*sql.ColumnType Rows [][]interface{} @@ -53,3 +53,9 @@ type QueryTagGrafanaStruct struct { User string `json:"user,omitempty"` DatasourceId string `json:"datasourceId,omitempty"` } + +type AuthenticationSecret struct { + Password string + PrivateKey string + Token string +} diff --git a/pkg/macros.go b/pkg/macros.go deleted file mode 100644 index d3d1b27..0000000 --- a/pkg/macros.go +++ /dev/null @@ -1,207 +0,0 @@ -package main - -import ( - "fmt" - "github.com/michelin/snowflake-grafana-datasource/pkg/data" - "github.com/michelin/snowflake-grafana-datasource/pkg/utils" - "math" - "regexp" - "strconv" - "strings" - "time" -) - -const rsIdentifier = `([_a-zA-Z0-9]+)` -const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` -const missingColumnMessage = "missing time column argument for macro %v" - -func ReplaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { - result := "" - lastIndex := 0 - - for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { - groups := []string{} - for i := 0; i < len(v); i += 2 { - groups = append(groups, str[v[i]:v[i+1]]) - } - - result += str[lastIndex:v[0]] + repl(groups) - lastIndex = v[1] - } - - return result + str[lastIndex:] -} - -func Interpolate(configStruct *data.QueryConfigStruct) (string, error) { - rExp, _ := regexp.Compile(sExpr) - var macroError error - - sql := ReplaceAllStringSubmatchFunc(rExp, configStruct.RawQuery, func(groups []string) string { - // Don't try to interpolate Snowflake macros SYSTEM$xxxxx - if strings.Contains(configStruct.RawQuery, "SYSTEM"+groups[0]) { - return groups[0] - } - args := strings.Split(groups[2], ",") - for i, arg := range args { - args[i] = strings.Trim(arg, " ") - } - res, err := evaluateMacro(groups[1], args, configStruct) - if err != nil && macroError == nil { - macroError = err - return "macro_error()" - } - return res - }) - - if macroError != nil { - return "", macroError - } - - return sql, nil -} - -func SetupFillmode(configStruct *data.QueryConfigStruct, fillmode string) error { - switch fillmode { - case "NULL": - configStruct.FillMode = NullFill - case "previous": - configStruct.FillMode = PreviousFill - default: - configStruct.FillMode = ValueFill - value, err := strconv.ParseFloat(fillmode, 64) - if err != nil { - return fmt.Errorf("error parsing fill value %v", fillmode) - } - configStruct.FillValue = value - } - - return nil -} - -// evaluateMacro convert macro expression to sql expression -func evaluateMacro(name string, args []string, configStruct *data.QueryConfigStruct) (string, error) { - timeRange := configStruct.TimeRange - - switch name { - case "__time": - if len(args) == 0 || args[0] == "" { - return "", fmt.Errorf(missingColumnMessage, name) - } - return fmt.Sprintf("TRY_TO_TIMESTAMP_NTZ(%s) AS time", args[0]), nil - case "__timeEpoch": - if len(args) == 0 || args[0] == "" { - return "", fmt.Errorf(missingColumnMessage, name) - } - return fmt.Sprintf("extract(epoch from %s) as time", args[0]), nil - case "__timeFilter": - if len(args) == 0 || args[0] == "" { - return "", fmt.Errorf(missingColumnMessage, name) - } - column := args[0] - timezone := "'UTC'" - if len(args) > 1 { - timezone = args[1] - } - return fmt.Sprintf("%s > CONVERT_TIMEZONE('UTC', %s, '%s'::timestamp_ntz) AND %s < CONVERT_TIMEZONE('UTC', %s, '%s'::timestamp_ntz)", column, timezone, timeRange.From.UTC().Format(time.RFC3339Nano), column, timezone, timeRange.To.UTC().Format(time.RFC3339Nano)), nil - case "__timeTzFilter": - if len(args) == 0 || args[0] == "" { - return "", fmt.Errorf(missingColumnMessage, name) - } - column := args[0] - return fmt.Sprintf("%s > '%s'::timestamp_tz AND %s < '%s'::timestamp_tz", column, timeRange.From.UTC().Format(time.RFC3339Nano), column, timeRange.To.UTC().Format(time.RFC3339Nano)), nil - case "__timeFrom": - return fmt.Sprintf("'%s'", timeRange.From.UTC().Format(time.RFC3339Nano)), nil - case "__timeTo": - return fmt.Sprintf("'%s'", timeRange.To.UTC().Format(time.RFC3339Nano)), nil - case "__timeRoundFrom": - //Rounds timestamp to the last 15min by default. First Argument could be passed to have a variable rounding in Minutes. - timeSpan := 15 - if len(args) == 1 && args[0] != "" { - if _, err := strconv.Atoi(args[0]); err == nil { - timeSpan, _ = strconv.Atoi(args[0]) - } else { - return "", fmt.Errorf("macro %v first argument must be a integer", name) - } - if timeSpan <= 0 { - return "", fmt.Errorf("macro %v first argument must be a positive Integer", name) - } - } else if len(args) > 1 { - return "", fmt.Errorf("macro %v only 1 argument allowed", name) - } - return fmt.Sprintf("'%s'", timeRange.From.UTC().Truncate(time.Minute*time.Duration(timeSpan)).Format(time.RFC3339Nano)), nil - case "__timeRoundTo": - timeSpan := 15 - if len(args) == 1 && args[0] != "" { - if _, err := strconv.Atoi(args[0]); err == nil { - timeSpan, _ = strconv.Atoi(args[0]) - } else { - return "", fmt.Errorf("macro %v first argument must be a integer", name) - } - if timeSpan <= 0 { - return "", fmt.Errorf("macro %v first argument must be a positive Integer", name) - } - } else if len(args) > 1 { - return "", fmt.Errorf("macro %v only 1 argument allowed", name) - } - return fmt.Sprintf("'%s'", timeRange.To.UTC().Add(time.Minute*time.Duration(timeSpan)).Truncate(time.Minute*time.Duration(timeSpan)).Format(time.RFC3339Nano)), nil - case "__timeGroup": - if len(args) < 2 { - return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) - } - interval, err := utils.ParseInterval(strings.Trim(args[1], `'`)) - if err != nil { - return "", fmt.Errorf("error parsing interval %v", args[1]) - } - if len(args) == 3 { - err := SetupFillmode(configStruct, args[2]) - if err != nil { - return "", err - } - } - - return fmt.Sprintf("TIME_SLICE(TO_TIMESTAMP_NTZ(%s), %v, 'SECOND', 'START')", args[0], math.Max(1, interval.Seconds())), nil - case "__timeGroupAlias": - tg, err := evaluateMacro("__timeGroup", args, configStruct) - if err == nil { - return tg + " AS time", nil - } - return "", err - case "__unixEpochFilter": - if len(args) == 0 || args[0] == "" { - return "", fmt.Errorf(missingColumnMessage, name) - } - return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], timeRange.From.UTC().Unix(), args[0], timeRange.To.UTC().Unix()), nil - case "__unixEpochNanoFilter": - if len(args) == 0 || args[0] == "" { - return "", fmt.Errorf(missingColumnMessage, name) - } - return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], timeRange.From.UTC().UnixNano(), args[0], timeRange.To.UTC().UnixNano()), nil - case "__unixEpochNanoFrom": - return fmt.Sprintf("%d", timeRange.From.UTC().UnixNano()), nil - case "__unixEpochNanoTo": - return fmt.Sprintf("%d", timeRange.To.UTC().UnixNano()), nil - case "__unixEpochGroup": - if len(args) < 2 { - return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) - } - interval, err := utils.ParseInterval(strings.Trim(args[1], `'`)) - if err != nil { - return "", fmt.Errorf("error parsing interval %v", args[1]) - } - if len(args) == 3 { - err := SetupFillmode(configStruct, args[2]) - if err != nil { - return "", err - } - } - return fmt.Sprintf("floor(%s/%v)*%v", args[0], interval.Seconds(), interval.Seconds()), nil - case "__unixEpochGroupAlias": - tg, err := evaluateMacro("__unixEpochGroup", args, configStruct) - if err == nil { - return tg + " AS time", nil - } - return "", err - default: - return "", fmt.Errorf("unknown macro %q", name) - } -} diff --git a/pkg/main.go b/pkg/main.go index 8d9bcba..fa9248f 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -16,10 +16,7 @@ func main() { // from Grafana to create different instances of SampleDatasource (per datasource // ID). When datasource configuration changed Dispose method will be called and // new datasource instance created using NewSampleDatasource factory. - err := datasource.Serve(newDatasource()) - - // Log any error if we could start the plugin. - if err != nil { + if err := datasource.Manage("michelin-snowflake-datasource", NewDataSourceInstance, datasource.ManageOpts{}); err != nil { log.DefaultLogger.Error(err.Error()) os.Exit(1) } diff --git a/pkg/oauth/oauth.go b/pkg/oauth/oauth.go new file mode 100644 index 0000000..c5a5bbd --- /dev/null +++ b/pkg/oauth/oauth.go @@ -0,0 +1,44 @@ +package oauth + +import ( + "context" + "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +type Oauth struct { + ClientId string + ClientSecret string + TokenEndpoint string +} + +var tokenSource oauth2.TokenSource + +// GetToken retrieves a token from the token endpoint (client credentials flow) +func GetToken(oauth Oauth, recreate bool) (string, error) { + + if oauth.ClientId == "" || oauth.ClientSecret == "" || oauth.TokenEndpoint == "" { + return "", nil + } + + if tokenSource == nil || recreate { + config := &clientcredentials.Config{ + ClientID: oauth.ClientId, + ClientSecret: oauth.ClientSecret, + TokenURL: oauth.TokenEndpoint, + AuthStyle: oauth2.AuthStyleAutoDetect, + } + + // Create a TokenSource that caches and refreshes the token automatically + tokenSource = config.TokenSource(context.Background()) + } + + token, err := tokenSource.Token() + if err != nil { + log.DefaultLogger.Error("Could not get token", "err", err) + return "", err + } + + return token.AccessToken, nil +} diff --git a/pkg/oauth/oauth_test.go b/pkg/oauth/oauth_test.go new file mode 100644 index 0000000..b6af227 --- /dev/null +++ b/pkg/oauth/oauth_test.go @@ -0,0 +1,127 @@ +package oauth + +import ( + "fmt" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetToken(t *testing.T) { + tcs := []struct { + oauth Oauth + }{ + {oauth: Oauth{ClientId: "", ClientSecret: "xxx", TokenEndpoint: "xxx"}}, + {oauth: Oauth{ClientId: "xx", ClientSecret: "", TokenEndpoint: "xxx"}}, + {oauth: Oauth{ClientId: "xx", ClientSecret: "xxx", TokenEndpoint: ""}}, + } + for i, tc := range tcs { + t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) { + code, err := GetToken(tc.oauth, true) + require.Nil(t, err) + require.Empty(t, code) + }) + } +} + +func TestTokenSourceIsRecreatedWhenRequested(t *testing.T) { + var callCount int + // Mock token endpoint + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + callCount++ + })) + defer ts.Close() + + oauth := Oauth{ + ClientId: "test-client-id", + ClientSecret: "test-client-secret", + TokenEndpoint: ts.URL, + } + + // First call to GetToken with recreate = true + token1, err1 := GetToken(oauth, true) + require.NotEmpty(t, token1) + require.NoError(t, err1) + + require.Equal(t, 1, callCount) + + // Second call to GetToken with recreate = true + token2, err2 := GetToken(oauth, true) + require.NotEmpty(t, token2) + require.NoError(t, err2) + + require.Equal(t, 2, callCount) +} + +func TestTokenSourceIsNotRecreatedWhenNotRequested(t *testing.T) { + var callCount int + // Mock token endpoint + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "access_token": "test_access_token", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + callCount++ + })) + defer ts.Close() + + oauth := Oauth{ + ClientId: "test-client-id", + ClientSecret: "test-client-secret", + TokenEndpoint: ts.URL, + } + + // First call to GetToken with recreate = true + token1, err1 := GetToken(oauth, true) + require.NotEmpty(t, token1) + require.NoError(t, err1) + + require.Equal(t, 1, callCount) + + // Second call to GetToken with recreate = false + token2, err2 := GetToken(oauth, false) + require.NotEmpty(t, token2) + require.NoError(t, err2) + + require.Equal(t, 1, callCount) +} + +func TestErrorWhenTokenCannotBeRetrieved(t *testing.T) { + var callCount int + // Mock token endpoint + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "invalid_client"}`)) + callCount++ + })) + defer ts.Close() + oauth := Oauth{ + ClientId: "invalid-client-id", + ClientSecret: "invalid-client-secret", + TokenEndpoint: ts.URL, + } + + // Call GetToken with recreate = true + token, err := GetToken(oauth, true) + require.Empty(t, token) + require.Error(t, err) + + require.Contains(t, err.Error(), "oauth2: \"invalid_client\"") +} + +func TestGetTokenMissingConfiguration(t *testing.T) { + oauth := Oauth{} + token, err := GetToken(oauth, true) + require.Empty(t, token) + require.NoError(t, err) +} diff --git a/pkg/query.go b/pkg/query.go index 45d5795..3294213 100644 --- a/pkg/query.go +++ b/pkg/query.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" _data "github.com/michelin/snowflake-grafana-datasource/pkg/data" + "github.com/michelin/snowflake-grafana-datasource/pkg/query" "github.com/michelin/snowflake-grafana-datasource/pkg/utils" "math/big" "reflect" @@ -27,13 +28,6 @@ var float float64 var str string var integer int64 -// Constant used to describe the time series fill mode if no value has been seen -const ( - NullFill = "null" - PreviousFill = "previous" - ValueFill = "value" -) - type queryModel struct { QueryText string `json:"queryText"` QueryType string `json:"queryType"` @@ -41,8 +35,8 @@ type queryModel struct { FillMode string `json:"fillMode"` } -func fetchData(ctx context.Context, qc *_data.QueryConfigStruct, config *pluginConfig, password string, privateKey string) (result _data.QueryResult, err error) { - connectionString := getConnectionString(config, password, privateKey) +func fetchData(ctx context.Context, qc *_data.QueryConfigStruct, config *pluginConfig, authenticationSecret _data.AuthenticationSecret) (result _data.QueryResult, err error) { + connectionString := getConnectionString(config, authenticationSecret) db, err := sql.Open("snowflake", connectionString) if err != nil { @@ -164,7 +158,7 @@ func transformQueryResult(qc _data.QueryConfigStruct, columnTypes []*sql.ColumnT return values, nil } -func (td *SnowflakeDatasource) query(ctx context.Context, dataQuery backend.DataQuery, request *backend.QueryDataRequest, config pluginConfig, password string, privateKey string) (response backend.DataResponse) { +func (td *SnowflakeDatasource) query(ctx context.Context, dataQuery backend.DataQuery, request *backend.QueryDataRequest, config pluginConfig, authentication _data.AuthenticationSecret) (response backend.DataResponse) { var qm queryModel err := json.Unmarshal(dataQuery.JSON, &qm) if err != nil { @@ -195,7 +189,7 @@ func (td *SnowflakeDatasource) query(ctx context.Context, dataQuery backend.Data log.DefaultLogger.Info("Query config", "config", qm) // Apply macros - queryConfig.FinalQuery, err = Interpolate(&queryConfig) + queryConfig.FinalQuery, err = query.Interpolate(&queryConfig) if err != nil { response.Error = err return response @@ -205,7 +199,7 @@ func (td *SnowflakeDatasource) query(ctx context.Context, dataQuery backend.Data queryConfig.FinalQuery = strings.TrimSuffix(strings.TrimSpace(queryConfig.FinalQuery), ";") frame := data.NewFrame("") - dataResponse, err := fetchData(ctx, &queryConfig, &config, password, privateKey) + dataResponse, err := fetchData(ctx, &queryConfig, &config, authentication) if err != nil { response.Error = err return response @@ -214,9 +208,6 @@ func (td *SnowflakeDatasource) query(ctx context.Context, dataQuery backend.Data for _, table := range dataResponse.Tables { timeColumnIndex := -1 for i, column := range table.Columns { - if err != nil { - return backend.DataResponse{} - } // Check time column if queryConfig.IsTimeSeriesType() && utils.EqualsIgnoreCase(queryConfig.TimeColumns, column.Name()) { if strings.EqualFold(column.Name(), "Time") { @@ -287,7 +278,7 @@ func (td *SnowflakeDatasource) query(ctx context.Context, dataQuery backend.Data func (td *SnowflakeDatasource) longToWide(frame *data.Frame, queryConfig _data.QueryConfigStruct, dataResponse _data.QueryResult) (*data.Frame, error) { tsSchema := frame.TimeSeriesSchema() if tsSchema.Type == data.TimeSeriesTypeLong { - fillMode := &data.FillMissing{Mode: mapFillMode(queryConfig.FillMode), Value: queryConfig.FillValue} + fillMode := &data.FillMissing{Mode: query.MapFillMode(queryConfig.FillMode), Value: queryConfig.FillValue} if len(dataResponse.Tables) > 0 && len(dataResponse.Tables[0].Rows) > 0 { var err error frame, err = data.LongToWide(frame, fillMode) @@ -307,21 +298,6 @@ func (td *SnowflakeDatasource) longToWide(frame *data.Frame, queryConfig _data.Q return frame, nil } -func mapFillMode(fillModeString string) data.FillMode { - var fillMode = data.FillModeNull - switch fillModeString { - case ValueFill: - fillMode = data.FillModeValue - case NullFill: - fillMode = data.FillModeNull - case PreviousFill: - fillMode = data.FillModePrevious - default: - // no-op - } - return fillMode -} - func fillTimesSeries(queryConfig _data.QueryConfigStruct, intervalStart int64, intervalEnd int64, timeColumnIndex int, frame *data.Frame, columnSize int, count *int, previousRow []interface{}) { if queryConfig.IsTimeSeriesType() && queryConfig.FillMode != "" && timeColumnIndex != -1 { for stepTime := intervalStart + queryConfig.Interval.Nanoseconds()/1e6*int64(*count); stepTime < intervalEnd; stepTime = stepTime + (queryConfig.Interval.Nanoseconds() / 1e6) { @@ -332,11 +308,11 @@ func fillTimesSeries(queryConfig _data.QueryConfigStruct, intervalStart int64, i continue } switch queryConfig.FillMode { - case ValueFill: + case query.ValueFill: frame.Fields[i].Append(&queryConfig.FillValue) - case NullFill: + case query.NullFill: frame.Fields[i].Append(nil) - case PreviousFill: + case query.PreviousFill: if previousRow == nil { utils.InsertFrameField(frame, nil, i) } else { diff --git a/pkg/query/fillmode.go b/pkg/query/fillmode.go new file mode 100644 index 0000000..9e39923 --- /dev/null +++ b/pkg/query/fillmode.go @@ -0,0 +1,25 @@ +package query + +import "github.com/grafana/grafana-plugin-sdk-go/data" + +// Constant used to describe the time series fill mode if no value has been seen +const ( + NullFill = "null" + PreviousFill = "previous" + ValueFill = "value" +) + +func MapFillMode(fillModeString string) data.FillMode { + var fillMode = data.FillModeNull + switch fillModeString { + case ValueFill: + fillMode = data.FillModeValue + case NullFill: + fillMode = data.FillModeNull + case PreviousFill: + fillMode = data.FillModePrevious + default: + // no-op + } + return fillMode +} diff --git a/pkg/query/fillmode_test.go b/pkg/query/fillmode_test.go new file mode 100644 index 0000000..6ba8c82 --- /dev/null +++ b/pkg/query/fillmode_test.go @@ -0,0 +1,15 @@ +package query + +import ( + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestMapFillMode(t *testing.T) { + assert.Equal(t, data.FillModeValue, MapFillMode("value")) + assert.Equal(t, data.FillModeNull, MapFillMode("null")) + assert.Equal(t, data.FillModePrevious, MapFillMode("previous")) + assert.Equal(t, data.FillModeNull, MapFillMode("unknown")) + assert.Equal(t, data.FillModeNull, MapFillMode("")) +} diff --git a/pkg/query/macros.go b/pkg/query/macros.go new file mode 100644 index 0000000..95f9517 --- /dev/null +++ b/pkg/query/macros.go @@ -0,0 +1,267 @@ +package query + +import ( + "fmt" + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/michelin/snowflake-grafana-datasource/pkg/data" + "github.com/michelin/snowflake-grafana-datasource/pkg/utils" + "math" + "regexp" + "strconv" + "strings" + "time" +) + +const rsIdentifier = `([_a-zA-Z0-9]+)` +const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)` +const missingColumnMessage = "missing time column argument for macro %v" + +func ReplaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string { + result := "" + lastIndex := 0 + + for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) { + groups := []string{} + for i := 0; i < len(v); i += 2 { + groups = append(groups, str[v[i]:v[i+1]]) + } + + result += str[lastIndex:v[0]] + repl(groups) + lastIndex = v[1] + } + + return result + str[lastIndex:] +} + +func Interpolate(configStruct *data.QueryConfigStruct) (string, error) { + rExp, _ := regexp.Compile(sExpr) + var macroError error + + sql := ReplaceAllStringSubmatchFunc(rExp, configStruct.RawQuery, func(groups []string) string { + // Don't try to interpolate Snowflake macros SYSTEM$xxxxx + if strings.Contains(configStruct.RawQuery, "SYSTEM"+groups[0]) { + return groups[0] + } + args := strings.Split(groups[2], ",") + for i, arg := range args { + args[i] = strings.Trim(arg, " ") + } + res, err := evaluateMacro(groups[1], args, configStruct) + if err != nil && macroError == nil { + macroError = err + return "macro_error()" + } + return res + }) + + if macroError != nil { + return "", macroError + } + + return sql, nil +} + +func SetupFillmode(configStruct *data.QueryConfigStruct, fillmode string) error { + switch fillmode { + case "NULL": + configStruct.FillMode = NullFill + case "previous": + configStruct.FillMode = PreviousFill + default: + configStruct.FillMode = ValueFill + value, err := strconv.ParseFloat(fillmode, 64) + if err != nil { + return fmt.Errorf("error parsing fill value %v", fillmode) + } + configStruct.FillValue = value + } + + return nil +} + +// evaluateMacro convert macro expression to sql expression +func evaluateMacro(name string, args []string, configStruct *data.QueryConfigStruct) (string, error) { + switch name { + case "__time": + return handleTimeMacro(args, name) + case "__timeEpoch": + return handleTimeEpochMacro(args, name) + case "__timeFilter": + return handleTimeFilterMacro(args, configStruct.TimeRange, name) + case "__timeTzFilter": + return handleTimeTzFilterMacro(args, configStruct.TimeRange, name) + case "__timeFrom": + return handleTimeFromMacro(configStruct.TimeRange) + case "__timeTo": + return handleTimeToMacro(configStruct.TimeRange) + case "__timeRoundFrom": + return handleTimeRoundFromMacro(args, configStruct.TimeRange, name) + case "__timeRoundTo": + return handleTimeRoundToMacro(args, configStruct.TimeRange, name) + case "__timeGroup": + return handleTimeGroupMacro(args, configStruct, name) + case "__timeGroupAlias": + return handleTimeGroupAliasMacro(args, configStruct) + case "__unixEpochFilter": + return handleUnixEpochFilterMacro(args, configStruct.TimeRange, name) + case "__unixEpochNanoFilter": + return handleUnixEpochNanoFilterMacro(args, configStruct.TimeRange, name) + case "__unixEpochNanoFrom": + return handleUnixEpochNanoFromMacro(configStruct.TimeRange) + case "__unixEpochNanoTo": + return handleUnixEpochNanoToMacro(configStruct.TimeRange) + case "__unixEpochGroup": + return handleUnixEpochGroupMacro(args, configStruct, name) + case "__unixEpochGroupAlias": + return handleUnixEpochGroupAliasMacro(args, configStruct) + default: + return "", fmt.Errorf("unknown macro %q", name) + } +} + +func handleTimeMacro(args []string, name string) (string, error) { + if len(args) == 0 || args[0] == "" { + return "", fmt.Errorf(missingColumnMessage, name) + } + return fmt.Sprintf("TRY_TO_TIMESTAMP_NTZ(%s) AS time", args[0]), nil +} + +func handleTimeEpochMacro(args []string, name string) (string, error) { + if len(args) == 0 || args[0] == "" { + return "", fmt.Errorf(missingColumnMessage, name) + } + return fmt.Sprintf("extract(epoch from %s) as time", args[0]), nil +} + +func handleTimeFilterMacro(args []string, timeRange backend.TimeRange, name string) (string, error) { + if len(args) == 0 || args[0] == "" { + return "", fmt.Errorf(missingColumnMessage, name) + } + column := args[0] + timezone := "'UTC'" + if len(args) > 1 { + timezone = args[1] + } + return fmt.Sprintf("%s > CONVERT_TIMEZONE('UTC', %s, '%s'::timestamp_ntz) AND %s < CONVERT_TIMEZONE('UTC', %s, '%s'::timestamp_ntz)", column, timezone, timeRange.From.UTC().Format(time.RFC3339Nano), column, timezone, timeRange.To.UTC().Format(time.RFC3339Nano)), nil +} + +func handleTimeTzFilterMacro(args []string, timeRange backend.TimeRange, name string) (string, error) { + if len(args) == 0 || args[0] == "" { + return "", fmt.Errorf(missingColumnMessage, name) + } + column := args[0] + return fmt.Sprintf("%s > '%s'::timestamp_tz AND %s < '%s'::timestamp_tz", column, timeRange.From.UTC().Format(time.RFC3339Nano), column, timeRange.To.UTC().Format(time.RFC3339Nano)), nil +} + +func handleTimeFromMacro(timeRange backend.TimeRange) (string, error) { + return fmt.Sprintf("'%s'", timeRange.From.UTC().Format(time.RFC3339Nano)), nil +} + +func handleTimeToMacro(timeRange backend.TimeRange) (string, error) { + return fmt.Sprintf("'%s'", timeRange.To.UTC().Format(time.RFC3339Nano)), nil +} + +func handleTimeRoundFromMacro(args []string, timeRange backend.TimeRange, name string) (string, error) { + timeSpan, err := parseTimeSpan(args, name) + if err != nil { + return "", err + } + return fmt.Sprintf("'%s'", timeRange.From.UTC().Truncate(time.Minute*time.Duration(timeSpan)).Format(time.RFC3339Nano)), nil +} + +func handleTimeRoundToMacro(args []string, timeRange backend.TimeRange, name string) (string, error) { + timeSpan, err := parseTimeSpan(args, name) + if err != nil { + return "", err + } + return fmt.Sprintf("'%s'", timeRange.To.UTC().Add(time.Minute*time.Duration(timeSpan)).Truncate(time.Minute*time.Duration(timeSpan)).Format(time.RFC3339Nano)), nil +} + +func handleTimeGroupMacro(args []string, configStruct *data.QueryConfigStruct, name string) (string, error) { + if len(args) < 2 { + return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) + } + interval, err := utils.ParseInterval(strings.Trim(args[1], `'`)) + if err != nil { + return "", fmt.Errorf("error parsing interval %v", args[1]) + } + if len(args) == 3 { + err := SetupFillmode(configStruct, args[2]) + if err != nil { + return "", err + } + } + return fmt.Sprintf("TIME_SLICE(TO_TIMESTAMP_NTZ(%s), %v, 'SECOND', 'START')", args[0], math.Max(1, interval.Seconds())), nil +} + +func handleTimeGroupAliasMacro(args []string, configStruct *data.QueryConfigStruct) (string, error) { + tg, err := handleTimeGroupMacro(args, configStruct, "__timeGroup") + if err == nil { + return tg + " AS time", nil + } + return "", err +} + +func handleUnixEpochFilterMacro(args []string, timeRange backend.TimeRange, name string) (string, error) { + if len(args) == 0 || args[0] == "" { + return "", fmt.Errorf(missingColumnMessage, name) + } + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], timeRange.From.UTC().Unix(), args[0], timeRange.To.UTC().Unix()), nil +} + +func handleUnixEpochNanoFilterMacro(args []string, timeRange backend.TimeRange, name string) (string, error) { + if len(args) == 0 || args[0] == "" { + return "", fmt.Errorf(missingColumnMessage, name) + } + return fmt.Sprintf("%s >= %d AND %s <= %d", args[0], timeRange.From.UTC().UnixNano(), args[0], timeRange.To.UTC().UnixNano()), nil +} + +func handleUnixEpochNanoFromMacro(timeRange backend.TimeRange) (string, error) { + return fmt.Sprintf("%d", timeRange.From.UTC().UnixNano()), nil +} + +func handleUnixEpochNanoToMacro(timeRange backend.TimeRange) (string, error) { + return fmt.Sprintf("%d", timeRange.To.UTC().UnixNano()), nil +} + +func handleUnixEpochGroupMacro(args []string, configStruct *data.QueryConfigStruct, name string) (string, error) { + if len(args) < 2 { + return "", fmt.Errorf("macro %v needs time column and interval and optional fill value", name) + } + interval, err := utils.ParseInterval(strings.Trim(args[1], `'`)) + if err != nil { + return "", fmt.Errorf("error parsing interval %v", args[1]) + } + if len(args) == 3 { + err := SetupFillmode(configStruct, args[2]) + if err != nil { + return "", err + } + } + return fmt.Sprintf("floor(%s/%v)*%v", args[0], interval.Seconds(), interval.Seconds()), nil +} + +func handleUnixEpochGroupAliasMacro(args []string, configStruct *data.QueryConfigStruct) (string, error) { + tg, err := handleUnixEpochGroupMacro(args, configStruct, "__unixEpochGroup") + if err == nil { + return tg + " AS time", nil + } + return "", err +} + +func parseTimeSpan(args []string, name string) (int, error) { + timeSpan := 15 + if len(args) == 1 && args[0] != "" { + if _, err := strconv.Atoi(args[0]); err == nil { + timeSpan, _ = strconv.Atoi(args[0]) + } else { + return 0, fmt.Errorf("macro %v first argument must be a integer", name) + } + if timeSpan <= 0 { + return 0, fmt.Errorf("macro %v first argument must be a positive Integer", name) + } + } else if len(args) > 1 { + return 0, fmt.Errorf("macro %v only 1 argument allowed", name) + } + return timeSpan, nil +} diff --git a/pkg/macros_test.go b/pkg/query/macros_test.go similarity index 99% rename from pkg/macros_test.go rename to pkg/query/macros_test.go index a1e8e29..4bcad40 100644 --- a/pkg/macros_test.go +++ b/pkg/query/macros_test.go @@ -1,4 +1,4 @@ -package main +package query import ( "fmt" diff --git a/pkg/query_test.go b/pkg/query_test.go index 6689da2..c695926 100644 --- a/pkg/query_test.go +++ b/pkg/query_test.go @@ -3,6 +3,7 @@ package main import ( "github.com/grafana/grafana-plugin-sdk-go/data" _data "github.com/michelin/snowflake-grafana-datasource/pkg/data" + "github.com/michelin/snowflake-grafana-datasource/pkg/query" sf "github.com/snowflakedb/gosnowflake" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -25,29 +26,12 @@ func TestIsTimeSeriesType_FalseWhenQueryTypeIsEmpty(t *testing.T) { assert.False(t, qc.IsTimeSeriesType()) } -// Helper functions to create pointers -func timePtr(t time.Time) *time.Time { - return &t -} - -func float64Ptr(f float64) *float64 { - return &f -} - -func TestMapFillMode(t *testing.T) { - assert.Equal(t, data.FillModeValue, mapFillMode("value")) - assert.Equal(t, data.FillModeNull, mapFillMode("null")) - assert.Equal(t, data.FillModePrevious, mapFillMode("previous")) - assert.Equal(t, data.FillModeNull, mapFillMode("unknown")) - assert.Equal(t, data.FillModeNull, mapFillMode("")) -} - func TestFillTimesSeries_AppendsCorrectTimeValues(t *testing.T) { frame := data.NewFrame("") frame.Fields = append(frame.Fields, data.NewField("time", nil, []*time.Time{})) queryConfig := _data.QueryConfigStruct{ Interval: time.Minute, - FillMode: NullFill, + FillMode: query.NullFill, QueryType: _data.TimeSeriesType, } fillTimesSeries(queryConfig, 0, 60000, 0, frame, 1, new(int), nil) @@ -61,7 +45,7 @@ func TestFillTimesSeries_AppendsFillValue(t *testing.T) { frame.Fields = append(frame.Fields, data.NewField("value", nil, []*float64{})) queryConfig := _data.QueryConfigStruct{ Interval: time.Minute, - FillMode: ValueFill, + FillMode: query.ValueFill, FillValue: 42.0, QueryType: _data.TimeSeriesType, } @@ -76,7 +60,7 @@ func TestFillTimesSeries_AppendsNilForNullFill(t *testing.T) { frame.Fields = append(frame.Fields, data.NewField("value", nil, []*float64{})) queryConfig := _data.QueryConfigStruct{ Interval: time.Minute, - FillMode: NullFill, + FillMode: query.NullFill, QueryType: _data.TimeSeriesType, } fillTimesSeries(queryConfig, 0, 60000, 0, frame, 2, new(int), nil) @@ -90,7 +74,7 @@ func TestFillTimesSeries_AppendsPreviousValue(t *testing.T) { frame.Fields = append(frame.Fields, data.NewField("value", nil, []*float64{})) queryConfig := _data.QueryConfigStruct{ Interval: time.Minute, - FillMode: PreviousFill, + FillMode: query.PreviousFill, QueryType: _data.TimeSeriesType, } previousRow := []interface{}{time.Unix(0, 0), 42.0} @@ -105,7 +89,7 @@ func TestFillTimesSeries_DoesNotAppendWhenNotTimeSeries(t *testing.T) { frame.Fields = append(frame.Fields, data.NewField("value", nil, []*float64{})) queryConfig := _data.QueryConfigStruct{ Interval: time.Minute, - FillMode: NullFill, + FillMode: query.NullFill, QueryType: "table", } fillTimesSeries(queryConfig, 0, 60000, 1, frame, 2, new(int), nil) @@ -118,7 +102,7 @@ func TestAppendsNilWhenPreviousRowIsNil(t *testing.T) { frame.Fields = append(frame.Fields, data.NewField("value", nil, []*float64{})) queryConfig := _data.QueryConfigStruct{ Interval: time.Minute, - FillMode: PreviousFill, + FillMode: query.PreviousFill, QueryType: _data.TimeSeriesType, } fillTimesSeries(queryConfig, 0, 60000, 0, frame, 2, new(int), nil) @@ -132,13 +116,13 @@ func TestMaxChunkDownloadWorkers(t *testing.T) { } t.Run("valid MaxChunkDownloadWorkers", func(t *testing.T) { - getConnectionString(&config, "", "") + getConnectionString(&config, _data.AuthenticationSecret{}) require.Equal(t, 5, sf.MaxChunkDownloadWorkers) }) t.Run("invalid MaxChunkDownloadWorkers", func(t *testing.T) { config.MaxChunkDownloadWorkers = "invalid" - getConnectionString(&config, "", "") + getConnectionString(&config, _data.AuthenticationSecret{}) require.NotEqual(t, 5, sf.MaxChunkDownloadWorkers) }) } @@ -149,13 +133,13 @@ func TestCustomJSONDecoderEnabled(t *testing.T) { } t.Run("CustomJSONDecoderEnabled true", func(t *testing.T) { - getConnectionString(&config, "", "") + getConnectionString(&config, _data.AuthenticationSecret{}) require.True(t, sf.CustomJSONDecoderEnabled) }) t.Run("CustomJSONDecoderEnabled false", func(t *testing.T) { config.CustomJSONDecoderEnabled = false - getConnectionString(&config, "", "") + getConnectionString(&config, _data.AuthenticationSecret{}) require.False(t, sf.CustomJSONDecoderEnabled) }) } diff --git a/pkg/snowflake.go b/pkg/snowflake.go index b627bad..fe26f77 100644 --- a/pkg/snowflake.go +++ b/pkg/snowflake.go @@ -5,10 +5,11 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/michelin/snowflake-grafana-datasource/pkg/data" + _oauth "github.com/michelin/snowflake-grafana-datasource/pkg/oauth" "strconv" "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" "github.com/grafana/grafana-plugin-sdk-go/backend/log" @@ -16,27 +17,11 @@ import ( "net/url" ) -// newDatasource returns datasource.ServeOpts. -func newDatasource() datasource.ServeOpts { - // creates a instance manager for your plugin. The function passed - // into `NewInstanceManger` is called when the instance is created - // for the first time or when a datasource configuration changed. - im := datasource.NewInstanceManager(newDataSourceInstance) - ds := &SnowflakeDatasource{ - im: im, - } - - return datasource.ServeOpts{ - QueryDataHandler: ds, - CheckHealthHandler: ds, - } -} +var ( + _ backend.QueryDataHandler = (*SnowflakeDatasource)(nil) +) type SnowflakeDatasource struct { - // The instance manager can help with lifecycle management - // of datasource instances in plugins. It's not a requirements - // but a best practice that we recommend that you follow. - im instancemgmt.InstanceManager db *sql.DB } @@ -49,20 +34,36 @@ func (td *SnowflakeDatasource) QueryData(ctx context.Context, req *backend.Query // create response struct response := backend.NewQueryDataResponse() + config, err := getConfig(req.PluginContext.DataSourceInstanceSettings) + if err != nil { + log.DefaultLogger.Error("Could not get config for plugin", "err", err) + return response, err + } + password := req.PluginContext.DataSourceInstanceSettings.DecryptedSecureJSONData["password"] privateKey := req.PluginContext.DataSourceInstanceSettings.DecryptedSecureJSONData["privateKey"] + oauth := _oauth.Oauth{ + ClientId: config.ClientId, + ClientSecret: req.PluginContext.DataSourceInstanceSettings.DecryptedSecureJSONData["clientSecret"], + TokenEndpoint: config.TokenEndpoint, + } - config, err := getConfig(req.PluginContext.DataSourceInstanceSettings) + token, err := _oauth.GetToken(oauth, false) if err != nil { - log.DefaultLogger.Error("Could not get config for plugin", "err", err) return response, err } + authenticationSecret := data.AuthenticationSecret{ + Password: password, + PrivateKey: privateKey, + Token: token, + } + // loop over queries and execute them individually. for _, q := range req.Queries { // save the response in a hashmap // based on with RefID as identifier - response.Responses[q.RefID] = td.query(ctx, q, req, config, password, privateKey) + response.Responses[q.RefID] = td.query(ctx, q, req, config, authenticationSecret) } return response, nil @@ -78,6 +79,9 @@ type pluginConfig struct { ExtraConfig string `json:"extraConfig"` MaxChunkDownloadWorkers string `json:"maxChunkDownloadWorkers"` CustomJSONDecoderEnabled bool `json:"customJSONDecoderEnabled"` + ClientId string `json:"clientId"` + TokenEndpoint string `json:"tokenEndpoint"` + RedirectUrl string `json:"redirectUrl"` } func getConfig(settings *backend.DataSourceInstanceSettings) (pluginConfig, error) { @@ -89,7 +93,7 @@ func getConfig(settings *backend.DataSourceInstanceSettings) (pluginConfig, erro return config, nil } -func getConnectionString(config *pluginConfig, password string, privateKey string) string { +func getConnectionString(config *pluginConfig, authenticationSecret data.AuthenticationSecret) string { params := url.Values{} params.Add("role", config.Role) params.Add("warehouse", config.Warehouse) @@ -106,25 +110,25 @@ func getConnectionString(config *pluginConfig, password string, privateKey strin sf.CustomJSONDecoderEnabled = config.CustomJSONDecoderEnabled var userPass = "" - if len(privateKey) != 0 { + if len(authenticationSecret.PrivateKey) != 0 { params.Add("authenticator", "SNOWFLAKE_JWT") - params.Add("privateKey", privateKey) - userPass = url.QueryEscape(config.Username) + params.Add("privateKey", authenticationSecret.PrivateKey) + userPass = url.QueryEscape(config.Username) + "@" + } else if len(authenticationSecret.Token) != 0 { + params.Add("authenticator", "oauth") + params.Add("token", authenticationSecret.Token) } else { - userPass = url.QueryEscape(config.Username) + ":" + url.QueryEscape(password) + userPass = url.QueryEscape(config.Username) + ":" + url.QueryEscape(authenticationSecret.Password) + "@" } - - return fmt.Sprintf("%s@%s?%s&%s", userPass, config.Account, params.Encode(), config.ExtraConfig) -} - -type instanceSettings struct { + return fmt.Sprintf("%s%s?%s&%s", userPass, config.Account, params.Encode(), config.ExtraConfig) } -func newDataSourceInstance(ctx context.Context, setting backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { +func NewDataSourceInstance(ctx context.Context, setting backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { log.DefaultLogger.Info("Creating instance") - return &instanceSettings{}, nil + datasource := &SnowflakeDatasource{} + return datasource, nil } -func (s *instanceSettings) Dispose() { +func (s *SnowflakeDatasource) Dispose() { log.DefaultLogger.Info("Disposing of instance") } diff --git a/pkg/snowflake_test.go b/pkg/snowflake_test.go index 6b2cdcb..a07e8d3 100644 --- a/pkg/snowflake_test.go +++ b/pkg/snowflake_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/michelin/snowflake-grafana-datasource/pkg/data" sf "github.com/snowflakedb/gosnowflake" "github.com/stretchr/testify/require" "testing" @@ -51,20 +52,25 @@ func TestGetConnectionString(t *testing.T) { } t.Run("with User/pass", func(t *testing.T) { - connectionString := getConnectionString(&config, "password", "") + connectionString := getConnectionString(&config, data.AuthenticationSecret{Password: "password", PrivateKey: "", Token: ""}) require.Equal(t, "username:password@account?database=database&role=role&schema=schema&warehouse=warehouse&conf=xxx", connectionString) }) t.Run("with private key", func(t *testing.T) { - connectionString := getConnectionString(&config, "", "privateKey") + connectionString := getConnectionString(&config, data.AuthenticationSecret{Password: "", PrivateKey: "privateKey", Token: ""}) require.Equal(t, "username@account?authenticator=SNOWFLAKE_JWT&database=database&privateKey=privateKey&role=role&schema=schema&warehouse=warehouse&conf=xxx", connectionString) }) t.Run("with User/pass special char", func(t *testing.T) { - connectionString := getConnectionString(&config, "p@sswor/d", "") + connectionString := getConnectionString(&config, data.AuthenticationSecret{Password: "p@sswor/d", PrivateKey: "", Token: ""}) require.Equal(t, "username:p%40sswor%2Fd@account?database=database&role=role&schema=schema&warehouse=warehouse&conf=xxx", connectionString) }) + t.Run("with token", func(t *testing.T) { + connectionString := getConnectionString(&config, data.AuthenticationSecret{Password: "", PrivateKey: "", Token: "xxxxxxxx"}) + require.Equal(t, "account?authenticator=oauth&database=database&role=role&schema=schema&token=xxxxxxxx&warehouse=warehouse&conf=xxx", connectionString) + }) + config = pluginConfig{ Account: "account", // account not escaped, can't have special chars Database: "dat@base", @@ -77,7 +83,7 @@ func TestGetConnectionString(t *testing.T) { t.Run("with string to escape", func(t *testing.T) { passwordIn := "pa$$s+&" - connectionString := getConnectionString(&config, passwordIn, "") + connectionString := getConnectionString(&config, data.AuthenticationSecret{Password: passwordIn, PrivateKey: "", Token: ""}) require.Equal(t, "user%40name:pa%24%24s%2B%26@account?database=dat%40base&role=ro%40le&schema=sch%40ema&warehouse=ware%40house&conf=xxx", connectionString) dsnParsed, err := sf.ParseDSN(connectionString) @@ -90,13 +96,13 @@ func TestGetConnectionString(t *testing.T) { func TestCreatesNewDataSourceInstance(t *testing.T) { settings := backend.DataSourceInstanceSettings{} - instance, err := newDataSourceInstance(context.Background(), settings) + instance, err := NewDataSourceInstance(context.Background(), settings) require.NoError(t, err) require.NotNil(t, instance) } func TestDisposesInstanceWithoutError(t *testing.T) { - instance := &instanceSettings{} + instance := &SnowflakeDatasource{} require.NotPanics(t, func() { instance.Dispose() }) diff --git a/pkg/utils/queryInfo.go b/pkg/utils/queryInfo.go index 679508d..7ba07bb 100644 --- a/pkg/utils/queryInfo.go +++ b/pkg/utils/queryInfo.go @@ -24,7 +24,7 @@ func AddQueryTagInfos(ctx context.Context, qc *data.QueryConfigStruct) context.C // Grafana Host var grafanaHost = "" if pluginConfig.GrafanaConfig != nil { - grafanaHost = pluginConfig.GrafanaConfig.Get("GF_APP_URL") + grafanaHost = pluginConfig.GrafanaConfig.Get(backend.AppURL) } // Datasource ID diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 6a3eefd..9303b12 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -132,3 +132,18 @@ func TestAppendsTimeValueToFrameField(t *testing.T) { InsertFrameField(frame, value, 0) require.Equal(t, &value, frame.Fields[0].At(0)) } + +func TestAppendsNilValueToFrameField(t *testing.T) { + frame := data.NewFrame("test") + frame.Fields = append(frame.Fields, data.NewField("field1", nil, []*time.Time{})) + InsertFrameField(frame, nil, 0) + require.Nil(t, frame.Fields[0].At(0)) +} + +func TestAppendsUnsupportedTypeToFrameField(t *testing.T) { + frame := data.NewFrame("test") + frame.Fields = append(frame.Fields, data.NewField("field1", nil, []*time.Time{})) + unsupportedValue := struct{}{} + InsertFrameField(frame, unsupportedValue, 0) + require.Nil(t, frame.Fields[0].At(0)) +} diff --git a/src/ConfigEditor.tsx b/src/ConfigEditor.tsx index 5ae657f..39326fc 100644 --- a/src/ConfigEditor.tsx +++ b/src/ConfigEditor.tsx @@ -1,37 +1,77 @@ -import React, { ChangeEvent, PureComponent } from 'react'; +import React, {ChangeEvent, PureComponent} from 'react'; import { Checkbox, ControlledCollapse, InlineField, Input, + RadioButtonGroup, SecretInput, SecretTextArea, - Switch } from '@grafana/ui'; -import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; -import { SnowflakeOptions, SnowflakeSecureOptions } from './types'; +import {DataSourcePluginOptionsEditorProps} from '@grafana/data'; +import {SnowflakeOptions, SnowflakeSecureOptions} from './types'; interface Props extends DataSourcePluginOptionsEditorProps {} -interface State {} +interface State { + authMethod: string; +} + +const authOptions = [ + { label: 'Password', value: 'password' }, + { label: 'Key Pair', value: 'keyPair' }, + { label: 'OAuth', value: 'oauth' }, +]; const LABEL_WIDTH = 30 -const INPUT_WIDTH = 40 +const INPUT_WIDTH = 50 export class ConfigEditor extends PureComponent { + + constructor(props: Props) { + super(props); + this.state = { + authMethod: this.props.options.jsonData.authMethod ?? authOptions[0].value, + }; + } + + onAuthMethodChange = (value: string) => { + const { onOptionsChange, options } = this.props; + const authMethod = value ?? 'password'; + this.setState({ authMethod: authMethod }); + const jsonData = { + ...options.jsonData, + authMethod, + }; + + onOptionsChange({ + ...options, + jsonData, + secureJsonFields: { + ...options.secureJsonFields, + password: false, + privateKey: false, + clientSecret: false, + }, + secureJsonData: { + ...options.secureJsonData, + password: '', + privateKey: '', + clientSecret: '', + }, + }); + }; + onAccountChange = (event: ChangeEvent) => { const { onOptionsChange, options } = this.props; - let value; - if (event.target.value.includes('.snowflakecomputing.com')) { - value = event.target.value; - } else { - value = event.target.value + '.snowflakecomputing.com'; + let value = event.target.value.trim(); + if (!value.includes('.snowflakecomputing.com')) { + value += '.snowflakecomputing.com'; } // Sanitize value to avoid error - const regex = /https?:\/\//; - value = value.replace(regex, ''); + value = value.replace(/^https?:\/\//, ''); const jsonData = { ...options.jsonData, @@ -103,15 +143,6 @@ export class ConfigEditor extends PureComponent { onOptionsChange({ ...options, jsonData }); }; - onAuthenticationChange = (event: React.SyntheticEvent) => { - const { onOptionsChange, options } = this.props; - const jsonData = { - ...options.jsonData, - basicAuth: (event.target as HTMLInputElement).checked, - }; - onOptionsChange({ ...options, jsonData }); - }; - onSchemaChange = (event: ChangeEvent) => { const { onOptionsChange, options } = this.props; const jsonData = { @@ -127,8 +158,8 @@ export class ConfigEditor extends PureComponent { onOptionsChange({ ...options, secureJsonData: { + ...options.secureJsonData, password: event.target.value, - privateKey: '', }, }); }; @@ -150,11 +181,25 @@ export class ConfigEditor extends PureComponent { onPrivateKeyChange = (event: ChangeEvent) => { const { onOptionsChange, options } = this.props; + let privateKey = event.target.value; + + // If the private key is not in the correct format, try to convert it + if (!/^[A-Za-z0-9\-_=]+$/.test(privateKey) && privateKey !== '') { + + // Remove the PEM header and footer + privateKey = privateKey.replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, ''); + + // Remove all newline and space characters + privateKey = privateKey.replace(/\n|\r|\s/g, ''); + + // Replace + with - and / with _ + privateKey = privateKey.replace(/\+/g, '-').replace(/\//g, '_'); + } onOptionsChange({ ...options, secureJsonData: { - privateKey: event.target.value, - password: '', + ...options.secureJsonData, + privateKey: privateKey, }, }); }; @@ -174,46 +219,95 @@ export class ConfigEditor extends PureComponent { }); }; + onClientIdChange = (event: ChangeEvent) => { + const { onOptionsChange, options } = this.props; + const jsonData = { + ...options.jsonData, + clientId: event.target.value, + }; + onOptionsChange({ ...options, jsonData }); + }; + + onTokenEndpointChange = (event: ChangeEvent) => { + const { onOptionsChange, options } = this.props; + const jsonData = { + ...options.jsonData, + tokenEndpoint: event.target.value, + }; + onOptionsChange({ ...options, jsonData }); + }; + + onClientSecretChange = (event: ChangeEvent) => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + secureJsonData: { + ...options.secureJsonData, + clientSecret: event.target.value, + }, + }); + }; + + onResetClientSecret = () => { + const { onOptionsChange, options } = this.props; + onOptionsChange({ + ...options, + secureJsonFields: { + ...options.secureJsonFields, + clientSecret: false, + }, + secureJsonData: { + ...options.secureJsonData, + clientSecret: '' + }, + }); + }; + render() { const { options } = this.props; const { jsonData, secureJsonFields } = options; - const secureJsonData = (options.secureJsonData || {}) as SnowflakeSecureOptions; + const secureJsonData = (options.secureJsonData ?? {}) as SnowflakeSecureOptions; + const { authMethod } = this.state; return ( - - Connection + + Connection - - - - - - - - - - {!jsonData.basicAuth && ( - + + + + + + + + + { authMethod !== 'oauth' && ( + + + + )} + {authMethod === 'password' && ( + { onReset={this.onResetPassword} onChange={this.onPasswordChange} /> - - )} - {jsonData.basicAuth && ( - + + )} + {authMethod === 'keyPair' && ( + { cols={38} rows={5} /> + + )} + {authMethod === 'oauth' && ( + + + - )} - - - - - Parameter configuration + + + + + + + + )} + + + + + Parameter configuration - + - + - + - + - - - - - Session configuration + + + + + Session configuration - + + + + + - - - - - - - - + + + - - - + + + ); } } diff --git a/src/QueryEditor.tsx b/src/QueryEditor.tsx index fb93478..d0ba495 100644 --- a/src/QueryEditor.tsx +++ b/src/QueryEditor.tsx @@ -1,11 +1,11 @@ import defaults from 'lodash/defaults'; -import React, { PureComponent } from 'react'; -import { Select, TagsInput, InlineFormLabel, CodeEditor, Field, Button, RadioButtonGroup } from '@grafana/ui'; -import { QueryEditorProps, SelectableValue } from '@grafana/data'; -import { DataSource } from './datasource'; -import { defaultQuery, SnowflakeOptions, SnowflakeQuery } from './types'; -import { format } from 'sql-formatter' +import React, {PureComponent} from 'react'; +import {Button, CodeEditor, Field, InlineFormLabel, RadioButtonGroup, Select, TagsInput} from '@grafana/ui'; +import {QueryEditorProps, SelectableValue} from '@grafana/data'; +import {DataSource} from './datasource'; +import {defaultQuery, SnowflakeOptions, SnowflakeQuery} from './types'; +import {format} from 'sql-formatter' type Props = QueryEditorProps; @@ -69,7 +69,7 @@ export class QueryEditor extends PureComponent { const {onChange, query} = this.props; onChange({ ...query, - fillMode: value || this.optionsFillMode[0].value, + fillMode: value ?? this.optionsFillMode[0].value, }); this.props.onRunQuery(); }; @@ -78,7 +78,7 @@ export class QueryEditor extends PureComponent { render() { const query = defaults(this.props.query, defaultQuery); const { queryText, queryType, fillMode, timeColumns } = query; - const selectedOption = this.options.find((options) => options.value === queryType) || this.options; + const selectedOption = this.options.find((options) => options.value === queryType) ?? this.options; const selectedFillMode = this.optionsFillMode.find((options) => options.value === fillMode)?.value ?? this.optionsFillMode[0].value; return ( diff --git a/src/types.ts b/src/types.ts index 4d0af91..1a2085f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,9 +28,14 @@ export interface SnowflakeOptions extends DataSourceJsonData { database?: string; schema?: string; extraConfig?: string; + /** @deprecated use authMethod */ basicAuth: boolean; maxChunkDownloadWorkers?: string; customJSONDecoderEnabled: boolean; + authMethod: string; + clientId?: string; + tokenEndpoint?: string; + redirectUrl?: string; } /** @@ -39,4 +44,5 @@ export interface SnowflakeOptions extends DataSourceJsonData { export interface SnowflakeSecureOptions { password?: string; privateKey?: string; + clientSecret?: string; }