diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 49c1504b884..cf514299466 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -94,7 +94,6 @@ /apps/shorturl/ @grafana/sharing-squad /apps/secret/ @grafana/grafana-operator-experience-squad /apps/scope/ @grafana/grafana-operator-experience-squad -/apps/investigations/ @fcjack @matryer @svennergr /apps/advisor/ @grafana/plugins-platform-backend /apps/iam/ @grafana/access-squad /apps/sdk.mk @grafana/grafana-app-platform-squad diff --git a/.github/actions/change-detection/action.yml b/.github/actions/change-detection/action.yml index 2b8484d7cf6..1fd10ae5540 100644 --- a/.github/actions/change-detection/action.yml +++ b/.github/actions/change-detection/action.yml @@ -14,6 +14,9 @@ outputs: frontend: description: Whether the frontend or self has changed in any way value: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }} + frontend-packages: + description: Whether any frontend packages have changed + value: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }} e2e: description: Whether the e2e tests or self have changed in any way value: ${{ steps.changed-files.outputs.e2e_any_changed == 'true' || @@ -97,6 +100,12 @@ runs: - '.yarn/**' - 'apps/dashboard/pkg/migration/**' - '${{ inputs.self }}' + frontend_packages: + - '.github/actions/checkout/**' + - '.github/actions/change-detection/**' + - 'packages/**' + - './scripts/validate-npm-packages.sh' + - '${{ inputs.self }}' e2e: - 'e2e/**' - 'e2e-playwright/**' @@ -153,6 +162,8 @@ runs: echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}" echo "Frontend: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }}" echo " --> ${{ steps.changed-files.outputs.frontend_all_changed_files }}" + echo "Frontend packages: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }}" + echo " --> ${{ steps.changed-files.outputs.frontend_packages_all_changed_files }}" echo "E2E: ${{ steps.changed-files.outputs.e2e_any_changed || 'true' }}" echo " --> ${{ steps.changed-files.outputs.e2e_all_changed_files }}" echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}" diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml index 5762389f83b..92ffd43593c 100644 --- a/.github/actions/setup-node/action.yml +++ b/.github/actions/setup-node/action.yml @@ -4,8 +4,8 @@ description: Sets up a node.js environment with presets for the Grafana reposito runs: using: "composite" steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'yarn' - cache-dependency-path: 'yarn.lock' \ No newline at end of file + cache-dependency-path: 'yarn.lock' diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 79987600b9a..6356a793b36 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,7 +19,6 @@ updates: - "/apps/dashboard" - "/apps/folder" - "/apps/iam" - - "/apps/investigations" - "/apps/playlist" - "/apps/plugins" - "/apps/preferences" diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/frontend-lint.yml index 02833d61149..539d57b1742 100644 --- a/.github/workflows/frontend-lint.yml +++ b/.github/workflows/frontend-lint.yml @@ -17,6 +17,7 @@ jobs: outputs: changed: ${{ steps.detect-changes.outputs.frontend }} prettier: ${{ steps.detect-changes.outputs.frontend == 'true' || steps.detect-changes.outputs.docs == 'true' }} + changed-frontend-packages: ${{ steps.detect-changes.outputs.frontend-packages }} steps: - uses: actions/checkout@v5 with: @@ -42,11 +43,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - run: yarn run prettier:check - run: yarn run lint @@ -63,11 +61,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -89,11 +84,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - run: yarn run typecheck lint-frontend-typecheck-enterprise: @@ -109,11 +101,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -133,11 +122,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - run: yarn install --immutable --check-cache - name: Generate API clients run: | @@ -164,11 +150,8 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-node@v6 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - cache-dependency-path: 'yarn.lock' + - name: Setup Node + uses: ./.github/actions/setup-node - name: Setup Enterprise uses: ./.github/actions/setup-enterprise with: @@ -187,3 +170,26 @@ jobs: echo "${uncommited_error_message}" exit 1 fi + lint-frontend-packed-packages: + needs: detect-changes + permissions: + contents: read + id-token: write + if: github.event_name == 'pull_request' && needs.detect-changes.outputs.changed-frontend-packages == 'true' + name: Verify packed frontend packages + runs-on: ubuntu-latest + steps: + - name: Checkout build commit + uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Setup Node + uses: ./.github/actions/setup-node + - name: Install dependencies + run: yarn install --immutable + - name: Build and pack packages + run: | + yarn run packages:build + yarn run packages:pack + - name: Validate packages + run: ./scripts/validate-npm-packages.sh diff --git a/.golangci.yml b/.golangci.yml index b52a986d435..069e88632ff 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -67,14 +67,6 @@ linters: deny: - pkg: github.com/grafana/grafana/pkg desc: apiserver is not allowed to import grafana core - apps-investigation: - list-mode: lax - files: - - ./apps/investigations/* - - ./apps/investigations/**/* - deny: - - pkg: github.com/grafana/grafana/pkg - desc: apps/investigations is not allowed to import grafana core apps-playlist: list-mode: lax files: diff --git a/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch b/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch similarity index 73% rename from .yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch rename to .yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch index 730ecce8fb2..f3b7cb48e98 100644 --- a/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch +++ b/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch @@ -1,8 +1,8 @@ diff --git a/dist/builder-manager/index.js b/dist/builder-manager/index.js -index 3d7f9b213dae1801bda62b31db31b9113e382ccd..212501c63d20146c29db63fb0f6300c6779eecb5 100644 +index ac8ac6a5f6a3b7852c4064e93dc9acd3201289e6..34a0a5a5c38dd7fe525c9ebd382a10a451d4d4f3 100644 --- a/dist/builder-manager/index.js +++ b/dist/builder-manager/index.js -@@ -1970,7 +1970,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => { +@@ -1974,7 +1974,7 @@ var pa = /^\/($|\?)/, G, C, xt = /* @__PURE__ */ o(async (e) => { bundle: !0, minify: !0, sourcemap: !1, diff --git a/Dockerfile b/Dockerfile index 2b16926a836..6644d789d4f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,6 +91,7 @@ COPY pkg/storage/unified/resource pkg/storage/unified/resource COPY pkg/storage/unified/resourcepb pkg/storage/unified/resourcepb COPY pkg/storage/unified/apistore pkg/storage/unified/apistore COPY pkg/semconv pkg/semconv +COPY pkg/plugins pkg/plugins COPY pkg/aggregator pkg/aggregator COPY apps/playlist apps/playlist COPY apps/quotas apps/quotas @@ -103,10 +104,10 @@ COPY apps/collections apps/collections COPY apps/provisioning apps/provisioning COPY apps/secret apps/secret COPY apps/scope apps/scope -COPY apps/investigations apps/investigations COPY apps/logsdrilldown apps/logsdrilldown COPY apps/advisor apps/advisor COPY apps/dashboard apps/dashboard +COPY apps/dashvalidator apps/dashvalidator COPY apps/folder apps/folder COPY apps/iam apps/iam COPY apps apps diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 314726c5ecb..c65d5dacdc5 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -68,14 +68,14 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect - github.com/aws/smithy-go v1.23.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/benbjohnson/clock v1.3.5 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -162,14 +162,14 @@ require ( github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-aws-sdk v1.3.0 // indirect + github.com/grafana/grafana-aws-sdk v1.4.2 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect - github.com/grafana/sqlds/v4 v4.2.7 // indirect + github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 112228d6ed8..c306b9fa889 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -173,44 +173,44 @@ github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= -github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= -github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= -github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= -github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= @@ -637,8 +637,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= -github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= +github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM= +github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= @@ -655,8 +655,8 @@ github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasn github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= -github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA= -github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= +github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index c13eb866c80..6488de41c96 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -129,7 +129,7 @@ DashboardLink: { placement?: DashboardLinkPlacement } -// Dashboard Link placement. Defines where the link should be displayed. +// Dashboard Link placement. Defines where the link should be displayed. // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu DashboardLinkPlacement: "inControlsMenu" @@ -932,6 +932,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index bb833795354..a8e1f121213 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -935,6 +935,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue index 8338c9e13c5..3c6c0e92355 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -222,8 +222,10 @@ lineage: schemas: [{ // Optional field, if you want to extract part of a series name or metric node segment. // Named capture groups can be used to separate the display text and value. regex?: string - // Determine whether regex applies to variable value or display text - regexApplyTo?: #VariableRegexApplyTo + // Optional, indicates whether a custom type variable uses CSV or JSON to define its values + valuesFormat?: "csv" | "json" | *"csv" + // Determine whether regex applies to variable value or display text + regexApplyTo?: #VariableRegexApplyTo // Additional static options for query variable staticOptions?: [...#VariableOption] // Ordering of static options in relation to options returned from data source for query variable diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue index 8338c9e13c5..3c6c0e92355 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue @@ -222,8 +222,10 @@ lineage: schemas: [{ // Optional field, if you want to extract part of a series name or metric node segment. // Named capture groups can be used to separate the display text and value. regex?: string - // Determine whether regex applies to variable value or display text - regexApplyTo?: #VariableRegexApplyTo + // Optional, indicates whether a custom type variable uses CSV or JSON to define its values + valuesFormat?: "csv" | "json" | *"csv" + // Determine whether regex applies to variable value or display text + regexApplyTo?: #VariableRegexApplyTo // Additional static options for query variable staticOptions?: [...#VariableOption] // Ordering of static options in relation to options returned from data source for query variable diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue index ec8d7eead87..2b027ff98e1 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec.cue @@ -133,7 +133,7 @@ DashboardLink: { placement?: DashboardLinkPlacement } -// Dashboard Link placement. Defines where the link should be displayed. +// Dashboard Link placement. Defines where the link should be displayed. // - "inControlsMenu" renders the link in bottom part of the dashboard controls dropdown menu DashboardLinkPlacement: "inControlsMenu" @@ -936,6 +936,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 883c5399663..3f594306ef5 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -1703,18 +1703,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind { // Custom variable specification // +k8s:openapi-gen=true type DashboardCustomVariableSpec struct { - Name string `json:"name"` - Query string `json:"query"` - Current DashboardVariableOption `json:"current"` - Options []DashboardVariableOption `json:"options"` - Multi bool `json:"multi"` - IncludeAll bool `json:"includeAll"` - AllValue *string `json:"allValue,omitempty"` - Label *string `json:"label,omitempty"` - Hide DashboardVariableHide `json:"hide"` - SkipUrlSync bool `json:"skipUrlSync"` - Description *string `json:"description,omitempty"` - AllowCustomValue bool `json:"allowCustomValue"` + Name string `json:"name"` + Query string `json:"query"` + Current DashboardVariableOption `json:"current"` + Options []DashboardVariableOption `json:"options"` + Multi bool `json:"multi"` + IncludeAll bool `json:"includeAll"` + AllValue *string `json:"allValue,omitempty"` + Label *string `json:"label,omitempty"` + Hide DashboardVariableHide `json:"hide"` + SkipUrlSync bool `json:"skipUrlSync"` + Description *string `json:"description,omitempty"` + AllowCustomValue bool `json:"allowCustomValue"` + ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"` } // NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object. @@ -2098,6 +2099,14 @@ const ( DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted" ) +// +k8s:openapi-gen=true +type DashboardCustomVariableSpecValuesFormat string + +const ( + DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv" + DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json" +) + // +k8s:openapi-gen=true type DashboardPanelKindOrLibraryPanelKind struct { PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index d697aa4f8b9..4c6f3f5ed20 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -1548,6 +1548,12 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardCustomVariableSpec(ref common.R Format: "", }, }, + "valuesFormat": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"}, }, diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index 12d7bec351b..375ba67f003 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -939,6 +939,7 @@ CustomVariableSpec: { skipUrlSync: bool | *false description?: string allowCustomValue: bool | *true + valuesFormat?: "csv" | "json" } // Custom variable kind diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index a8ec1537e38..96054cb2fc4 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -1707,18 +1707,19 @@ func NewDashboardCustomVariableKind() *DashboardCustomVariableKind { // Custom variable specification // +k8s:openapi-gen=true type DashboardCustomVariableSpec struct { - Name string `json:"name"` - Query string `json:"query"` - Current DashboardVariableOption `json:"current"` - Options []DashboardVariableOption `json:"options"` - Multi bool `json:"multi"` - IncludeAll bool `json:"includeAll"` - AllValue *string `json:"allValue,omitempty"` - Label *string `json:"label,omitempty"` - Hide DashboardVariableHide `json:"hide"` - SkipUrlSync bool `json:"skipUrlSync"` - Description *string `json:"description,omitempty"` - AllowCustomValue bool `json:"allowCustomValue"` + Name string `json:"name"` + Query string `json:"query"` + Current DashboardVariableOption `json:"current"` + Options []DashboardVariableOption `json:"options"` + Multi bool `json:"multi"` + IncludeAll bool `json:"includeAll"` + AllValue *string `json:"allValue,omitempty"` + Label *string `json:"label,omitempty"` + Hide DashboardVariableHide `json:"hide"` + SkipUrlSync bool `json:"skipUrlSync"` + Description *string `json:"description,omitempty"` + AllowCustomValue bool `json:"allowCustomValue"` + ValuesFormat *DashboardCustomVariableSpecValuesFormat `json:"valuesFormat,omitempty"` } // NewDashboardCustomVariableSpec creates a new DashboardCustomVariableSpec object. @@ -2133,6 +2134,14 @@ const ( DashboardQueryVariableSpecStaticOptionsOrderSorted DashboardQueryVariableSpecStaticOptionsOrder = "sorted" ) +// +k8s:openapi-gen=true +type DashboardCustomVariableSpecValuesFormat string + +const ( + DashboardCustomVariableSpecValuesFormatCsv DashboardCustomVariableSpecValuesFormat = "csv" + DashboardCustomVariableSpecValuesFormatJson DashboardCustomVariableSpecValuesFormat = "json" +) + // +k8s:openapi-gen=true type DashboardPanelKindOrLibraryPanelKind struct { PanelKind *DashboardPanelKind `json:"PanelKind,omitempty"` diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go index 2b1fe573336..402810f6e53 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/zz_generated.openapi.go @@ -1560,6 +1560,12 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardCustomVariableSpec(ref common.Re Format: "", }, }, + "valuesFormat": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"name", "query", "current", "options", "multi", "includeAll", "hide", "skipUrlSync", "allowCustomValue"}, }, diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index c815e815e08..e94d66fec82 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -32,10 +32,10 @@ var ( rawSchemaDashboardv1beta1 = []byte(`{"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv1beta1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv1beta1, &versionSchemaDashboardv1beta1) - rawSchemaDashboardv2alpha1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a DataQueryKind is the datasource type","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["kind","spec"],"type":"object"},"DataSourceRef":{"additionalProperties":false,"properties":{"type":{"description":"The plugin type-id","type":"string"},"uid":{"description":"Specific datasource instance","type":"string"}},"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"description":"Switch variable specification","properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing).","enum":["dontHide","hideLabel","hideVariable"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a VizConfigKind is the plugin ID","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"}},"required":["kind","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"pluginVersion":{"type":"string"}},"required":["pluginVersion","options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) + rawSchemaDashboardv2alpha1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"},"valuesFormat":{"enum":["csv","json"],"type":"string"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a DataQueryKind is the datasource type","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["kind","spec"],"type":"object"},"DataSourceRef":{"additionalProperties":false,"properties":{"type":{"description":"The plugin type-id","type":"string"},"uid":{"description":"Specific datasource instance","type":"string"}},"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"datasource":{"$ref":"#/components/schemas/DataSourceRef"},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"description":"Switch variable specification","properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing).","enum":["dontHide","hideLabel","hideVariable"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a VizConfigKind is the plugin ID","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"}},"required":["kind","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"pluginVersion":{"type":"string"}},"required":["pluginVersion","options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv2alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv2alpha1, &versionSchemaDashboardv2alpha1) - rawSchemaDashboardv2beta1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQueryPlacement":{"const":"inControlsMenu","description":"Annotation Query placement. Defines where the annotation query should be displayed.\n- \"inControlsMenu\" renders the annotation query in the dashboard controls dropdown menu","type":"string"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties. Should not be available in as code tooling.","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"placement":{"$ref":"#/components/schemas/AnnotationQueryPlacement","description":"Placement can be used to display the annotation query somewhere else on the dashboard other than the default location."},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["query","enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"datasource":{"additionalProperties":false,"description":"New type for datasource reference\nNot creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS.","properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"DataQuery","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"version":{"default":"v0","type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"const":"onTimeRangeChanged","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"default":"A","type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeCompare":{"type":"string"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing), ` + "`" + `inControlsMenu` + "`" + ` (show in a drop-down menu).","enum":["dontHide","hideLabel","hideVariable","inControlsMenu"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"group":{"description":"The group is the plugin ID","type":"string"},"kind":{"const":"VizConfig","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"},"version":{"type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) + rawSchemaDashboardv2beta1 = []byte(`{"Action":{"additionalProperties":false,"properties":{"confirmation":{"type":"string"},"fetch":{"$ref":"#/components/schemas/FetchOptions"},"infinity":{"$ref":"#/components/schemas/InfinityOptions"},"oneClick":{"type":"boolean"},"style":{"additionalProperties":false,"properties":{"backgroundColor":{"type":"string"}},"type":"object"},"title":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionType"},"variables":{"items":{"$ref":"#/components/schemas/ActionVariable"},"type":"array"}},"required":["type","title"],"type":"object"},"ActionType":{"enum":["fetch","infinity"],"type":"string"},"ActionVariable":{"additionalProperties":false,"properties":{"key":{"type":"string"},"name":{"type":"string"},"type":{"$ref":"#/components/schemas/ActionVariableType"}},"required":["key","name","type"],"type":"object"},"ActionVariableType":{"const":"string","description":"Action variable type","type":"string"},"AdHocFilterWithLabels":{"additionalProperties":false,"description":"Define the AdHocFilterWithLabels type","properties":{"condition":{"description":"@deprecated","type":"string"},"forceEdit":{"type":"boolean"},"key":{"type":"string"},"keyLabel":{"type":"string"},"operator":{"type":"string"},"origin":{"$ref":"#/components/schemas/FilterOrigin"},"value":{"type":"string"},"valueLabels":{"items":{"type":"string"},"type":"array"},"values":{"items":{"type":"string"},"type":"array"}},"required":["key","operator","value"],"type":"object"},"AdhocVariableKind":{"additionalProperties":false,"description":"Adhoc variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"AdhocVariable","type":"string"},"spec":{"$ref":"#/components/schemas/AdhocVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"AdhocVariableSpec":{"additionalProperties":false,"description":"Adhoc variable specification","properties":{"allowCustomValue":{"default":true,"type":"boolean"},"baseFilters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"defaultKeys":{"items":{"$ref":"#/components/schemas/MetricFindValue"},"type":"array"},"description":{"type":"string"},"filters":{"items":{"$ref":"#/components/schemas/AdHocFilterWithLabels"},"type":"array"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","baseFilters","filters","defaultKeys","hide","skipUrlSync","allowCustomValue"],"type":"object"},"AnnotationEventFieldMapping":{"additionalProperties":false,"description":"Annotation event field mapping. Defines how to map a data frame field to an annotation event field.","properties":{"regex":{"description":"Regular expression to apply to the field value","type":"string"},"source":{"default":"field","description":"Source type for the field value","type":"string"},"value":{"description":"Constant value to use when source is \"text\"","type":"string"}},"type":"object"},"AnnotationPanelFilter":{"additionalProperties":false,"properties":{"exclude":{"default":false,"description":"Should the specified panels be included or excluded","type":"boolean"},"ids":{"description":"Panel IDs that should be included or excluded","items":{"type":"integer"},"type":"array"}},"required":["ids"],"type":"object"},"AnnotationQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"AnnotationQuery","type":"string"},"spec":{"$ref":"#/components/schemas/AnnotationQuerySpec"}},"required":["kind","spec"],"type":"object"},"AnnotationQueryPlacement":{"const":"inControlsMenu","description":"Annotation Query placement. Defines where the annotation query should be displayed.\n- \"inControlsMenu\" renders the annotation query in the dashboard controls dropdown menu","type":"string"},"AnnotationQuerySpec":{"additionalProperties":false,"properties":{"builtIn":{"default":false,"type":"boolean"},"enable":{"type":"boolean"},"filter":{"$ref":"#/components/schemas/AnnotationPanelFilter"},"hide":{"type":"boolean"},"iconColor":{"type":"string"},"legacyOptions":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"Catch-all field for datasource-specific properties. Should not be available in as code tooling.","type":"object"},"mappings":{"additionalProperties":{"$ref":"#/components/schemas/AnnotationEventFieldMapping"},"description":"Mappings define how to convert data frame fields to annotation event fields.","type":"object"},"name":{"type":"string"},"placement":{"$ref":"#/components/schemas/AnnotationQueryPlacement","description":"Placement can be used to display the annotation query somewhere else on the dashboard other than the default location."},"query":{"$ref":"#/components/schemas/DataQueryKind"}},"required":["query","enable","hide","iconColor","name"],"type":"object"},"AutoGridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutItemSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"element":{"$ref":"#/components/schemas/ElementReference"},"repeat":{"$ref":"#/components/schemas/AutoGridRepeatOptions"}},"required":["element"],"type":"object"},"AutoGridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"AutoGridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/AutoGridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"AutoGridLayoutSpec":{"additionalProperties":false,"properties":{"columnWidth":{"type":"number"},"columnWidthMode":{"default":"standard","enum":["narrow","standard","wide","custom"],"type":"string"},"fillScreen":{"default":false,"type":"boolean"},"items":{"items":{"$ref":"#/components/schemas/AutoGridLayoutItemKind"},"type":"array"},"maxColumnCount":{"default":3,"type":"number"},"rowHeight":{"type":"number"},"rowHeightMode":{"default":"standard","enum":["short","standard","tall","custom"],"type":"string"}},"required":["columnWidthMode","rowHeightMode","items"],"type":"object"},"AutoGridRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"ConditionalRenderingDataKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingData","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingDataSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingDataSpec":{"additionalProperties":false,"properties":{"value":{"type":"boolean"}},"required":["value"],"type":"object"},"ConditionalRenderingGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingGroup","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingGroupSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingGroupSpec":{"additionalProperties":false,"properties":{"condition":{"enum":["and","or"],"type":"string"},"items":{"items":{"oneOf":[{"$ref":"#/components/schemas/ConditionalRenderingVariableKind"},{"$ref":"#/components/schemas/ConditionalRenderingDataKind"},{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeKind"}]},"type":"array"},"visibility":{"enum":["show","hide"],"type":"string"}},"required":["visibility","condition","items"],"type":"object"},"ConditionalRenderingTimeRangeSizeKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingTimeRangeSize","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingTimeRangeSizeSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingTimeRangeSizeSpec":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"required":["value"],"type":"object"},"ConditionalRenderingVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"ConditionalRenderingVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConditionalRenderingVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConditionalRenderingVariableSpec":{"additionalProperties":false,"properties":{"operator":{"enum":["equals","notEquals","matches","notMatches"],"type":"string"},"value":{"type":"string"},"variable":{"type":"string"}},"required":["variable","operator","value"],"type":"object"},"ConstantVariableKind":{"additionalProperties":false,"description":"Constant variable kind","properties":{"kind":{"const":"ConstantVariable","type":"string"},"spec":{"$ref":"#/components/schemas/ConstantVariableSpec"}},"required":["kind","spec"],"type":"object"},"ConstantVariableSpec":{"additionalProperties":false,"description":"Constant variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","hide","skipUrlSync"],"type":"object"},"ConversionStatus":{"additionalProperties":false,"description":"ConversionStatus is the status of the conversion of the dashboard.","properties":{"error":{"description":"The error message from the conversion.\nEmpty if the conversion has not failed.","type":"string"},"failed":{"description":"Whether from another version has failed.\nIf true, means that the dashboard is not valid,\nand the caller should instead fetch the stored version.","type":"boolean"},"source":{"additionalProperties":{},"description":"The original value map[string]any","type":"object"},"storedVersion":{"description":"The version which was stored when the dashboard was created / updated.\nFetching this version should always succeed.","type":"string"}},"required":["failed"],"type":"object"},"CustomVariableKind":{"additionalProperties":false,"description":"Custom variable kind","properties":{"kind":{"const":"CustomVariable","type":"string"},"spec":{"$ref":"#/components/schemas/CustomVariableSpec"}},"required":["kind","spec"],"type":"object"},"CustomVariableSpec":{"additionalProperties":false,"description":"Custom variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"},"valuesFormat":{"enum":["csv","json"],"type":"string"}},"required":["name","query","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"Dashboard":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"DashboardCursorSync":{"description":"\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip.","enum":["Crosshair","Tooltip","Off"],"type":"string"},"DashboardLink":{"additionalProperties":false,"description":"Links with references to other dashboards or external resources","properties":{"asDropdown":{"default":false,"description":"If true, all dashboards links will be displayed in a dropdown. If false, all dashboards links will be displayed side by side. Only valid if the type is dashboards","type":"boolean"},"icon":{"description":"Icon name to be displayed with the link","type":"string"},"includeVars":{"default":false,"description":"If true, includes current template variables values in the link as query params","type":"boolean"},"keepTime":{"default":false,"description":"If true, includes current time range in the link as query params","type":"boolean"},"placement":{"$ref":"#/components/schemas/DashboardLinkPlacement","description":"Placement can be used to display the link somewhere else on the dashboard other than above the visualisations."},"tags":{"description":"List of tags to limit the linked dashboards. If empty, all dashboards will be displayed. Only valid if the type is dashboards","items":{"type":"string"},"type":"array"},"targetBlank":{"default":false,"description":"If true, the link will be opened in a new tab","type":"boolean"},"title":{"description":"Title to display with the link","type":"string"},"tooltip":{"description":"Tooltip to display when the user hovers their mouse over it","type":"string"},"type":{"$ref":"#/components/schemas/DashboardLinkType","description":"Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)\nFIXME: The type is generated as ` + "`" + `type: DashboardLinkType | dashboardLinkType.Link;` + "`" + ` but it should be ` + "`" + `type: DashboardLinkType` + "`" + `"},"url":{"description":"Link URL. Only required/valid if the type is link","type":"string"}},"required":["title","type","icon","tooltip","tags","asDropdown","targetBlank","includeVars","keepTime"],"type":"object"},"DashboardLinkPlacement":{"const":"inControlsMenu","description":"Dashboard Link placement. Defines where the link should be displayed.\n- \"inControlsMenu\" renders the link in bottom part of the dashboard controls dropdown menu","type":"string"},"DashboardLinkType":{"description":"Dashboard Link type. Accepted values are dashboards (to refer to another dashboard) and link (to refer to an external resource)","enum":["link","dashboards"],"type":"string"},"DataLink":{"additionalProperties":false,"properties":{"targetBlank":{"type":"boolean"},"title":{"type":"string"},"url":{"type":"string"}},"required":["title","url"],"type":"object"},"DataQueryKind":{"additionalProperties":false,"properties":{"datasource":{"additionalProperties":false,"description":"New type for datasource reference\nNot creating a new type until we figure out how to handle DS refs for group by, adhoc, and every place that uses DataSourceRef in TS.","properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"DataQuery","type":"string"},"spec":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"version":{"default":"v0","type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"DataTopic":{"description":"A topic is attached to DataFrame metadata in query results.\nThis specifies where the data should be used.","enum":["series","annotations","alertStates"],"type":"string"},"DataTransformerConfig":{"additionalProperties":false,"description":"Transformations allow to manipulate data returned by a query before the system applies a visualization.\nUsing transformations you can: rename fields, join time series data, perform mathematical operations across queries,\nuse the output of one transformation as the input to another transformation, etc.","properties":{"disabled":{"description":"Disabled transformations are skipped","type":"boolean"},"filter":{"$ref":"#/components/schemas/MatcherConfig","description":"Optional frame matcher. When missing it will be applied to all results"},"id":{"description":"Unique identifier of transformer","type":"string"},"options":{"additionalProperties":{},"description":"Options to be passed to the transformer\nValid options depend on the transformer id","type":"object"},"topic":{"$ref":"#/components/schemas/DataTopic","description":"Where to pull DataFrames from as input to transformation"}},"required":["id","options"],"type":"object"},"DatasourceVariableKind":{"additionalProperties":false,"description":"Datasource variable kind","properties":{"kind":{"const":"DatasourceVariable","type":"string"},"spec":{"$ref":"#/components/schemas/DatasourceVariableSpec"}},"required":["kind","spec"],"type":"object"},"DatasourceVariableSpec":{"additionalProperties":false,"description":"Datasource variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"pluginId":{"default":"","type":"string"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","pluginId","refresh","regex","current","options","multi","includeAll","hide","skipUrlSync","allowCustomValue"],"type":"object"},"DynamicConfigValue":{"additionalProperties":false,"properties":{"id":{"default":"","type":"string"},"value":{"additionalProperties":{},"type":"object"}},"required":["id"],"type":"object"},"Element":{"description":"Supported dashboard elements\n|* more element types in the future","oneOf":[{"$ref":"#/components/schemas/PanelKind"},{"$ref":"#/components/schemas/LibraryPanelKind"}]},"ElementReference":{"additionalProperties":false,"properties":{"kind":{"const":"ElementReference","type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"},"FetchOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url"],"type":"object"},"FieldColor":{"additionalProperties":false,"description":"Map a field to a color.","properties":{"fixedColor":{"description":"The fixed color value for fixed or shades color modes.","type":"string"},"mode":{"$ref":"#/components/schemas/FieldColorModeId","description":"The main color scheme mode."},"seriesBy":{"$ref":"#/components/schemas/FieldColorSeriesByMode","description":"Some visualizations need to know how to assign a series color from by value color schemes."}},"required":["mode"],"type":"object"},"FieldColorModeId":{"description":"Color mode for a field. You can specify a single color, or select a continuous (gradient) color schemes, based on a value.\nContinuous color interpolates a color using the percentage of a value relative to min and max.\nAccepted values are:\n` + "`" + `thresholds` + "`" + `: From thresholds. Informs Grafana to take the color from the matching threshold\n` + "`" + `palette-classic` + "`" + `: Classic palette. Grafana will assign color by looking up a color in a palette by series index. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `palette-classic-by-name` + "`" + `: Classic palette (by name). Grafana will assign color by looking up a color in a palette by series name. Useful for Graphs and pie charts and other categorical data visualizations\n` + "`" + `continuous-viridis` + "`" + `: Continuous Viridis palette mode\n` + "`" + `continuous-magma` + "`" + `: Continuous Magma palette mode\n` + "`" + `continuous-plasma` + "`" + `: Continuous Plasma palette mode\n` + "`" + `continuous-inferno` + "`" + `: Continuous Inferno palette mode\n` + "`" + `continuous-cividis` + "`" + `: Continuous Cividis palette mode\n` + "`" + `continuous-GrYlRd` + "`" + `: Continuous Green-Yellow-Red palette mode\n` + "`" + `continuous-RdYlGr` + "`" + `: Continuous Red-Yellow-Green palette mode\n` + "`" + `continuous-BlYlRd` + "`" + `: Continuous Blue-Yellow-Red palette mode\n` + "`" + `continuous-YlRd` + "`" + `: Continuous Yellow-Red palette mode\n` + "`" + `continuous-BlPu` + "`" + `: Continuous Blue-Purple palette mode\n` + "`" + `continuous-YlBl` + "`" + `: Continuous Yellow-Blue palette mode\n` + "`" + `continuous-blues` + "`" + `: Continuous Blue palette mode\n` + "`" + `continuous-reds` + "`" + `: Continuous Red palette mode\n` + "`" + `continuous-greens` + "`" + `: Continuous Green palette mode\n` + "`" + `continuous-purples` + "`" + `: Continuous Purple palette mode\n` + "`" + `shades` + "`" + `: Shades of a single color. Specify a single color, useful in an override rule.\n` + "`" + `fixed` + "`" + `: Fixed color mode. Specify a single color, useful in an override rule.","enum":["thresholds","palette-classic","palette-classic-by-name","continuous-viridis","continuous-magma","continuous-plasma","continuous-inferno","continuous-cividis","continuous-GrYlRd","continuous-RdYlGr","continuous-BlYlRd","continuous-YlRd","continuous-BlPu","continuous-YlBl","continuous-blues","continuous-reds","continuous-greens","continuous-purples","fixed","shades"],"type":"string"},"FieldColorSeriesByMode":{"description":"Defines how to assign a series color from \"by value\" color schemes. For example for an aggregated data points like a timeseries, the color can be assigned by the min, max or last value.","enum":["min","max","last"],"type":"string"},"FieldConfig":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"actions":{"description":"Define interactive HTTP requests that can be triggered from data visualizations.","items":{"$ref":"#/components/schemas/Action"},"type":"array"},"color":{"$ref":"#/components/schemas/FieldColor","description":"Panel color configuration"},"custom":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"custom is specified by the FieldConfig field\nin panel plugin schemas.","type":"object"},"decimals":{"description":"Specify the number of decimals Grafana includes in the rendered value.\nIf you leave this field blank, Grafana automatically truncates the number of decimals based on the value.\nFor example 1.1234 will display as 1.12 and 100.456 will display as 100.\nTo display all decimals, set the unit to ` + "`" + `String` + "`" + `.","type":"number"},"description":{"description":"Human readable field metadata","type":"string"},"displayName":{"description":"The display value for this field. This supports template variables blank is auto","type":"string"},"displayNameFromDS":{"description":"This can be used by data sources that return and explicit naming structure for values and labels\nWhen this property is configured, this value is used rather than the default naming strategy.","type":"string"},"filterable":{"description":"True if data source field supports ad-hoc filters","type":"boolean"},"links":{"description":"The behavior when clicking on a result","items":{"additionalProperties":{},"type":"object"},"type":"array"},"mappings":{"description":"Convert input values into a display string","items":{"$ref":"#/components/schemas/ValueMapping"},"type":"array"},"max":{"description":"The maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"min":{"description":"The minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields.","type":"number"},"noValue":{"description":"Alternative to empty string","type":"string"},"path":{"description":"An explicit path to the field in the datasource. When the frame meta includes a path,\nThis will default to ` + "`" + `${frame.meta.path}/${field.name}\n\nWhen defined, this value can be used as an identifier within the datasource scope, and\nmay be used to update the results","type":"string"},"thresholds":{"$ref":"#/components/schemas/ThresholdsConfig","description":"Map numeric values to states"},"unit":{"description":"Unit a field should use. The unit you select is applied to all fields except time.\nYou can use the units ID availables in Grafana or a custom unit.\nAvailable units in Grafana: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts\nAs custom unit, you can use the following formats:\n` + "`" + `suffix:\u003csuffix\u003e` + "`" + ` for custom unit that should go after value.\n` + "`" + `prefix:\u003cprefix\u003e` + "`" + ` for custom unit that should go before value.\n` + "`" + `time:\u003cformat\u003e` + "`" + ` For custom date time formats type for example ` + "`" + `time:YYYY-MM-DD` + "`" + `.\n` + "`" + `si:\u003cbase scale\u003e\u003cunit characters\u003e` + "`" + ` for custom SI units. For example: ` + "`" + `si: mF` + "`" + `. This one is a bit more advanced as you can specify both a unit and the source data scale. So if your source data is represented as milli (thousands of) something prefix the unit with that SI scale character.\n` + "`" + `count:\u003cunit\u003e` + "`" + ` for a custom count unit.\n` + "`" + `currency:\u003cunit\u003e` + "`" + ` for custom a currency unit.","type":"string"},"writeable":{"description":"True if data source can write a value to the path. Auth/authz are supported separately","type":"boolean"}},"type":"object"},"FieldConfigSource":{"additionalProperties":false,"description":"The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.\nEach column within this structure is called a field. A field can represent a single time series or table column.\nField options allow you to change how the data is displayed in your visualizations.","properties":{"defaults":{"$ref":"#/components/schemas/FieldConfig","description":"Defaults are the options applied to all fields."},"overrides":{"description":"Overrides are the options applied to specific fields overriding the defaults.","items":{"additionalProperties":false,"properties":{"__systemRef":{"description":"Describes config override rules created when interacting with Grafana.","type":"string"},"matcher":{"$ref":"#/components/schemas/MatcherConfig"},"properties":{"items":{"$ref":"#/components/schemas/DynamicConfigValue"},"type":"array"}},"required":["matcher","properties"],"type":"object"},"type":"array"}},"required":["defaults","overrides"],"type":"object"},"FilterOrigin":{"const":"dashboard","description":"Determine the origin of the adhoc variable filter","type":"string"},"GridLayoutItemKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayoutItem","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutItemSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutItemSpec":{"additionalProperties":false,"properties":{"element":{"$ref":"#/components/schemas/ElementReference","description":"reference to a PanelKind from dashboard.spec.elements Expressed as JSON Schema reference"},"height":{"type":"integer"},"repeat":{"$ref":"#/components/schemas/RepeatOptions"},"width":{"type":"integer"},"x":{"type":"integer"},"y":{"type":"integer"}},"required":["x","y","width","height","element"],"type":"object"},"GridLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"GridLayout","type":"string"},"spec":{"$ref":"#/components/schemas/GridLayoutSpec"}},"required":["kind","spec"],"type":"object"},"GridLayoutSpec":{"additionalProperties":false,"properties":{"items":{"items":{"$ref":"#/components/schemas/GridLayoutItemKind"},"type":"array"}},"required":["items"],"type":"object"},"GroupByVariableKind":{"additionalProperties":false,"description":"Group variable kind","properties":{"datasource":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"type":"object"},"group":{"type":"string"},"kind":{"const":"GroupByVariable","type":"string"},"spec":{"$ref":"#/components/schemas/GroupByVariableSpec"}},"required":["kind","group","spec"],"type":"object"},"GroupByVariableSpec":{"additionalProperties":false,"description":"GroupBy variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"defaultValue":{"$ref":"#/components/schemas/VariableOption"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","options","multi","hide","skipUrlSync"],"type":"object"},"HttpRequestMethod":{"enum":["GET","PUT","POST","DELETE","PATCH"],"type":"string"},"InfinityOptions":{"additionalProperties":false,"properties":{"body":{"type":"string"},"datasourceUid":{"type":"string"},"headers":{"items":{"items":{"type":"string"},"type":"array"},"type":"array"},"method":{"$ref":"#/components/schemas/HttpRequestMethod"},"queryParams":{"description":"These are 2D arrays of strings, each representing a key-value pair\nWe are defining them this way because we can't generate a go struct that\nthat would have exactly two strings in each sub-array","items":{"items":{"type":"string"},"type":"array"},"type":"array"},"url":{"type":"string"}},"required":["method","url","datasourceUid"],"type":"object"},"IntervalVariableKind":{"additionalProperties":false,"description":"Interval variable kind","properties":{"kind":{"const":"IntervalVariable","type":"string"},"spec":{"$ref":"#/components/schemas/IntervalVariableSpec"}},"required":["kind","spec"],"type":"object"},"IntervalVariableSpec":{"additionalProperties":false,"description":"Interval variable specification","properties":{"auto":{"default":false,"type":"boolean"},"auto_count":{"default":0,"type":"integer"},"auto_min":{"default":"","type":"string"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"query":{"default":"","type":"string"},"refresh":{"const":"onTimeRangeChanged","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","query","current","options","auto","auto_min","auto_count","refresh","hide","skipUrlSync"],"type":"object"},"LibraryPanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"LibraryPanel","type":"string"},"spec":{"$ref":"#/components/schemas/LibraryPanelKindSpec"}},"required":["kind","spec"],"type":"object"},"LibraryPanelKindSpec":{"additionalProperties":false,"properties":{"id":{"description":"Panel ID for the library panel in the dashboard","type":"number"},"libraryPanel":{"$ref":"#/components/schemas/LibraryPanelRef"},"title":{"description":"Title for the library panel in the dashboard","type":"string"}},"required":["id","title","libraryPanel"],"type":"object"},"LibraryPanelRef":{"additionalProperties":false,"description":"A library panel is a reusable panel that you can use in any dashboard.\nWhen you make a change to a library panel, that change propagates to all instances of where the panel is used.\nLibrary panels streamline reuse of panels across multiple dashboards.","properties":{"name":{"description":"Library panel name","type":"string"},"uid":{"description":"Library panel uid","type":"string"}},"required":["name","uid"],"type":"object"},"MappingType":{"description":"Supported value mapping types\n` + "`" + `value` + "`" + `: Maps text values to a color or different display text and color. For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.\n` + "`" + `range` + "`" + `: Maps numerical ranges to a display text and color. For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.\n` + "`" + `regex` + "`" + `: Maps regular expressions to replacement text and a color. For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.\n` + "`" + `special` + "`" + `: Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color. See SpecialValueMatch to see the list of special values. For example, you can configure a special value mapping so that null values appear as N/A.","enum":["value","range","regex","special"],"type":"string"},"MatcherConfig":{"additionalProperties":false,"description":"Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.\nIt comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type.","properties":{"id":{"default":"","description":"The matcher id. This is used to find the matcher implementation from registry.","type":"string"},"options":{"additionalProperties":{},"description":"The matcher options. This is specific to the matcher implementation.","type":"object"}},"required":["id"],"type":"object"},"MetricFindValue":{"additionalProperties":false,"description":"Define the MetricFindValue type","properties":{"expandable":{"type":"boolean"},"group":{"type":"string"},"text":{"type":"string"},"value":{"oneOf":[{"type":"string"},{"type":"number"}]}},"required":["text"],"type":"object"},"PanelKind":{"additionalProperties":false,"properties":{"kind":{"const":"Panel","type":"string"},"spec":{"$ref":"#/components/schemas/PanelSpec"}},"required":["kind","spec"],"type":"object"},"PanelQueryKind":{"additionalProperties":false,"properties":{"kind":{"const":"PanelQuery","type":"string"},"spec":{"$ref":"#/components/schemas/PanelQuerySpec"}},"required":["kind","spec"],"type":"object"},"PanelQuerySpec":{"additionalProperties":false,"properties":{"hidden":{"type":"boolean"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refId":{"default":"A","type":"string"}},"required":["query","refId","hidden"],"type":"object"},"PanelSpec":{"additionalProperties":false,"properties":{"data":{"$ref":"#/components/schemas/QueryGroupKind"},"description":{"type":"string"},"id":{"type":"number"},"links":{"items":{"$ref":"#/components/schemas/DataLink"},"type":"array"},"title":{"type":"string"},"transparent":{"type":"boolean"},"vizConfig":{"$ref":"#/components/schemas/VizConfigKind"}},"required":["id","title","description","links","data","vizConfig"],"type":"object"},"QueryGroupKind":{"additionalProperties":false,"properties":{"kind":{"const":"QueryGroup","type":"string"},"spec":{"$ref":"#/components/schemas/QueryGroupSpec"}},"required":["kind","spec"],"type":"object"},"QueryGroupSpec":{"additionalProperties":false,"properties":{"queries":{"items":{"$ref":"#/components/schemas/PanelQueryKind"},"type":"array"},"queryOptions":{"$ref":"#/components/schemas/QueryOptionsSpec"},"transformations":{"items":{"$ref":"#/components/schemas/TransformationKind"},"type":"array"}},"required":["queries","transformations","queryOptions"],"type":"object"},"QueryOptionsSpec":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"string"},"hideTimeOverride":{"type":"boolean"},"interval":{"type":"string"},"maxDataPoints":{"type":"integer"},"queryCachingTTL":{"type":"integer"},"timeCompare":{"type":"string"},"timeFrom":{"type":"string"},"timeShift":{"type":"string"}},"type":"object"},"QueryVariableKind":{"additionalProperties":false,"description":"Query variable kind","properties":{"kind":{"const":"QueryVariable","type":"string"},"spec":{"$ref":"#/components/schemas/QueryVariableSpec"}},"required":["kind","spec"],"type":"object"},"QueryVariableSpec":{"additionalProperties":false,"description":"Query variable specification","properties":{"allValue":{"type":"string"},"allowCustomValue":{"default":true,"type":"boolean"},"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"definition":{"type":"string"},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"includeAll":{"default":false,"type":"boolean"},"label":{"type":"string"},"multi":{"default":false,"type":"boolean"},"name":{"default":"","type":"string"},"options":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"placeholder":{"type":"string"},"query":{"$ref":"#/components/schemas/DataQueryKind"},"refresh":{"$ref":"#/components/schemas/VariableRefresh","default":"never"},"regex":{"default":"","type":"string"},"regexApplyTo":{"$ref":"#/components/schemas/VariableRegexApplyTo","default":"value"},"skipUrlSync":{"default":false,"type":"boolean"},"sort":{"$ref":"#/components/schemas/VariableSort"},"staticOptions":{"items":{"$ref":"#/components/schemas/VariableOption"},"type":"array"},"staticOptionsOrder":{"enum":["before","after","sorted"],"type":"string"}},"required":["name","current","hide","refresh","skipUrlSync","query","regex","sort","options","multi","includeAll","allowCustomValue"],"type":"object"},"RangeMap":{"additionalProperties":false,"description":"Maps numerical ranges to a display text and color.\nFor example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.","properties":{"options":{"additionalProperties":false,"description":"Range to match against and the result to apply when the value is within the range","properties":{"from":{"description":"Min value of the range. It can be null which means -Infinity","type":"number"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value is within the range"},"to":{"description":"Max value of the range. It can be null which means +Infinity","type":"number"}},"required":["from","to","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RegexMap":{"additionalProperties":false,"description":"Maps regular expressions to replacement text and a color.\nFor example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.","properties":{"options":{"additionalProperties":false,"description":"Regular expression to match against and the result to apply when the value matches the regex","properties":{"pattern":{"description":"Regular expression to match against","type":"string"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the regex"}},"required":["pattern","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"RepeatMode":{"const":"variable","description":"other repeat modes will be added in the future: label, frame","type":"string"},"RepeatOptions":{"additionalProperties":false,"properties":{"direction":{"enum":["h","v"],"type":"string"},"maxPerRow":{"type":"integer"},"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"RowsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowKind":{"additionalProperties":false,"properties":{"kind":{"const":"RowsLayoutRow","type":"string"},"spec":{"$ref":"#/components/schemas/RowsLayoutRowSpec"}},"required":["kind","spec"],"type":"object"},"RowsLayoutRowSpec":{"additionalProperties":false,"properties":{"collapse":{"type":"boolean"},"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"fillScreen":{"type":"boolean"},"hideHeader":{"type":"boolean"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/RowRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"RowsLayoutSpec":{"additionalProperties":false,"properties":{"rows":{"items":{"$ref":"#/components/schemas/RowsLayoutRowKind"},"type":"array"}},"required":["rows"],"type":"object"},"SpecialValueMap":{"additionalProperties":false,"description":"Maps special values like Null, NaN (not a number), and boolean values like true and false to a display text and color.\nSee SpecialValueMatch to see the list of special values.\nFor example, you can configure a special value mapping so that null values appear as N/A.","properties":{"options":{"additionalProperties":false,"properties":{"match":{"$ref":"#/components/schemas/SpecialValueMatch","description":"Special value to match against"},"result":{"$ref":"#/components/schemas/ValueMappingResult","description":"Config to apply when the value matches the special value"}},"required":["match","result"],"type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"SpecialValueMatch":{"description":"Special value types supported by the ` + "`" + `SpecialValueMap` + "`" + `","enum":["true","false","null","nan","null+nan","empty"],"type":"string"},"SwitchVariableKind":{"additionalProperties":false,"properties":{"kind":{"const":"SwitchVariable","type":"string"},"spec":{"$ref":"#/components/schemas/SwitchVariableSpec"}},"required":["kind","spec"],"type":"object"},"SwitchVariableSpec":{"additionalProperties":false,"properties":{"current":{"default":"false","type":"string"},"description":{"type":"string"},"disabledValue":{"default":"false","type":"string"},"enabledValue":{"default":"true","type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","enabledValue","disabledValue","hide","skipUrlSync"],"type":"object"},"TabRepeatOptions":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/RepeatMode"},"value":{"type":"string"}},"required":["mode","value"],"type":"object"},"TabsLayoutKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayout","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutSpec":{"additionalProperties":false,"properties":{"tabs":{"items":{"$ref":"#/components/schemas/TabsLayoutTabKind"},"type":"array"}},"required":["tabs"],"type":"object"},"TabsLayoutTabKind":{"additionalProperties":false,"properties":{"kind":{"const":"TabsLayoutTab","type":"string"},"spec":{"$ref":"#/components/schemas/TabsLayoutTabSpec"}},"required":["kind","spec"],"type":"object"},"TabsLayoutTabSpec":{"additionalProperties":false,"properties":{"conditionalRendering":{"$ref":"#/components/schemas/ConditionalRenderingGroupKind"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"repeat":{"$ref":"#/components/schemas/TabRepeatOptions"},"title":{"type":"string"}},"required":["layout"],"type":"object"},"TextVariableKind":{"additionalProperties":false,"description":"Text variable kind","properties":{"kind":{"const":"TextVariable","type":"string"},"spec":{"$ref":"#/components/schemas/TextVariableSpec"}},"required":["kind","spec"],"type":"object"},"TextVariableSpec":{"additionalProperties":false,"description":"Text variable specification","properties":{"current":{"$ref":"#/components/schemas/VariableOption","default":{"text":"","value":""}},"description":{"type":"string"},"hide":{"$ref":"#/components/schemas/VariableHide","default":"dontHide"},"label":{"type":"string"},"name":{"default":"","type":"string"},"query":{"default":"","type":"string"},"skipUrlSync":{"default":false,"type":"boolean"}},"required":["name","current","query","hide","skipUrlSync"],"type":"object"},"Threshold":{"additionalProperties":false,"properties":{"color":{"type":"string"},"value":{"description":"Value null means -Infinity","type":"number"}},"required":["value","color"],"type":"object"},"ThresholdsConfig":{"additionalProperties":false,"properties":{"mode":{"$ref":"#/components/schemas/ThresholdsMode"},"steps":{"items":{"$ref":"#/components/schemas/Threshold"},"type":"array"}},"required":["mode","steps"],"type":"object"},"ThresholdsMode":{"enum":["absolute","percentage"],"type":"string"},"TimeRangeOption":{"additionalProperties":false,"properties":{"display":{"default":"Last 6 hours","type":"string"},"from":{"default":"now-6h","type":"string"},"to":{"default":"now","type":"string"}},"required":["display","from","to"],"type":"object"},"TimeSettingsSpec":{"additionalProperties":false,"description":"Time configuration\nIt defines the default time config for the time picker, the refresh picker for the specific dashboard.","properties":{"autoRefresh":{"default":"","description":"Refresh rate of dashboard. Represented via interval string, e.g. \"5s\", \"1m\", \"1h\", \"1d\".\nv1: refresh","type":"string"},"autoRefreshIntervals":{"default":["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"],"description":"Interval options available in the refresh picker dropdown.\nv1: timepicker.refresh_intervals","items":{"type":"string"},"type":"array"},"fiscalYearStartMonth":{"default":0,"description":"The month that the fiscal year starts on. 0 = January, 11 = December","type":"integer"},"from":{"default":"now-6h","description":"Start time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"hideTimepicker":{"default":false,"description":"Whether timepicker is visible or not.\nv1: timepicker.hidden","type":"boolean"},"nowDelay":{"description":"Override the now time by entering a time delay. Use this option to accommodate known delays in data aggregation to avoid null values.\nv1: timepicker.nowDelay","type":"string"},"quickRanges":{"description":"Selectable options available in the time picker dropdown. Has no effect on provisioned dashboard.\nv1: timepicker.quick_ranges , not exposed in the UI","items":{"$ref":"#/components/schemas/TimeRangeOption"},"type":"array"},"timezone":{"default":"browser","description":"Timezone of dashboard. Accepted values are IANA TZDB zone ID or \"browser\" or \"utc\".","type":"string"},"to":{"default":"now","description":"End time range for dashboard.\nAccepted values are relative time strings like \"now-6h\" or absolute time strings like \"2020-07-10T08:00:00.000Z\".","type":"string"},"weekStart":{"description":"Day when the week starts. Expressed by the name of the day in lowercase, e.g. \"monday\".","enum":["saturday","monday","sunday"],"type":"string"}},"required":["from","to","autoRefresh","autoRefreshIntervals","hideTimepicker","fiscalYearStartMonth"],"type":"object"},"TransformationKind":{"additionalProperties":false,"properties":{"kind":{"description":"The kind of a TransformationKind is the transformation ID","type":"string"},"spec":{"$ref":"#/components/schemas/DataTransformerConfig"}},"required":["kind","spec"],"type":"object"},"ValueMap":{"additionalProperties":false,"description":"Maps text values to a color or different display text and color.\nFor example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.","properties":{"options":{"additionalProperties":{"$ref":"#/components/schemas/ValueMappingResult"},"description":"Map with \u003cvalue_to_match\u003e: ValueMappingResult. For example: { \"10\": { text: \"Perfection!\", color: \"green\" } }","type":"object"},"type":{"$ref":"#/components/schemas/MappingType"}},"required":["type","options"],"type":"object"},"ValueMapping":{"oneOf":[{"$ref":"#/components/schemas/ValueMap"},{"$ref":"#/components/schemas/RangeMap"},{"$ref":"#/components/schemas/RegexMap"},{"$ref":"#/components/schemas/SpecialValueMap"}]},"ValueMappingResult":{"additionalProperties":false,"description":"Result used as replacement with text and color when the value matches","properties":{"color":{"description":"Text to use when the value matches","type":"string"},"icon":{"description":"Icon to display when the value matches. Only specific visualizations.","type":"string"},"index":{"description":"Position in the mapping array. Only used internally.","type":"integer"},"text":{"description":"Text to display when the value matches","type":"string"}},"type":"object"},"VariableHide":{"description":"Determine if the variable shows on dashboard\nAccepted values are ` + "`" + `dontHide` + "`" + ` (show label and value), ` + "`" + `hideLabel` + "`" + ` (show value only), ` + "`" + `hideVariable` + "`" + ` (show nothing), ` + "`" + `inControlsMenu` + "`" + ` (show in a drop-down menu).","enum":["dontHide","hideLabel","hideVariable","inControlsMenu"],"type":"string"},"VariableKind":{"oneOf":[{"$ref":"#/components/schemas/QueryVariableKind"},{"$ref":"#/components/schemas/TextVariableKind"},{"$ref":"#/components/schemas/ConstantVariableKind"},{"$ref":"#/components/schemas/DatasourceVariableKind"},{"$ref":"#/components/schemas/IntervalVariableKind"},{"$ref":"#/components/schemas/CustomVariableKind"},{"$ref":"#/components/schemas/GroupByVariableKind"},{"$ref":"#/components/schemas/AdhocVariableKind"},{"$ref":"#/components/schemas/SwitchVariableKind"}]},"VariableOption":{"additionalProperties":false,"description":"Variable option specification","properties":{"selected":{"description":"Whether the option is selected or not","type":"boolean"},"text":{"description":"Text to be displayed for the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"value":{"description":"Value of the option","oneOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]}},"required":["text","value"],"type":"object"},"VariableRefresh":{"description":"Options to config when to refresh a variable\n` + "`" + `never` + "`" + `: Never refresh the variable\n` + "`" + `onDashboardLoad` + "`" + `: Queries the data source every time the dashboard loads.\n` + "`" + `onTimeRangeChanged` + "`" + `: Queries the data source when the dashboard time range changes.","enum":["never","onDashboardLoad","onTimeRangeChanged"],"type":"string"},"VariableRegexApplyTo":{"description":"Determine whether regex applies to variable value or display text\nAccepted values are ` + "`" + `value` + "`" + ` (apply to value used in queries) or ` + "`" + `text` + "`" + ` (apply to display text shown to users)","enum":["value","text"],"type":"string"},"VariableSort":{"description":"Sort variable options\nAccepted values are:\n` + "`" + `disabled` + "`" + `: No sorting\n` + "`" + `alphabeticalAsc` + "`" + `: Alphabetical ASC\n` + "`" + `alphabeticalDesc` + "`" + `: Alphabetical DESC\n` + "`" + `numericalAsc` + "`" + `: Numerical ASC\n` + "`" + `numericalDesc` + "`" + `: Numerical DESC\n` + "`" + `alphabeticalCaseInsensitiveAsc` + "`" + `: Alphabetical Case Insensitive ASC\n` + "`" + `alphabeticalCaseInsensitiveDesc` + "`" + `: Alphabetical Case Insensitive DESC\n` + "`" + `naturalAsc` + "`" + `: Natural ASC\n` + "`" + `naturalDesc` + "`" + `: Natural DESC\nVariableSort enum with default value","enum":["disabled","alphabeticalAsc","alphabeticalDesc","numericalAsc","numericalDesc","alphabeticalCaseInsensitiveAsc","alphabeticalCaseInsensitiveDesc","naturalAsc","naturalDesc"],"type":"string"},"VizConfigKind":{"additionalProperties":false,"properties":{"group":{"description":"The group is the plugin ID","type":"string"},"kind":{"const":"VizConfig","type":"string"},"spec":{"$ref":"#/components/schemas/VizConfigSpec"},"version":{"type":"string"}},"required":["kind","group","version","spec"],"type":"object"},"VizConfigSpec":{"additionalProperties":false,"description":"--- Kinds ---","properties":{"fieldConfig":{"$ref":"#/components/schemas/FieldConfigSource"},"options":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"}},"required":["options","fieldConfig"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"annotations":{"items":{"$ref":"#/components/schemas/AnnotationQueryKind"},"type":"array"},"cursorSync":{"$ref":"#/components/schemas/DashboardCursorSync","default":"Off","description":"Configuration of dashboard cursor sync behavior.\n\"Off\" for no shared crosshair or tooltip (default).\n\"Crosshair\" for shared crosshair.\n\"Tooltip\" for shared crosshair AND shared tooltip."},"description":{"description":"Description of dashboard.","type":"string"},"editable":{"default":true,"description":"Whether a dashboard is editable or not.","type":"boolean"},"elements":{"additionalProperties":{"$ref":"#/components/schemas/Element"},"type":"object"},"layout":{"oneOf":[{"$ref":"#/components/schemas/GridLayoutKind"},{"$ref":"#/components/schemas/RowsLayoutKind"},{"$ref":"#/components/schemas/AutoGridLayoutKind"},{"$ref":"#/components/schemas/TabsLayoutKind"}]},"links":{"description":"Links with references to other dashboards or external websites.","items":{"$ref":"#/components/schemas/DashboardLink"},"type":"array"},"liveNow":{"description":"When set to true, the dashboard will redraw panels at an interval matching the pixel width.\nThis will keep data \"moving left\" regardless of the query refresh rate. This setting helps\navoid dashboards presenting stale live data.","type":"boolean"},"preload":{"default":false,"description":"When set to true, the dashboard will load all panels in the dashboard when it's loaded.","type":"boolean"},"revision":{"description":"Plugins only. The version of the dashboard installed together with the plugin.\nThis is used to determine if the dashboard should be updated when the plugin is updated.","type":"integer"},"tags":{"description":"Tags associated with dashboard.","items":{"type":"string"},"type":"array"},"timeSettings":{"$ref":"#/components/schemas/TimeSettingsSpec"},"title":{"description":"Title of dashboard.","type":"string"},"variables":{"description":"Configured template variables.","items":{"$ref":"#/components/schemas/VariableKind"},"type":"array"}},"required":["annotations","cursorSync","elements","layout","links","preload","tags","timeSettings","title","variables"],"type":"object"},"status":{"additionalProperties":false,"properties":{"conversion":{"$ref":"#/components/schemas/ConversionStatus","description":"Optional conversion status."}},"type":"object"}}`) versionSchemaDashboardv2beta1 app.VersionSchema _ = json.Unmarshal(rawSchemaDashboardv2beta1, &versionSchemaDashboardv2beta1) ) diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json new file mode 100644 index 00000000000..7bb2bc70f81 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.legacy-ds-ref.json @@ -0,0 +1,287 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "legacy-ds-ref" + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "panels": [ + { + "datasource": "${datasource}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minimum cluster size" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [10, 10], + "fill": "dash" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "${datasource}", + "editorMode": "code", + "expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})", + "instant": false, + "legendFormat": "{{version}}", + "range": true, + "refId": "B" + } + ], + "title": "Number of Alloy Instances", + "type": "timeseries" + }, + { + "datasource": "${datasource}", + "description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Total" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "${datasource}", + "expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n", + "hide": true, + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + }, + { + "datasource": "${datasource}", + "editorMode": "code", + "expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true, + "refId": "B" + } + ], + "title": "CPU usage", + "type": "timeseries" + } + ], + "time": { + "from": "now-90m", + "to": "now" + }, + "timezone": "utc", + "title": "Legacy DS Panel Query Ref", + "weekStart": "" + } +} + + \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json index a27c33c3239..ac88b83370f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2alpha1.ds-data-query.json @@ -852,6 +852,194 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -914,6 +1102,24 @@ "name": "panel-6" } } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json index 0c787609714..fad72787d19 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v2beta1.ds-data-query.json @@ -879,6 +879,200 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -973,6 +1167,32 @@ "name": "panel-6" } } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json new file mode 100644 index 00000000000..9c6354b2319 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v0alpha1.json @@ -0,0 +1,294 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "legacy-ds-ref" + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "panels": [ + { + "datasource": "${datasource}", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minimum cluster size" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 0 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "${datasource}", + "editorMode": "code", + "expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})", + "instant": false, + "legendFormat": "{{version}}", + "range": true, + "refId": "B" + } + ], + "title": "Number of Alloy Instances", + "type": "timeseries" + }, + { + "datasource": "${datasource}", + "description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Total" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 0 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "datasource": "${datasource}", + "expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n", + "hide": true, + "instant": false, + "legendFormat": "{{instance}}", + "range": true, + "refId": "A" + }, + { + "datasource": "${datasource}", + "editorMode": "code", + "expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true, + "refId": "B" + } + ], + "title": "CPU usage", + "type": "timeseries" + } + ], + "time": { + "from": "now-90m", + "to": "now" + }, + "timezone": "utc", + "title": "Legacy DS Panel Query Ref", + "weekStart": "" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json new file mode 100644 index 00000000000..23affbf4d4a --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2alpha1.json @@ -0,0 +1,405 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "legacy-ds-ref" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-16": { + "kind": "Panel", + "spec": { + "id": 16, + "title": "Number of Alloy Instances", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": { + "editorMode": "code", + "expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})", + "instant": false, + "legendFormat": "{{version}}", + "range": true + } + }, + "datasource": { + "type": "", + "uid": "${datasource}" + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minimum cluster size" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + } + ] + } + } + } + } + }, + "panel-17": { + "kind": "Panel", + "spec": { + "id": 17, + "title": "CPU usage", + "description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": { + "expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n", + "instant": false, + "legendFormat": "{{instance}}", + "range": true + } + }, + "datasource": { + "type": "", + "uid": "${datasource}" + }, + "refId": "A", + "hidden": true + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "", + "spec": { + "editorMode": "code", + "expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true + } + }, + "datasource": { + "type": "", + "uid": "${datasource}" + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Total" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 8, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-16" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 0, + "width": 8, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-17" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "utc", + "from": "now-90m", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Legacy DS Panel Query Ref", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json new file mode 100644 index 00000000000..758ffd9c348 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.legacy-ds-ref.v2beta1.json @@ -0,0 +1,411 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "legacy-ds-ref" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-16": { + "kind": "Panel", + "spec": { + "id": 16, + "title": "Number of Alloy Instances", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "datasource": { + "name": "${datasource}" + }, + "spec": { + "editorMode": "code", + "expr": "count by (version) (alloy_build_info{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\"})", + "instant": false, + "legendFormat": "{{version}}", + "range": true + } + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Minimum cluster size" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + }, + { + "id": "custom.lineStyle", + "value": { + "dash": [ + 10, + 10 + ], + "fill": "dash" + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + } + ] + } + } + } + } + }, + "panel-17": { + "kind": "Panel", + "spec": { + "id": 17, + "title": "CPU usage", + "description": "CPU usage of the Alloy process relative to 1 CPU core.\n\nFor example, 100% means using one entire CPU core.\n", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "datasource": { + "name": "${datasource}" + }, + "spec": { + "expr": "rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval])\n", + "instant": false, + "legendFormat": "{{instance}}", + "range": true + } + }, + "refId": "A", + "hidden": true + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "", + "version": "v0", + "datasource": { + "name": "${datasource}" + }, + "spec": { + "editorMode": "code", + "expr": "sum(rate(alloy_resources_process_cpu_seconds_total{cluster=~\"$cluster\", namespace=~\"$namespace\", job=~\"$job\", instance=~\"$instance\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "Total", + "range": true + } + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "timeCompare": false, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "Total" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": true, + "viz": true + } + } + ] + } + ] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 8, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-16" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 0, + "width": 8, + "height": 9, + "element": { + "kind": "ElementReference", + "name": "panel-17" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "utc", + "from": "now-90m", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Legacy DS Panel Query Ref", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json index 20f70a0a647..b38cf688949 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v0alpha1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json index 9956ad6962f..0b7f512e6f1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v1beta1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json index 09e35c64258..aba5db6146d 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2alpha1.ds-data-query.v2beta1.json @@ -879,6 +879,200 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "datasource", + "version": "v0", + "datasource": { + "name": "-- Dashboard --" + }, + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "12.1.0-pre", + "spec": { + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -941,6 +1135,24 @@ "name": "panel-6" } } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "AutoGridLayoutItem", + "spec": { + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json index 4494023eb13..99bcc3e9581 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v0alpha1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 0, + "y": 6 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 8, + "y": 6 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json index bc8d90d796a..e2d54185ea5 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v1beta1.json @@ -711,6 +711,146 @@ ], "title": "Mixed DS WITHOUT REFS", "type": "timeseries" + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "description": "Panel with a single -- Dashboard -- datasource query", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 0, + "y": 6 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + } + ], + "title": "Single Dashboard DS Query", + "type": "stat" + }, + { + "datasource": { + "type": "mixed", + "uid": "-- Mixed --" + }, + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + } + ] + } + } + }, + "gridPos": { + "h": 3, + "w": 8, + "x": 8, + "y": 6 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.1.0-pre", + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 1, + "refId": "A", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 2, + "refId": "B", + "withTransforms": true + }, + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 3, + "refId": "C", + "withTransforms": true + } + ], + "title": "Multiple Dashboard DS Queries", + "type": "stat" } ], "preload": false, diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json index bb70e99ec48..d3ca201e380 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v2beta1.ds-data-query.v2alpha1.json @@ -852,6 +852,194 @@ } } } + }, + "panel-7": { + "kind": "Panel", + "spec": { + "id": 7, + "title": "Single Dashboard DS Query", + "description": "Panel with a single -- Dashboard -- datasource query", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } + }, + "panel-8": { + "kind": "Panel", + "spec": { + "id": 8, + "title": "Multiple Dashboard DS Queries", + "description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 1, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 2, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "B", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "datasource", + "spec": { + "panelId": 3, + "withTransforms": true + } + }, + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "refId": "C", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "12.1.0-pre", + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + } + ] + }, + "color": { + "mode": "thresholds" + } + }, + "overrides": [] + } + } + } + } } }, "layout": { @@ -946,6 +1134,32 @@ "name": "panel-6" } } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-7" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 8, + "y": 6, + "width": 8, + "height": 3, + "element": { + "kind": "ElementReference", + "name": "panel-8" + } + } } ] } diff --git a/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go index d0679c76e63..df2db1ac5a1 100644 --- a/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v0alpha1_to_v1beta1.go @@ -88,6 +88,11 @@ func ConvertDashboard_V0_to_V1beta1(in *dashv0.Dashboard, out *dashv1.Dashboard, // Which means that we have schemaVersion: 42 dashboards where datasource variable references are still strings normalizeTemplateVariableDatasources(out.Spec.Object) + // Normalize panel and target datasources from string to object format + // This handles legacy dashboards where panels/targets have datasource: "$datasource" (string) + // instead of datasource: { uid: "$datasource" } (object) + normalizePanelDatasources(out.Spec.Object) + return nil } @@ -134,3 +139,62 @@ func isTemplateVariableRef(s string) bool { } return strings.HasPrefix(s, "$") || strings.HasPrefix(s, "${") } + +// normalizePanelDatasources converts panel and target string datasources to object format. +// Legacy dashboards may have panels/targets with datasource: "$datasource" (string). +// This normalizes them to datasource: { uid: "$datasource" } for consistent V1→V2 conversion. +func normalizePanelDatasources(dashboard map[string]interface{}) { + panels, ok := dashboard["panels"].([]interface{}) + if !ok { + return + } + + normalizePanelsDatasources(panels) +} + +// normalizePanelsDatasources normalizes datasources in a list of panels (including nested row panels) +func normalizePanelsDatasources(panels []interface{}) { + for _, panel := range panels { + panelMap, ok := panel.(map[string]interface{}) + if !ok { + continue + } + + // Handle row panels with nested panels + if panelType, _ := panelMap["type"].(string); panelType == "row" { + if nestedPanels, ok := panelMap["panels"].([]interface{}); ok { + normalizePanelsDatasources(nestedPanels) + } + } + + // Normalize panel-level datasource + if ds := panelMap["datasource"]; ds != nil { + if dsStr, ok := ds.(string); ok && isTemplateVariableRef(dsStr) { + panelMap["datasource"] = map[string]interface{}{ + "uid": dsStr, + } + } + } + + // Normalize target-level datasources + targets, ok := panelMap["targets"].([]interface{}) + if !ok { + continue + } + + for _, target := range targets { + targetMap, ok := target.(map[string]interface{}) + if !ok { + continue + } + + if ds := targetMap["datasource"]; ds != nil { + if dsStr, ok := ds.(string); ok && isTemplateVariableRef(dsStr) { + targetMap["datasource"] = map[string]interface{}{ + "uid": dsStr, + } + } + } + } + } +} diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index b63e0146cc2..3eeb61893fa 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -1336,6 +1336,17 @@ func buildCustomVariable(varMap map[string]interface{}, commonProps CommonVariab customVar.Spec.AllValue = &allValue } + if valuesFormat := schemaversion.GetStringValue(varMap, "valuesFormat"); valuesFormat != "" { + switch valuesFormat { + case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson): + format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson + customVar.Spec.ValuesFormat = &format + case string(dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv): + format := dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv + customVar.Spec.ValuesFormat = &format + } + } + return dashv2alpha1.DashboardVariableKind{ CustomVariableKind: customVar, }, nil @@ -2048,6 +2059,12 @@ func transformPanelQueries(ctx context.Context, panelMap map[string]interface{}, Uid: &dsUID, } } + } else if dsStr, ok := ds.(string); ok && isTemplateVariable(dsStr) { + // Handle legacy panel datasource as string (template variable reference e.g., "$datasource") + // Only process template variables - other string values are not supported in V2 format + panelDatasource = &dashv2alpha1.DashboardDataSourceRef{ + Uid: &dsStr, + } } } @@ -2134,6 +2151,10 @@ func transformSingleQuery(ctx context.Context, targetMap map[string]interface{}, // Resolve Grafana datasource UID when type is "datasource" and UID is empty queryDatasourceUID = resolveGrafanaDatasourceUID(queryDatasourceType, queryDatasourceUID) } + } else if dsStr, ok := targetMap["datasource"].(string); ok && isTemplateVariable(dsStr) { + // Handle legacy target datasource as string (template variable reference e.g., "$datasource") + // Only process template variables - other string values are not supported in V2 format + queryDatasourceUID = dsStr } // Use panel datasource if target datasource is missing or empty diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go index 46a2a533d41..5dc7ecf21fd 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v1beta1.go @@ -1195,16 +1195,36 @@ func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, quer // getPanelDatasource determines the panel-level datasource for V1. // Returns: // - Mixed datasource reference if queries use different datasources +// - Mixed datasource reference if multiple queries use Dashboard datasource (they fetch from different panels) +// - Dashboard datasource reference if a single query uses Dashboard datasource // - First query's datasource if all queries use the same datasource // - nil if no queries exist // Compares based on V2 input without runtime resolution: // - If query has explicit datasource.uid → use that UID and type // - Else → use query.Kind as type (empty UID) func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} { + const sharedDashboardQuery = "-- Dashboard --" + if len(queries) == 0 { return nil } + // Count how many queries use Dashboard datasource + // Multiple dashboard queries need mixed mode because they fetch from different panels + // which may have different underlying datasources + dashboardDsQueryCount := 0 + for _, query := range queries { + if query.Spec.Datasource != nil && query.Spec.Datasource.Uid != nil && *query.Spec.Datasource.Uid == sharedDashboardQuery { + dashboardDsQueryCount++ + } + } + if dashboardDsQueryCount > 1 { + return map[string]interface{}{ + "type": "mixed", + "uid": "-- Mixed --", + } + } + var firstUID, firstType string var hasFirst bool @@ -1239,6 +1259,16 @@ func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[stri } } + // Handle case when a single query uses Dashboard datasource. + // This is needed for the frontend to properly activate and fetch data from source panels. + // See DashboardDatasourceBehaviour.tsx for more details. + if firstUID == sharedDashboardQuery { + return map[string]interface{}{ + "type": "datasource", + "uid": sharedDashboardQuery, + } + } + // Not mixed - return the first query's datasource so the panel has a datasource set. // This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource // to resolve the datasource, and if undefined, it falls back to the default datasource diff --git a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go index 5b3a0d115b8..45803b6d7ec 100644 --- a/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go +++ b/apps/dashboard/pkg/migration/conversion/v2alpha1_to_v2beta1.go @@ -685,6 +685,7 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind, SkipUrlSync: in.CustomVariableKind.Spec.SkipUrlSync, Description: in.CustomVariableKind.Spec.Description, AllowCustomValue: in.CustomVariableKind.Spec.AllowCustomValue, + ValuesFormat: convertCustomValuesFormat_V2alpha1_to_V2beta1(in.CustomVariableKind.Spec.ValuesFormat), }, } } @@ -758,6 +759,23 @@ func convertVariable_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardVariableKind, return nil } +func convertCustomValuesFormat_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardCustomVariableSpecValuesFormat) *dashv2beta1.DashboardCustomVariableSpecValuesFormat { + if in == nil { + return nil + } + + switch *in { + case dashv2alpha1.DashboardCustomVariableSpecValuesFormatJson: + v := dashv2beta1.DashboardCustomVariableSpecValuesFormatJson + return &v + case dashv2alpha1.DashboardCustomVariableSpecValuesFormatCsv: + v := dashv2beta1.DashboardCustomVariableSpecValuesFormatCsv + return &v + default: + return nil + } +} + func convertQueryVariableSpec_V2alpha1_to_V2beta1(in *dashv2alpha1.DashboardQueryVariableSpec, out *dashv2beta1.DashboardQueryVariableSpec, scope conversion.Scope) error { out.Name = in.Name out.Current = convertVariableOption_V2alpha1_to_V2beta1(in.Current) diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json index f705124be5b..5b07f246ae3 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-piechart/panel_test_piechart.v42.json @@ -290,7 +290,7 @@ ], "legend": { "displayMode": "table", - "placement": "bottom", + "placement": "right", "showLegend": true, "values": [ "percent" @@ -304,7 +304,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -323,6 +323,15 @@ } ], "title": "Percent", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "^Backend-(.*)$", + "renamePattern": "b-$1" + } + } + ], "type": "piechart" }, { @@ -366,7 +375,7 @@ ], "legend": { "displayMode": "table", - "placement": "bottom", + "placement": "right", "showLegend": true, "values": [ "value" @@ -380,7 +389,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -399,6 +408,15 @@ } ], "title": "Value", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "(.*)", + "renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood" + } + } + ], "type": "piechart" }, { diff --git a/apps/iam/go.mod b/apps/iam/go.mod index aed406c5434..e35a733d350 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -24,8 +24,6 @@ replace github.com/grafana/grafana/apps/alerting/historian => ../alerting/histor replace github.com/grafana/grafana/apps/correlations => ../correlations -replace github.com/grafana/grafana/apps/investigations => ../investigations - replace github.com/grafana/grafana/apps/logsdrilldown => ../logsdrilldown replace github.com/grafana/grafana/apps/playlist => ../playlist @@ -106,25 +104,25 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect - github.com/aws/smithy-go v1.23.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/benbjohnson/clock v1.3.5 // indirect @@ -229,7 +227,7 @@ require ( github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-aws-sdk v1.3.0 // indirect + github.com/grafana/grafana-aws-sdk v1.4.2 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect @@ -242,7 +240,7 @@ require ( github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect - github.com/grafana/sqlds/v4 v4.2.7 // indirect + github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 35997e0d1ec..063696e3d5a 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -82,8 +82,8 @@ cloud.google.com/go/storage v1.55.0 h1:NESjdAToN9u1tmhVqhXCaCwYBuvEhZLLv0gBr+2zn cloud.google.com/go/storage v1.55.0/go.mod h1:ztSmTTwzsdXe5syLVS0YsbFxXuvEmEyZj7v7zChEmuY= cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4= cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= cuelang.org/go v0.11.1 h1:pV+49MX1mmvDm8Qh3Za3M786cty8VKPWzQ1Ho4gZRP0= @@ -238,24 +238,24 @@ github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= -github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= -github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 h1:Nn3qce+OHZuMj/edx4its32uxedAmquCDxtZkrdeiD4= @@ -264,12 +264,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFie github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo= @@ -280,14 +280,16 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 h1:Pwbxovp github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6/go.mod h1:Z4xLt5mXspLKjBV92i165wAJ/3T6TIv4n7RtIS8pWV0= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= -github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= -github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= @@ -747,6 +749,8 @@ github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs= +github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -851,8 +855,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= -github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= +github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM= +github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI= @@ -885,12 +889,12 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= -github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp9KWnSoV/DuymmyIj5aHE0CYlDQ5m2KeXUPAc= -github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ= +github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0= +github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= -github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA= -github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= +github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew= github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec h1:wnzJov9RhSHGaTYGzTygL4qq986fLen8xSqnQgaMd28= github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec/go.mod h1:j1IY7J2rUz7TcTjFVVx6HCpyTlYOJPtXuGRZ7sI+vSo= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= @@ -2321,6 +2325,8 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= diff --git a/apps/iam/pkg/apis/iam/v0alpha1/extensions.go b/apps/iam/pkg/apis/iam/v0alpha1/extensions.go deleted file mode 100644 index c0c5a11f7e3..00000000000 --- a/apps/iam/pkg/apis/iam/v0alpha1/extensions.go +++ /dev/null @@ -1,43 +0,0 @@ -package v0alpha1 - -import ( - "fmt" - - "github.com/grafana/grafana/pkg/apimachinery/utils" -) - -func (u User) AuthID() string { - meta, err := utils.MetaAccessor(&u) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} - -func (s ServiceAccount) AuthID() string { - meta, err := utils.MetaAccessor(&s) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} - -func (t Team) AuthID() string { - meta, err := utils.MetaAccessor(&t) - if err != nil { - return "" - } - // TODO: Workaround until we move all definitions - // After having all resource definitions here in the app, we can remove this - // and we need to change the List authorization to use the MetaAccessor and the GetDeprecatedInternalID method - //nolint:staticcheck - return fmt.Sprintf("%d", meta.GetDeprecatedInternalID()) -} diff --git a/apps/investigations/Makefile b/apps/investigations/Makefile deleted file mode 100644 index bc8d6d30cb5..00000000000 --- a/apps/investigations/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -include ../sdk.mk - -.PHONY: generate # Run Grafana App SDK code generation -generate: install-app-sdk update-app-sdk - @$(APP_SDK_BIN) generate \ - --source=./kinds/ \ - --gogenpath=./pkg/apis \ - --grouping=group \ - --genoperatorstate=false \ - --defencoding=none \ No newline at end of file diff --git a/apps/investigations/example.json b/apps/investigations/example.json deleted file mode 100644 index 0a390fb074f..00000000000 --- a/apps/investigations/example.json +++ /dev/null @@ -1,152 +0,0 @@ -[ - { - "id": "896312ce-65b0-4b50-ade1-e7f04fa22c66", - "title": "Thursday morning investigation", - "hasCustomName": false, - "isFavorite": false, - "collectables": [ - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{detected_level}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\"} | detected_level != \"\"[$__auto])) by (detected_level)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_detected_level", - "title": "detected_level", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:23.637Z" - } - ], - "createdAt": "2025-02-13T11:31:23.636Z", - "updatedAt": "2025-02-13T11:31:23.637Z", - "viewMode": { - "mode": "compact", - "showComments": true, - "showTooltips": false - } - }, - { - "id": "e9cf1958-d0ed-46b7-b597-9052c7648656", - "title": "Thursday morning investigation", - "hasCustomName": false, - "isFavorite": false, - "collectables": [ - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{detected_level}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\"} | detected_level != \"\"[$__auto])) by (detected_level)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_detected_level", - "title": "detected_level", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:23.638Z" - }, - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{service_name}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\",service_name != \"\"} [$__auto])) by (service_name)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_service_name", - "title": "service_name", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:41.507Z" - }, - { - "origin": "Explore Logs", - "type": "timeseries", - "queries": [ - { - "refId": "LABEL_BREAKDOWN_VALUES", - "queryType": "range", - "editorMode": "code", - "supportingQueryType": "grafana-lokiexplore-app", - "legendFormat": "{{service}}", - "expr": "sum(count_over_time({service_name=\"web_app_1\",service != \"\"} [$__auto])) by (service)" - } - ], - "timeRange": { - "to": "2025-02-13T11:31:20.536Z", - "from": "2025-02-13T11:16:20.536Z", - "raw": { - "from": "now-15m", - "to": "now" - } - }, - "datasource": { - "uid": "fe9k7u07b1a0wc" - }, - "url": "http://localhost:3000/a/grafana-lokiexplore-app/explore/service/web_app_1/labels?patterns=%5B%5D&from=now-15m&to=now&var-ds=fe9k7u07b1a0wc&var-filters=service_name%7C%3D%7Cweb_app_1&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilterV2=&var-lineFilters=&urlColumns=%5B%5D&visualizationType=%22logs%22&displayedFields=%5B%5D&timezone=browser&var-all-fields=&var-labelBy=$__all", - "id": "LABEL_BREAKDOWN_VALUES_service", - "title": "service", - "logoPath": "public/plugins/grafana-lokiexplore-app/img/img/logo.svg", - "createdAt": "2025-02-13T11:31:43.698Z" - } - ], - "createdAt": "2025-02-13T11:31:23.637Z", - "updatedAt": "2025-02-13T11:31:43.698Z", - "viewMode": { - "mode": "compact", - "showComments": true, - "showTooltips": false - } - } -] diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod deleted file mode 100644 index 40677b56dab..00000000000 --- a/apps/investigations/go.mod +++ /dev/null @@ -1,102 +0,0 @@ -module github.com/grafana/grafana/apps/investigations - -go 1.25.5 - -require ( - github.com/grafana/grafana-app-sdk v0.48.7 - k8s.io/apimachinery v0.34.3 - k8s.io/klog/v2 v2.130.1 - k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e -) - -require ( - github.com/beorn7/perks v1.0.1 // indirect - github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.13.0 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect - github.com/go-test/deep v1.1.1 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana-app-sdk/logging v0.48.7 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.1 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect - github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/onsi/ginkgo/v2 v2.22.2 // indirect - github.com/onsi/gomega v1.36.2 // indirect - github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect - github.com/prometheus/procfs v0.19.2 // indirect - github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/woodsbury/decimal128 v1.4.0 // indirect - github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.14.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.34.3 // indirect - k8s.io/apiextensions-apiserver v0.34.3 // indirect - k8s.io/client-go v0.34.3 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect - sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect -) diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum deleted file mode 100644 index e5233ba82c9..00000000000 --- a/apps/investigations/go.sum +++ /dev/null @@ -1,264 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbTdLa62d2HDBFdvgSbIGB3eJE8HqhgiL9I= -github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= -github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= -github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= -github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= -github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= -github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -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/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= -github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= -github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= -github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= -github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= -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/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= -github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= -github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= -github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= -github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= -github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= -github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= -github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= -github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= -github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= -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= -gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= -gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= -gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= -k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= -k8s.io/apiextensions-apiserver v0.34.3 h1:p10fGlkDY09eWKOTeUSioxwLukJnm+KuDZdrW71y40g= -k8s.io/apiextensions-apiserver v0.34.3/go.mod h1:aujxvqGFRdb/cmXYfcRTeppN7S2XV/t7WMEc64zB5A0= -k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= -k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= -k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ= -k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= -sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/investigations/kinds/collectable.cue b/apps/investigations/kinds/collectable.cue deleted file mode 100644 index f9fc754a67c..00000000000 --- a/apps/investigations/kinds/collectable.cue +++ /dev/null @@ -1,43 +0,0 @@ -package investigations - -// Collectable represents an item collected during investigation -#Collectable: { - id: string - createdAt: string - - title: string - origin: string - type: string - queries: [...string] // +listType=atomic - timeRange: #TimeRange - datasource: #DatasourceRef - url: string - logoPath?: string - - note: string - noteUpdatedAt: string - - fieldConfig: string -} - -#CollectableSummary: { - id: string - title: string - logoPath: string - origin: string -} - -// TimeRange represents a time range with both absolute and relative values -#TimeRange: { - from: string - to: string - raw: { - from: string - to: string - } -} - -// DatasourceRef is a reference to a datasource -#DatasourceRef: { - uid: string -} diff --git a/apps/investigations/kinds/cue.mod/module.cue b/apps/investigations/kinds/cue.mod/module.cue deleted file mode 100644 index c592b1ce788..00000000000 --- a/apps/investigations/kinds/cue.mod/module.cue +++ /dev/null @@ -1,4 +0,0 @@ -module: "github.com/grafana/grafana/apps/investigations" -language: { - version: "v0.11.0" -} \ No newline at end of file diff --git a/apps/investigations/kinds/investigation.cue b/apps/investigations/kinds/investigation.cue deleted file mode 100644 index 1ade89416b9..00000000000 --- a/apps/investigations/kinds/investigation.cue +++ /dev/null @@ -1,43 +0,0 @@ -package investigations - -investigationV0alpha1: { - kind: "Investigation" - pluralName: "Investigations" - schema: { - spec: { - title: string - createdByProfile: #Person - hasCustomName: bool - isFavorite: bool - overviewNote: string - overviewNoteUpdatedAt: string - collectables: [...#Collectable] // +listType=atomic - viewMode: #ViewMode - } - } -} - -// Type definition for investigation summaries -#InvestigationSummary: { - title: string - createdByProfile: #Person - hasCustomName: bool - isFavorite: bool - overviewNote: string - overviewNoteUpdatedAt: string - viewMode: #ViewMode - collectableSummaries: [...#CollectableSummary] // +listType=atomic -} - -// Person represents a user profile with basic information -#Person: { - uid: string // Unique identifier for the user - name: string // Display name of the user - gravatarUrl: string // URL to user's Gravatar image -} - -#ViewMode: { - mode: "compact" | "full" - showComments: bool - showTooltips: bool -} diff --git a/apps/investigations/kinds/investigationindex.cue b/apps/investigations/kinds/investigationindex.cue deleted file mode 100644 index 65c5e3ae79f..00000000000 --- a/apps/investigations/kinds/investigationindex.cue +++ /dev/null @@ -1,18 +0,0 @@ -package investigations - -investigationIndexV0alpha1:{ - kind: "InvestigationIndex" - pluralName: "InvestigationIndexes" - schema: { - spec: { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - title: string - - // The Person who owns this investigation index - owner: #Person - - // Array of investigation summaries - investigationSummaries: [...#InvestigationSummary] // +listType=atomic - } - } -} diff --git a/apps/investigations/kinds/manifest.cue b/apps/investigations/kinds/manifest.cue deleted file mode 100644 index 947d84be482..00000000000 --- a/apps/investigations/kinds/manifest.cue +++ /dev/null @@ -1,18 +0,0 @@ -package investigations - -manifest: { - appName: "investigations" - groupOverride: "investigations.grafana.app" - versions: { - "v0alpha1": { - codegen: { - ts: {enabled: false} - go: {enabled: true} - } - kinds: [ - investigationV0alpha1, - investigationIndexV0alpha1, - ] - } - } -} \ No newline at end of file diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go b/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go deleted file mode 100644 index bd538eb9c30..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v0alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "investigations.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v0alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go deleted file mode 100644 index dea4d71802d..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_client_gen.go +++ /dev/null @@ -1,80 +0,0 @@ -package v0alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" -) - -type InvestigationClient struct { - client *resource.TypedClient[*Investigation, *InvestigationList] -} - -func NewInvestigationClient(client resource.Client) *InvestigationClient { - return &InvestigationClient{ - client: resource.NewTypedClient[*Investigation, *InvestigationList](client, InvestigationKind()), - } -} - -func NewInvestigationClientFromGenerator(generator resource.ClientGenerator) (*InvestigationClient, error) { - c, err := generator.ClientFor(InvestigationKind()) - if err != nil { - return nil, err - } - return NewInvestigationClient(c), nil -} - -func (c *InvestigationClient) Get(ctx context.Context, identifier resource.Identifier) (*Investigation, error) { - return c.client.Get(ctx, identifier) -} - -func (c *InvestigationClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *InvestigationClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *InvestigationClient) Create(ctx context.Context, obj *Investigation, opts resource.CreateOptions) (*Investigation, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = InvestigationKind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *InvestigationClient) Update(ctx context.Context, obj *Investigation, opts resource.UpdateOptions) (*Investigation, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *InvestigationClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*Investigation, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *InvestigationClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go deleted file mode 100644 index 6bde81b50d3..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// InvestigationJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type InvestigationJSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*InvestigationJSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*InvestigationJSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &InvestigationJSONCodec{} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go deleted file mode 100644 index ffd588fec5e..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type InvestigationMetadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewInvestigationMetadata creates a new InvestigationMetadata object. -func NewInvestigationMetadata() *InvestigationMetadata { - return &InvestigationMetadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go deleted file mode 100644 index f64d7b6e047..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_object_gen.go +++ /dev/null @@ -1,293 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type Investigation struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the Investigation - Spec InvestigationSpec `json:"spec" yaml:"spec"` -} - -func (o *Investigation) GetSpec() any { - return o.Spec -} - -func (o *Investigation) SetSpec(spec any) error { - cast, ok := spec.(InvestigationSpec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *Investigation) GetSubresources() map[string]any { - return map[string]any{} -} - -func (o *Investigation) GetSubresource(name string) (any, bool) { - switch name { - default: - return nil, false - } -} - -func (o *Investigation) SetSubresource(name string, value any) error { - switch name { - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *Investigation) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *Investigation) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *Investigation) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *Investigation) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *Investigation) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *Investigation) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *Investigation) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *Investigation) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *Investigation) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *Investigation) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *Investigation) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *Investigation) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *Investigation) DeepCopy() *Investigation { - cpy := &Investigation{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *Investigation) DeepCopyInto(dst *Investigation) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) -} - -// Interface compliance compile-time check -var _ resource.Object = &Investigation{} - -// +k8s:openapi-gen=true -type InvestigationList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []Investigation `json:"items" yaml:"items"` -} - -func (o *InvestigationList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationList) Copy() resource.ListObject { - cpy := &InvestigationList{ - TypeMeta: o.TypeMeta, - Items: make([]Investigation, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*Investigation); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *InvestigationList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *InvestigationList) SetItems(items []resource.Object) { - o.Items = make([]Investigation, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*Investigation) - } -} - -func (o *InvestigationList) DeepCopy() *InvestigationList { - cpy := &InvestigationList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationList) DeepCopyInto(dst *InvestigationList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &InvestigationList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *InvestigationSpec) DeepCopy() *InvestigationSpec { - cpy := &InvestigationSpec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *InvestigationSpec) DeepCopyInto(dst *InvestigationSpec) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go deleted file mode 100644 index 2a1f03c884a..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaInvestigation = resource.NewSimpleSchema("investigations.grafana.app", "v0alpha1", &Investigation{}, &InvestigationList{}, resource.WithKind("Investigation"), - resource.WithPlural("investigations"), resource.WithScope(resource.NamespacedScope)) - kindInvestigation = resource.Kind{ - Schema: schemaInvestigation, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &InvestigationJSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func InvestigationKind() resource.Kind { - return kindInvestigation -} - -// Schema returns a resource.SimpleSchema representation of Investigation -func InvestigationSchema() *resource.SimpleSchema { - return schemaInvestigation -} - -// Interface compliance checks -var _ resource.Schema = kindInvestigation diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go deleted file mode 100644 index 88d74be54eb..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigation_spec_gen.go +++ /dev/null @@ -1,126 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// Person represents a user profile with basic information -// +k8s:openapi-gen=true -type InvestigationPerson struct { - // Unique identifier for the user - Uid string `json:"uid"` - // Display name of the user - Name string `json:"name"` - // URL to user's Gravatar image - GravatarUrl string `json:"gravatarUrl"` -} - -// NewInvestigationPerson creates a new InvestigationPerson object. -func NewInvestigationPerson() *InvestigationPerson { - return &InvestigationPerson{} -} - -// Collectable represents an item collected during investigation -// +k8s:openapi-gen=true -type InvestigationCollectable struct { - Id string `json:"id"` - CreatedAt string `json:"createdAt"` - Title string `json:"title"` - Origin string `json:"origin"` - Type string `json:"type"` - // +listType=atomic - Queries []string `json:"queries"` - TimeRange InvestigationTimeRange `json:"timeRange"` - Datasource InvestigationDatasourceRef `json:"datasource"` - Url string `json:"url"` - LogoPath *string `json:"logoPath,omitempty"` - Note string `json:"note"` - NoteUpdatedAt string `json:"noteUpdatedAt"` - FieldConfig string `json:"fieldConfig"` -} - -// NewInvestigationCollectable creates a new InvestigationCollectable object. -func NewInvestigationCollectable() *InvestigationCollectable { - return &InvestigationCollectable{ - Queries: []string{}, - TimeRange: *NewInvestigationTimeRange(), - Datasource: *NewInvestigationDatasourceRef(), - } -} - -// TimeRange represents a time range with both absolute and relative values -// +k8s:openapi-gen=true -type InvestigationTimeRange struct { - From string `json:"from"` - To string `json:"to"` - Raw InvestigationV0alpha1TimeRangeRaw `json:"raw"` -} - -// NewInvestigationTimeRange creates a new InvestigationTimeRange object. -func NewInvestigationTimeRange() *InvestigationTimeRange { - return &InvestigationTimeRange{ - Raw: *NewInvestigationV0alpha1TimeRangeRaw(), - } -} - -// DatasourceRef is a reference to a datasource -// +k8s:openapi-gen=true -type InvestigationDatasourceRef struct { - Uid string `json:"uid"` -} - -// NewInvestigationDatasourceRef creates a new InvestigationDatasourceRef object. -func NewInvestigationDatasourceRef() *InvestigationDatasourceRef { - return &InvestigationDatasourceRef{} -} - -// +k8s:openapi-gen=true -type InvestigationViewMode struct { - Mode InvestigationViewModeMode `json:"mode"` - ShowComments bool `json:"showComments"` - ShowTooltips bool `json:"showTooltips"` -} - -// NewInvestigationViewMode creates a new InvestigationViewMode object. -func NewInvestigationViewMode() *InvestigationViewMode { - return &InvestigationViewMode{} -} - -// +k8s:openapi-gen=true -type InvestigationSpec struct { - Title string `json:"title"` - CreatedByProfile InvestigationPerson `json:"createdByProfile"` - HasCustomName bool `json:"hasCustomName"` - IsFavorite bool `json:"isFavorite"` - OverviewNote string `json:"overviewNote"` - OverviewNoteUpdatedAt string `json:"overviewNoteUpdatedAt"` - // +listType=atomic - Collectables []InvestigationCollectable `json:"collectables"` - ViewMode InvestigationViewMode `json:"viewMode"` -} - -// NewInvestigationSpec creates a new InvestigationSpec object. -func NewInvestigationSpec() *InvestigationSpec { - return &InvestigationSpec{ - CreatedByProfile: *NewInvestigationPerson(), - Collectables: []InvestigationCollectable{}, - ViewMode: *NewInvestigationViewMode(), - } -} - -// +k8s:openapi-gen=true -type InvestigationV0alpha1TimeRangeRaw struct { - From string `json:"from"` - To string `json:"to"` -} - -// NewInvestigationV0alpha1TimeRangeRaw creates a new InvestigationV0alpha1TimeRangeRaw object. -func NewInvestigationV0alpha1TimeRangeRaw() *InvestigationV0alpha1TimeRangeRaw { - return &InvestigationV0alpha1TimeRangeRaw{} -} - -// +k8s:openapi-gen=true -type InvestigationViewModeMode string - -const ( - InvestigationViewModeModeCompact InvestigationViewModeMode = "compact" - InvestigationViewModeModeFull InvestigationViewModeMode = "full" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go deleted file mode 100644 index dafe3e1012e..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_client_gen.go +++ /dev/null @@ -1,80 +0,0 @@ -package v0alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" -) - -type InvestigationIndexClient struct { - client *resource.TypedClient[*InvestigationIndex, *InvestigationIndexList] -} - -func NewInvestigationIndexClient(client resource.Client) *InvestigationIndexClient { - return &InvestigationIndexClient{ - client: resource.NewTypedClient[*InvestigationIndex, *InvestigationIndexList](client, InvestigationIndexKind()), - } -} - -func NewInvestigationIndexClientFromGenerator(generator resource.ClientGenerator) (*InvestigationIndexClient, error) { - c, err := generator.ClientFor(InvestigationIndexKind()) - if err != nil { - return nil, err - } - return NewInvestigationIndexClient(c), nil -} - -func (c *InvestigationIndexClient) Get(ctx context.Context, identifier resource.Identifier) (*InvestigationIndex, error) { - return c.client.Get(ctx, identifier) -} - -func (c *InvestigationIndexClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationIndexList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *InvestigationIndexClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*InvestigationIndexList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *InvestigationIndexClient) Create(ctx context.Context, obj *InvestigationIndex, opts resource.CreateOptions) (*InvestigationIndex, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = InvestigationIndexKind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *InvestigationIndexClient) Update(ctx context.Context, obj *InvestigationIndex, opts resource.UpdateOptions) (*InvestigationIndex, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *InvestigationIndexClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*InvestigationIndex, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *InvestigationIndexClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go deleted file mode 100644 index 0496ce4af6f..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// InvestigationIndexJSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type InvestigationIndexJSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*InvestigationIndexJSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*InvestigationIndexJSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &InvestigationIndexJSONCodec{} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go deleted file mode 100644 index e7bef02a137..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type InvestigationIndexMetadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewInvestigationIndexMetadata creates a new InvestigationIndexMetadata object. -func NewInvestigationIndexMetadata() *InvestigationIndexMetadata { - return &InvestigationIndexMetadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go deleted file mode 100644 index b8272b4fae7..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_object_gen.go +++ /dev/null @@ -1,293 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type InvestigationIndex struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the InvestigationIndex - Spec InvestigationIndexSpec `json:"spec" yaml:"spec"` -} - -func (o *InvestigationIndex) GetSpec() any { - return o.Spec -} - -func (o *InvestigationIndex) SetSpec(spec any) error { - cast, ok := spec.(InvestigationIndexSpec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *InvestigationIndex) GetSubresources() map[string]any { - return map[string]any{} -} - -func (o *InvestigationIndex) GetSubresource(name string) (any, bool) { - switch name { - default: - return nil, false - } -} - -func (o *InvestigationIndex) SetSubresource(name string, value any) error { - switch name { - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *InvestigationIndex) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *InvestigationIndex) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *InvestigationIndex) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *InvestigationIndex) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *InvestigationIndex) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *InvestigationIndex) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *InvestigationIndex) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *InvestigationIndex) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *InvestigationIndex) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *InvestigationIndex) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *InvestigationIndex) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *InvestigationIndex) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationIndex) DeepCopy() *InvestigationIndex { - cpy := &InvestigationIndex{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationIndex) DeepCopyInto(dst *InvestigationIndex) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) -} - -// Interface compliance compile-time check -var _ resource.Object = &InvestigationIndex{} - -// +k8s:openapi-gen=true -type InvestigationIndexList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []InvestigationIndex `json:"items" yaml:"items"` -} - -func (o *InvestigationIndexList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *InvestigationIndexList) Copy() resource.ListObject { - cpy := &InvestigationIndexList{ - TypeMeta: o.TypeMeta, - Items: make([]InvestigationIndex, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*InvestigationIndex); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *InvestigationIndexList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *InvestigationIndexList) SetItems(items []resource.Object) { - o.Items = make([]InvestigationIndex, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*InvestigationIndex) - } -} - -func (o *InvestigationIndexList) DeepCopy() *InvestigationIndexList { - cpy := &InvestigationIndexList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *InvestigationIndexList) DeepCopyInto(dst *InvestigationIndexList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &InvestigationIndexList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *InvestigationIndexSpec) DeepCopy() *InvestigationIndexSpec { - cpy := &InvestigationIndexSpec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *InvestigationIndexSpec) DeepCopyInto(dst *InvestigationIndexSpec) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go deleted file mode 100644 index 3a0041dc6b8..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v0alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaInvestigationIndex = resource.NewSimpleSchema("investigations.grafana.app", "v0alpha1", &InvestigationIndex{}, &InvestigationIndexList{}, resource.WithKind("InvestigationIndex"), - resource.WithPlural("investigationindexes"), resource.WithScope(resource.NamespacedScope)) - kindInvestigationIndex = resource.Kind{ - Schema: schemaInvestigationIndex, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &InvestigationIndexJSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func InvestigationIndexKind() resource.Kind { - return kindInvestigationIndex -} - -// Schema returns a resource.SimpleSchema representation of InvestigationIndex -func InvestigationIndexSchema() *resource.SimpleSchema { - return schemaInvestigationIndex -} - -// Interface compliance checks -var _ resource.Schema = kindInvestigationIndex diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go deleted file mode 100644 index 44be927c850..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_spec_gen.go +++ /dev/null @@ -1,94 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// Person represents a user profile with basic information -// +k8s:openapi-gen=true -type InvestigationIndexPerson struct { - // Unique identifier for the user - Uid string `json:"uid"` - // Display name of the user - Name string `json:"name"` - // URL to user's Gravatar image - GravatarUrl string `json:"gravatarUrl"` -} - -// NewInvestigationIndexPerson creates a new InvestigationIndexPerson object. -func NewInvestigationIndexPerson() *InvestigationIndexPerson { - return &InvestigationIndexPerson{} -} - -// Type definition for investigation summaries -// +k8s:openapi-gen=true -type InvestigationIndexInvestigationSummary struct { - Title string `json:"title"` - CreatedByProfile InvestigationIndexPerson `json:"createdByProfile"` - HasCustomName bool `json:"hasCustomName"` - IsFavorite bool `json:"isFavorite"` - OverviewNote string `json:"overviewNote"` - OverviewNoteUpdatedAt string `json:"overviewNoteUpdatedAt"` - ViewMode InvestigationIndexViewMode `json:"viewMode"` - // +listType=atomic - CollectableSummaries []InvestigationIndexCollectableSummary `json:"collectableSummaries"` -} - -// NewInvestigationIndexInvestigationSummary creates a new InvestigationIndexInvestigationSummary object. -func NewInvestigationIndexInvestigationSummary() *InvestigationIndexInvestigationSummary { - return &InvestigationIndexInvestigationSummary{ - CreatedByProfile: *NewInvestigationIndexPerson(), - ViewMode: *NewInvestigationIndexViewMode(), - CollectableSummaries: []InvestigationIndexCollectableSummary{}, - } -} - -// +k8s:openapi-gen=true -type InvestigationIndexViewMode struct { - Mode InvestigationIndexViewModeMode `json:"mode"` - ShowComments bool `json:"showComments"` - ShowTooltips bool `json:"showTooltips"` -} - -// NewInvestigationIndexViewMode creates a new InvestigationIndexViewMode object. -func NewInvestigationIndexViewMode() *InvestigationIndexViewMode { - return &InvestigationIndexViewMode{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexCollectableSummary struct { - Id string `json:"id"` - Title string `json:"title"` - LogoPath string `json:"logoPath"` - Origin string `json:"origin"` -} - -// NewInvestigationIndexCollectableSummary creates a new InvestigationIndexCollectableSummary object. -func NewInvestigationIndexCollectableSummary() *InvestigationIndexCollectableSummary { - return &InvestigationIndexCollectableSummary{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexSpec struct { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - Title string `json:"title"` - // The Person who owns this investigation index - Owner InvestigationIndexPerson `json:"owner"` - // Array of investigation summaries - // +listType=atomic - InvestigationSummaries []InvestigationIndexInvestigationSummary `json:"investigationSummaries"` -} - -// NewInvestigationIndexSpec creates a new InvestigationIndexSpec object. -func NewInvestigationIndexSpec() *InvestigationIndexSpec { - return &InvestigationIndexSpec{ - Owner: *NewInvestigationIndexPerson(), - InvestigationSummaries: []InvestigationIndexInvestigationSummary{}, - } -} - -// +k8s:openapi-gen=true -type InvestigationIndexViewModeMode string - -const ( - InvestigationIndexViewModeModeCompact InvestigationIndexViewModeMode = "compact" - InvestigationIndexViewModeModeFull InvestigationIndexViewModeMode = "full" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go deleted file mode 100644 index 455d39bdb48..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/investigationindex_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v0alpha1 - -// +k8s:openapi-gen=true -type InvestigationIndexstatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State InvestigationIndexStatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewInvestigationIndexstatusOperatorState creates a new InvestigationIndexstatusOperatorState object. -func NewInvestigationIndexstatusOperatorState() *InvestigationIndexstatusOperatorState { - return &InvestigationIndexstatusOperatorState{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexStatus struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]InvestigationIndexstatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewInvestigationIndexStatus creates a new InvestigationIndexStatus object. -func NewInvestigationIndexStatus() *InvestigationIndexStatus { - return &InvestigationIndexStatus{} -} - -// +k8s:openapi-gen=true -type InvestigationIndexStatusOperatorStateState string - -const ( - InvestigationIndexStatusOperatorStateStateSuccess InvestigationIndexStatusOperatorStateState = "success" - InvestigationIndexStatusOperatorStateStateInProgress InvestigationIndexStatusOperatorStateState = "in_progress" - InvestigationIndexStatusOperatorStateStateFailed InvestigationIndexStatusOperatorStateState = "failed" -) diff --git a/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go b/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go deleted file mode 100644 index 4a095da63fc..00000000000 --- a/apps/investigations/pkg/apis/investigations/v0alpha1/zz_openapi_gen.go +++ /dev/null @@ -1,1014 +0,0 @@ -//go:build !ignore_autogenerated -// +build !ignore_autogenerated - -// Code generated by grafana-app-sdk. DO NOT EDIT. - -package v0alpha1 - -import ( - common "k8s.io/kube-openapi/pkg/common" - spec "k8s.io/kube-openapi/pkg/validation/spec" -) - -func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { - return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.Investigation": schema_pkg_apis_investigations_v0alpha1_Investigation(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable": schema_pkg_apis_investigations_v0alpha1_InvestigationCollectable(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef": schema_pkg_apis_investigations_v0alpha1_InvestigationDatasourceRef(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex": schema_pkg_apis_investigations_v0alpha1_InvestigationIndex(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexCollectableSummary(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexInvestigationSummary(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexList": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexList(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexPerson(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexSpec(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexStatus(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexViewMode(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState": schema_pkg_apis_investigations_v0alpha1_InvestigationIndexstatusOperatorState(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationList": schema_pkg_apis_investigations_v0alpha1_InvestigationList(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson": schema_pkg_apis_investigations_v0alpha1_InvestigationPerson(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec": schema_pkg_apis_investigations_v0alpha1_InvestigationSpec(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus": schema_pkg_apis_investigations_v0alpha1_InvestigationStatus(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange": schema_pkg_apis_investigations_v0alpha1_InvestigationTimeRange(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw": schema_pkg_apis_investigations_v0alpha1_InvestigationV0alpha1TimeRangeRaw(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode": schema_pkg_apis_investigations_v0alpha1_InvestigationViewMode(ref), - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState": schema_pkg_apis_investigations_v0alpha1_InvestigationstatusOperatorState(ref), - } -} - -func schema_pkg_apis_investigations_v0alpha1_Investigation(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Description: "Spec is the spec of the Investigation", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec"), - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus"), - }, - }, - }, - Required: []string{"metadata", "spec", "status"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationSpec", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationCollectable(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Collectable represents an item collected during investigation", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "id": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "origin": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "type": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "queries": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - "timeRange": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange"), - }, - }, - "datasource": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef"), - }, - }, - "url": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "logoPath": { - SchemaProps: spec.SchemaProps{ - Type: []string{"string"}, - Format: "", - }, - }, - "note": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "noteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "fieldConfig": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"id", "createdAt", "title", "origin", "type", "queries", "timeRange", "datasource", "url", "note", "noteUpdatedAt", "fieldConfig"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationDatasourceRef", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationTimeRange"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationDatasourceRef(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "DatasourceRef is a reference to a datasource", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndex(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Description: "Spec is the spec of the InvestigationIndex", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec"), - }, - }, - "status": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus"), - }, - }, - }, - Required: []string{"metadata", "spec", "status"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexSpec", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexCollectableSummary(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "id": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "logoPath": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "origin": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"id", "title", "logoPath", "origin"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexInvestigationSummary(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Type definition for investigation summaries", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdByProfile": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"), - }, - }, - "hasCustomName": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "isFavorite": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "overviewNote": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "overviewNoteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "viewMode": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode"), - }, - }, - "collectableSummaries": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary"), - }, - }, - }, - }, - }, - }, - Required: []string{"title", "createdByProfile", "hasCustomName", "isFavorite", "overviewNote", "overviewNoteUpdatedAt", "viewMode", "collectableSummaries"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexCollectableSummary", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexViewMode"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex"), - }, - }, - }, - }, - }, - }, - Required: []string{"metadata", "items"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndex", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexPerson(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Person represents a user profile with basic information", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "Unique identifier for the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Display name of the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gravatarUrl": { - SchemaProps: spec.SchemaProps{ - Description: "URL to user's Gravatar image", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid", "name", "gravatarUrl"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Description: "Title of the index, e.g. 'Favorites' or 'My Investigations'", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "owner": { - SchemaProps: spec.SchemaProps{ - Description: "The Person who owns this investigation index", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"), - }, - }, - "investigationSummaries": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Description: "Array of investigation summaries", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary"), - }, - }, - }, - }, - }, - }, - Required: []string{"title", "owner", "investigationSummaries"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexInvestigationSummary", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexPerson"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "operatorStates": { - SchemaProps: spec.SchemaProps{ - Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState"), - }, - }, - }, - }, - }, - "additionalFields": { - SchemaProps: spec.SchemaProps{ - Description: "additionalFields is reserved for future use", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationIndexstatusOperatorState"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexViewMode(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "mode": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "showComments": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "showTooltips": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - Required: []string{"mode", "showComments", "showTooltips"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationIndexstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "lastEvaluation": { - SchemaProps: spec.SchemaProps{ - Description: "lastEvaluation is the ResourceVersion last evaluated", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "state": { - SchemaProps: spec.SchemaProps{ - Description: "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "descriptiveState": { - SchemaProps: spec.SchemaProps{ - Description: "descriptiveState is an optional more descriptive state field which has no requirements on format", - Type: []string{"string"}, - Format: "", - }, - }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "details contains any extra information that is operator-specific", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"lastEvaluation", "state"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.Investigation"), - }, - }, - }, - }, - }, - }, - Required: []string{"metadata", "items"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.Investigation", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationPerson(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "Person represents a user profile with basic information", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "uid": { - SchemaProps: spec.SchemaProps{ - Description: "Unique identifier for the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "name": { - SchemaProps: spec.SchemaProps{ - Description: "Display name of the user", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "gravatarUrl": { - SchemaProps: spec.SchemaProps{ - Description: "URL to user's Gravatar image", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"uid", "name", "gravatarUrl"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "spec is the schema of our resource", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "createdByProfile": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson"), - }, - }, - "hasCustomName": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "isFavorite": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "overviewNote": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "overviewNoteUpdatedAt": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "collectables": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "atomic", - }, - }, - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable"), - }, - }, - }, - }, - }, - "viewMode": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode"), - }, - }, - }, - Required: []string{"title", "createdByProfile", "hasCustomName", "isFavorite", "overviewNote", "overviewNoteUpdatedAt", "collectables", "viewMode"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationCollectable", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationPerson", "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationViewMode"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "operatorStates": { - SchemaProps: spec.SchemaProps{ - Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState"), - }, - }, - }, - }, - }, - "additionalFields": { - SchemaProps: spec.SchemaProps{ - Description: "additionalFields is reserved for future use", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationstatusOperatorState"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationTimeRange(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Description: "TimeRange represents a time range with both absolute and relative values", - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "from": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "to": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "raw": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw"), - }, - }, - }, - Required: []string{"from", "to", "raw"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1.InvestigationV0alpha1TimeRangeRaw"}, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationV0alpha1TimeRangeRaw(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "from": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "to": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - Required: []string{"from", "to"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationViewMode(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "mode": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "showComments": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - "showTooltips": { - SchemaProps: spec.SchemaProps{ - Default: false, - Type: []string{"boolean"}, - Format: "", - }, - }, - }, - Required: []string{"mode", "showComments", "showTooltips"}, - }, - }, - } -} - -func schema_pkg_apis_investigations_v0alpha1_InvestigationstatusOperatorState(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "lastEvaluation": { - SchemaProps: spec.SchemaProps{ - Description: "lastEvaluation is the ResourceVersion last evaluated", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "state": { - SchemaProps: spec.SchemaProps{ - Description: "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "descriptiveState": { - SchemaProps: spec.SchemaProps{ - Description: "descriptiveState is an optional more descriptive state field which has no requirements on format", - Type: []string{"string"}, - Format: "", - }, - }, - "details": { - SchemaProps: spec.SchemaProps{ - Description: "details contains any extra information that is operator-specific", - Type: []string{"object"}, - AdditionalProperties: &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"lastEvaluation", "state"}, - }, - }, - } -} diff --git a/apps/investigations/pkg/apis/investigations_manifest.go b/apps/investigations/pkg/apis/investigations_manifest.go deleted file mode 100644 index 249e9b49758..00000000000 --- a/apps/investigations/pkg/apis/investigations_manifest.go +++ /dev/null @@ -1,136 +0,0 @@ -// -// This file is generated by grafana-app-sdk -// DO NOT EDIT -// - -package apis - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - v0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" -) - -var ( - rawSchemaInvestigationv0alpha1 = []byte(`{"Collectable":{"additionalProperties":false,"description":"Collectable represents an item collected during investigation","properties":{"createdAt":{"type":"string"},"datasource":{"$ref":"#/components/schemas/DatasourceRef"},"fieldConfig":{"type":"string"},"id":{"type":"string"},"logoPath":{"type":"string"},"note":{"type":"string"},"noteUpdatedAt":{"type":"string"},"origin":{"type":"string"},"queries":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"timeRange":{"$ref":"#/components/schemas/TimeRange"},"title":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"required":["id","createdAt","title","origin","type","queries","timeRange","datasource","url","note","noteUpdatedAt","fieldConfig"],"type":"object"},"DatasourceRef":{"additionalProperties":false,"description":"DatasourceRef is a reference to a datasource","properties":{"uid":{"type":"string"}},"required":["uid"],"type":"object"},"Investigation":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"Person":{"additionalProperties":false,"description":"Person represents a user profile with basic information","properties":{"gravatarUrl":{"description":"URL to user's Gravatar image","type":"string"},"name":{"description":"Display name of the user","type":"string"},"uid":{"description":"Unique identifier for the user","type":"string"}},"required":["uid","name","gravatarUrl"],"type":"object"},"TimeRange":{"additionalProperties":false,"description":"TimeRange represents a time range with both absolute and relative values","properties":{"from":{"type":"string"},"raw":{"additionalProperties":false,"properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"],"type":"object"},"to":{"type":"string"}},"required":["from","to","raw"],"type":"object"},"ViewMode":{"additionalProperties":false,"properties":{"mode":{"enum":["compact","full"],"type":"string"},"showComments":{"type":"boolean"},"showTooltips":{"type":"boolean"}},"required":["mode","showComments","showTooltips"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"collectables":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Collectable"},"type":"array"},"createdByProfile":{"$ref":"#/components/schemas/Person"},"hasCustomName":{"type":"boolean"},"isFavorite":{"type":"boolean"},"overviewNote":{"type":"string"},"overviewNoteUpdatedAt":{"type":"string"},"title":{"type":"string"},"viewMode":{"$ref":"#/components/schemas/ViewMode"}},"required":["title","createdByProfile","hasCustomName","isFavorite","overviewNote","overviewNoteUpdatedAt","collectables","viewMode"],"type":"object"}}`) - versionSchemaInvestigationv0alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaInvestigationv0alpha1, &versionSchemaInvestigationv0alpha1) - rawSchemaInvestigationIndexv0alpha1 = []byte(`{"CollectableSummary":{"additionalProperties":false,"properties":{"id":{"type":"string"},"logoPath":{"type":"string"},"origin":{"type":"string"},"title":{"type":"string"}},"required":["id","title","logoPath","origin"],"type":"object"},"InvestigationIndex":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"InvestigationSummary":{"additionalProperties":false,"description":"Type definition for investigation summaries","properties":{"collectableSummaries":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/CollectableSummary"},"type":"array"},"createdByProfile":{"$ref":"#/components/schemas/Person"},"hasCustomName":{"type":"boolean"},"isFavorite":{"type":"boolean"},"overviewNote":{"type":"string"},"overviewNoteUpdatedAt":{"type":"string"},"title":{"type":"string"},"viewMode":{"$ref":"#/components/schemas/ViewMode"}},"required":["title","createdByProfile","hasCustomName","isFavorite","overviewNote","overviewNoteUpdatedAt","viewMode","collectableSummaries"],"type":"object"},"Person":{"additionalProperties":false,"description":"Person represents a user profile with basic information","properties":{"gravatarUrl":{"description":"URL to user's Gravatar image","type":"string"},"name":{"description":"Display name of the user","type":"string"},"uid":{"description":"Unique identifier for the user","type":"string"}},"required":["uid","name","gravatarUrl"],"type":"object"},"ViewMode":{"additionalProperties":false,"properties":{"mode":{"enum":["compact","full"],"type":"string"},"showComments":{"type":"boolean"},"showTooltips":{"type":"boolean"}},"required":["mode","showComments","showTooltips"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"investigationSummaries":{"description":"Array of investigation summaries\n+listType=atomic","items":{"$ref":"#/components/schemas/InvestigationSummary"},"type":"array"},"owner":{"$ref":"#/components/schemas/Person","description":"The Person who owns this investigation index"},"title":{"description":"Title of the index, e.g. 'Favorites' or 'My Investigations'","type":"string"}},"required":["title","owner","investigationSummaries"],"type":"object"}}`) - versionSchemaInvestigationIndexv0alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaInvestigationIndexv0alpha1, &versionSchemaInvestigationIndexv0alpha1) -) - -var appManifestData = app.ManifestData{ - AppName: "investigations", - Group: "investigations.grafana.app", - PreferredVersion: "v0alpha1", - Versions: []app.ManifestVersion{ - { - Name: "v0alpha1", - Served: true, - Kinds: []app.ManifestVersionKind{ - { - Kind: "Investigation", - Plural: "Investigations", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaInvestigationv0alpha1, - }, - - { - Kind: "InvestigationIndex", - Plural: "InvestigationIndexes", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaInvestigationIndexv0alpha1, - }, - }, - Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, - }, - }, - }, -} - -func LocalManifest() app.Manifest { - return app.NewEmbeddedManifest(appManifestData) -} - -func RemoteManifest() app.Manifest { - return app.NewAPIServerManifest("investigations") -} - -var kindVersionToGoType = map[string]resource.Kind{ - "Investigation/v0alpha1": v0alpha1.InvestigationKind(), - "InvestigationIndex/v0alpha1": v0alpha1.InvestigationIndexKind(), -} - -// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. -// If there is no association for the provided Kind and Version, exists will return false. -func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { - goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] - return goType, exists -} - -var customRouteToGoResponseType = map[string]any{} - -// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. -// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. -// If there is no association for the provided kind, version, custom route path, and method, exists will return false. -// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) -func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoParamsType = map[string]runtime.Object{} - -func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoRequestBodyType = map[string]any{} - -func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -type GoTypeAssociator struct{} - -func NewGoTypeAssociator() *GoTypeAssociator { - return &GoTypeAssociator{} -} - -func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { - return ManifestGoTypeAssociator(kind, version) -} -func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { - return ManifestCustomRouteQueryAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) -} diff --git a/apps/investigations/pkg/app/investigations_app.go b/apps/investigations/pkg/app/investigations_app.go deleted file mode 100644 index 516d278788d..00000000000 --- a/apps/investigations/pkg/app/investigations_app.go +++ /dev/null @@ -1,62 +0,0 @@ -package app - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/operator" - "github.com/grafana/grafana-app-sdk/resource" - "github.com/grafana/grafana-app-sdk/simple" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/klog/v2" - - investigationsv0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" -) - -func New(cfg app.Config) (app.App, error) { - var err error - simpleConfig := simple.AppConfig{ - Name: "investigation", - KubeConfig: cfg.KubeConfig, - InformerConfig: simple.AppInformerConfig{ - InformerOptions: operator.InformerOptions{ - ErrorHandler: func(_ context.Context, err error) { - klog.ErrorS(err, "Informer processing error") - }, - }, - }, - ManagedKinds: []simple.AppManagedKind{ - { - Kind: investigationsv0alpha1.InvestigationKind(), - }, - { - Kind: investigationsv0alpha1.InvestigationIndexKind(), - }, - }, - } - - a, err := simple.NewApp(simpleConfig) - if err != nil { - return nil, err - } - - err = a.ValidateManifest(cfg.ManifestData) - if err != nil { - return nil, err - } - - return a, nil -} - -func GetKinds() map[schema.GroupVersion][]resource.Kind { - gv := schema.GroupVersion{ - Group: investigationsv0alpha1.InvestigationKind().Group(), - Version: investigationsv0alpha1.InvestigationKind().Version(), - } - return map[schema.GroupVersion][]resource.Kind{ - gv: { - investigationsv0alpha1.InvestigationKind(), - investigationsv0alpha1.InvestigationIndexKind(), - }, - } -} diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts b/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts deleted file mode 100644 index c46622cd502..00000000000 --- a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.spec.gen.ts +++ /dev/null @@ -1,115 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -// Person represents a user profile with basic information -export interface Person { - // Unique identifier for the user - uid: string; - // Display name of the user - name: string; - // URL to user's Gravatar image - gravatarUrl: string; -} - -export const defaultPerson = (): Person => ({ - uid: "", - name: "", - gravatarUrl: "", -}); - -// Collectable represents an item collected during investigation -export interface Collectable { - id: string; - createdAt: string; - title: string; - origin: string; - type: string; - // +listType=atomic - queries: string[]; - timeRange: TimeRange; - datasource: DatasourceRef; - url: string; - logoPath?: string; - note: string; - noteUpdatedAt: string; - fieldConfig: string; -} - -export const defaultCollectable = (): Collectable => ({ - id: "", - createdAt: "", - title: "", - origin: "", - type: "", - queries: [], - timeRange: defaultTimeRange(), - datasource: defaultDatasourceRef(), - url: "", - note: "", - noteUpdatedAt: "", - fieldConfig: "", -}); - -// TimeRange represents a time range with both absolute and relative values -export interface TimeRange { - from: string; - to: string; - raw: { - from: string; - to: string; - }; -} - -export const defaultTimeRange = (): TimeRange => ({ - from: "", - to: "", - raw: { - from: "", - to: "", -}, -}); - -// DatasourceRef is a reference to a datasource -export interface DatasourceRef { - uid: string; -} - -export const defaultDatasourceRef = (): DatasourceRef => ({ - uid: "", -}); - -export interface ViewMode { - mode: "compact" | "full"; - showComments: boolean; - showTooltips: boolean; -} - -export const defaultViewMode = (): ViewMode => ({ - mode: "compact", - showComments: false, - showTooltips: false, -}); - -// spec is the schema of our resource -export interface Spec { - title: string; - createdByProfile: Person; - hasCustomName: boolean; - isFavorite: boolean; - overviewNote: string; - overviewNoteUpdatedAt: string; - // +listType=atomic - collectables: Collectable[]; - viewMode: ViewMode; -} - -export const defaultSpec = (): Spec => ({ - title: "", - createdByProfile: defaultPerson(), - hasCustomName: false, - isFavorite: false, - overviewNote: "", - overviewNoteUpdatedAt: "", - collectables: [], - viewMode: defaultViewMode(), -}); - diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts deleted file mode 100644 index 7d88430d451..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/investigationindex_object_gen.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * This file was generated by grafana-app-sdk. DO NOT EDIT. - */ -import { Spec } from './types.spec.gen'; -import { Status } from './types.status.gen'; - -export interface Metadata { - name: string; - namespace: string; - generateName?: string; - selfLink?: string; - uid?: string; - resourceVersion?: string; - generation?: number; - creationTimestamp?: string; - deletionTimestamp?: string; - deletionGracePeriodSeconds?: number; - labels?: Record; - annotations?: Record; - ownerReferences?: OwnerReference[]; - finalizers?: string[]; - managedFields?: ManagedFieldsEntry[]; -} - -export interface OwnerReference { - apiVersion: string; - kind: string; - name: string; - uid: string; - controller?: boolean; - blockOwnerDeletion?: boolean; -} - -export interface ManagedFieldsEntry { - manager?: string; - operation?: string; - apiVersion?: string; - time?: string; - fieldsType?: string; - subresource?: string; -} - -export interface InvestigationIndex { - kind: string; - apiVersion: string; - metadata: Metadata; - spec: Spec; - status: Status; -} diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.metadata.gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.metadata.gen.ts deleted file mode 100644 index 4377f3c1d08..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.metadata.gen.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -export interface Metadata { - updateTimestamp: string; - createdBy: string; - uid: string; - creationTimestamp: string; - deletionTimestamp?: string; - finalizers: string[]; - resourceVersion: string; - generation: number; - updatedBy: string; - labels: Record; -} - -export const defaultMetadata = (): Metadata => ({ - updateTimestamp: "", - createdBy: "", - uid: "", - creationTimestamp: "", - finalizers: [], - resourceVersion: "", - generation: 0, - updatedBy: "", - labels: {}, -}); - diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts deleted file mode 100644 index f0d2f25d96b..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.spec.gen.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -// Person represents a user profile with basic information -export interface Person { - // Unique identifier for the user - uid: string; - // Display name of the user - name: string; - // URL to user's Gravatar image - gravatarUrl: string; -} - -export const defaultPerson = (): Person => ({ - uid: "", - name: "", - gravatarUrl: "", -}); - -// Type definition for investigation summaries -export interface InvestigationSummary { - title: string; - createdByProfile: Person; - hasCustomName: boolean; - isFavorite: boolean; - overviewNote: string; - overviewNoteUpdatedAt: string; - viewMode: ViewMode; - // +listType=atomic - collectableSummaries: CollectableSummary[]; -} - -export const defaultInvestigationSummary = (): InvestigationSummary => ({ - title: "", - createdByProfile: defaultPerson(), - hasCustomName: false, - isFavorite: false, - overviewNote: "", - overviewNoteUpdatedAt: "", - viewMode: defaultViewMode(), - collectableSummaries: [], -}); - -export interface ViewMode { - mode: "compact" | "full"; - showComments: boolean; - showTooltips: boolean; -} - -export const defaultViewMode = (): ViewMode => ({ - mode: "compact", - showComments: false, - showTooltips: false, -}); - -export interface CollectableSummary { - id: string; - title: string; - logoPath: string; - origin: string; -} - -export const defaultCollectableSummary = (): CollectableSummary => ({ - id: "", - title: "", - logoPath: "", - origin: "", -}); - -export interface Spec { - // Title of the index, e.g. 'Favorites' or 'My Investigations' - title: string; - // The Person who owns this investigation index - owner: Person; - // Array of investigation summaries - // +listType=atomic - investigationSummaries: InvestigationSummary[]; -} - -export const defaultSpec = (): Spec => ({ - title: "", - owner: defaultPerson(), - investigationSummaries: [], -}); - diff --git a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.status.gen.ts b/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.status.gen.ts deleted file mode 100644 index 01be8df7961..00000000000 --- a/apps/investigations/plugin/src/generated/investigationindex/v0alpha1/types.status.gen.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -export interface OperatorState { - // lastEvaluation is the ResourceVersion last evaluated - lastEvaluation: string; - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - state: "success" | "in_progress" | "failed"; - // descriptiveState is an optional more descriptive state field which has no requirements on format - descriptiveState?: string; - // details contains any extra information that is operator-specific - details?: Record; -} - -export const defaultOperatorState = (): OperatorState => ({ - lastEvaluation: "", - state: "success", -}); - -export interface Status { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - operatorStates?: Record; - // additionalFields is reserved for future use - additionalFields?: Record; -} - -export const defaultStatus = (): Status => ({ -}); - diff --git a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json index da34c0c70f2..3ef92d3d5b8 100644 --- a/apps/logsdrilldown/definitions/logsdrilldown-manifest.json +++ b/apps/logsdrilldown/definitions/logsdrilldown-manifest.json @@ -191,7 +191,13 @@ } }, "conversion": false - }, + } + ] + }, + { + "name": "v1beta1", + "served": true, + "kinds": [ { "kind": "LogsDrilldownDefaultColumns", "plural": "LogsDrilldownDefaultColumns", @@ -314,6 +320,6 @@ ] } ], - "preferredVersion": "v1alpha1" + "preferredVersion": "v1beta1" } } diff --git a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json index 28aa314311d..c8987741983 100644 --- a/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json +++ b/apps/logsdrilldown/definitions/logsdrilldowndefaultcolumns.logsdrilldown.grafana.app.json @@ -8,7 +8,7 @@ "group": "logsdrilldown.grafana.app", "versions": [ { - "name": "v1alpha1", + "name": "v1beta1", "served": true, "storage": true, "schema": { diff --git a/apps/logsdrilldown/kinds/logsdrilldown.cue b/apps/logsdrilldown/kinds/logsdrilldown.cue index d2752103820..4b2d815743d 100644 --- a/apps/logsdrilldown/kinds/logsdrilldown.cue +++ b/apps/logsdrilldown/kinds/logsdrilldown.cue @@ -1,7 +1,7 @@ package kinds import ( - "github.com/grafana/grafana/apps/logsdrilldown/kinds/v0alpha1" + "github.com/grafana/grafana/apps/logsdrilldown/kinds/v1beta1", ) LogsDrilldownSpecv1alpha1: { @@ -26,11 +26,11 @@ logsdrilldownDefaultsv1alpha1: { } } -// Default columns API -logsdrilldownDefaultColumnsv0alpha1: { +// Default columns API (beta) +logsdrilldownDefaultColumnsv1beta1: { kind: "LogsDrilldownDefaultColumns" pluralName: "LogsDrilldownDefaultColumns" schema: { - spec: v0alpha1.LogsDefaultColumns + spec: v1beta1.LogsDefaultColumns } } diff --git a/apps/logsdrilldown/kinds/manifest.cue b/apps/logsdrilldown/kinds/manifest.cue index ab717de6a92..cbd47ce2b65 100644 --- a/apps/logsdrilldown/kinds/manifest.cue +++ b/apps/logsdrilldown/kinds/manifest.cue @@ -15,6 +15,7 @@ manifest: { // If your app needs access to kinds managed by another app, use permissions.accessKinds to allow your app access. versions: { "v1alpha1": v1alpha1 + "v1beta1" : v1beta1 } // extraPermissions contains any additional permissions your app may require to function. // Your app will always have all permissions for each kind it manages (the items defined in 'kinds'). @@ -35,7 +36,40 @@ manifest: { // It includes kinds which the v1alpha1 API serves, and (future) custom routes served globally from the v1alpha1 version. v1alpha1: { // kinds is the list of kinds served by this version - kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1, logsdrilldownDefaultColumnsv0alpha1] + kinds: [logsdrilldownv1alpha1, logsdrilldownDefaultsv1alpha1] + // [OPTIONAL] + // served indicates whether this particular version is served by the API server. + // served should be set to false before a version is removed from the manifest entirely. + // served defaults to true if not present. + served: true + // [OPTIONAL] + // Codegen is a trait that tells the grafana-app-sdk, or other code generation tooling, how to process this kind. + // If not present, default values within the codegen trait are used. + // If you wish to specify codegen per-version, put this section in the version's object + // (for example, v1alpha1) instead. + codegen: { + // [OPTIONAL] + // ts contains TypeScript code generation properties for the kind + ts: { + // [OPTIONAL] + // enabled indicates whether the CLI should generate front-end TypeScript code for the kind. + // Defaults to true if not present. + enabled: true + } + // [OPTIONAL] + // go contains go code generation properties for the kind + go: { + // [OPTIONAL] + // enabled indicates whether the CLI should generate back-end go code for the kind. + // Defaults to true if not present. + enabled: true + } + } +} + +v1beta1: { + // kinds is the list of kinds served by this version + kinds: [logsdrilldownDefaultColumnsv1beta1] // [OPTIONAL] // served indicates whether this particular version is served by the API server. // served should be set to false before a version is removed from the manifest entirely. diff --git a/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue b/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue new file mode 100644 index 00000000000..a66e813766b --- /dev/null +++ b/apps/logsdrilldown/kinds/v1beta1/defaultcolumns.cue @@ -0,0 +1,19 @@ +package v1beta1 + +#LogsDefaultColumnsLabel: { + key: string + value: string +} + +#LogsDefaultColumnsLabels: [...#LogsDefaultColumnsLabel] + +#LogsDefaultColumnsRecord: { + columns: [...string] + labels: #LogsDefaultColumnsLabels +} + +#LogsDefaultColumnsRecords: [...#LogsDefaultColumnsRecord] + +LogsDefaultColumns: { + records: #LogsDefaultColumnsRecords +} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go similarity index 91% rename from apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go index 082bec7c874..ecfc11a456f 100644 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/constants.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/constants.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import "k8s.io/apimachinery/pkg/runtime/schema" @@ -6,7 +6,7 @@ const ( // APIGroup is the API group used by all kinds in this package APIGroup = "logsdrilldown.grafana.app" // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" + APIVersion = "v1beta1" ) var ( diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go index b5d573bc1dc..856c3af291b 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_client_gen.go @@ -1,4 +1,4 @@ -package v1alpha1 +package v1beta1 import ( "context" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go similarity index 98% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go index 311d2f02683..12622814e72 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_codec_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "encoding/json" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go similarity index 98% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go index a4bb052fe25..ee2d44fde2e 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_metadata_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 import ( time "time" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go index 4340a27714e..6822fbfb4d7 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_object_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "fmt" diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go similarity index 85% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go index cc5363e16bb..49d234f47a5 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_schema_gen.go @@ -2,7 +2,7 @@ // Code generated by grafana-app-sdk. DO NOT EDIT. // -package v1alpha1 +package v1beta1 import ( "github.com/grafana/grafana-app-sdk/resource" @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), + schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1beta1", NewLogsDrilldownDefaultColumns(), &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) kindLogsDrilldownDefaultColumns = resource.Kind{ Schema: schemaLogsDrilldownDefaultColumns, diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go index ce12ebb0761..6163f66c52c 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_spec_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords []LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go similarity index 99% rename from apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go rename to apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go index c2183832095..e109592eb09 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1/logsdrilldowndefaultcolumns_status_gen.go @@ -1,6 +1,6 @@ // Code generated - EDITING IS FUTILE. DO NOT EDIT. -package v1alpha1 +package v1beta1 // +k8s:openapi-gen=true type LogsDrilldownDefaultColumnsstatusOperatorState struct { diff --git a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go index 2350b924dda..a242d11b2bf 100644 --- a/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go +++ b/apps/logsdrilldown/pkg/apis/logsdrilldown_manifest.go @@ -17,24 +17,25 @@ import ( "k8s.io/kube-openapi/pkg/validation/spec" v1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1" + v1beta1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1" ) var ( - rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) - rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) - rawSchemaLogsDrilldownDefaultColumnsv1alpha1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultColumnsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1alpha1, &versionSchemaLogsDrilldownDefaultColumnsv1alpha1) + rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) + rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) + rawSchemaLogsDrilldownDefaultColumnsv1beta1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + versionSchemaLogsDrilldownDefaultColumnsv1beta1 app.VersionSchema + _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1beta1, &versionSchemaLogsDrilldownDefaultColumnsv1beta1) ) var appManifestData = app.ManifestData{ AppName: "logsdrilldown", Group: "logsdrilldown.grafana.app", - PreferredVersion: "v1alpha1", + PreferredVersion: "v1beta1", Versions: []app.ManifestVersion{ { Name: "v1alpha1", @@ -55,13 +56,24 @@ var appManifestData = app.ManifestData{ Conversion: false, Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, }, + }, + Routes: app.ManifestVersionRoutes{ + Namespaced: map[string]spec3.PathProps{}, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{}, + }, + }, + { + Name: "v1beta1", + Served: true, + Kinds: []app.ManifestVersionKind{ { Kind: "LogsDrilldownDefaultColumns", Plural: "LogsDrilldownDefaultColumns", Scope: "Namespaced", Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, + Schema: &versionSchemaLogsDrilldownDefaultColumnsv1beta1, }, }, Routes: app.ManifestVersionRoutes{ @@ -82,9 +94,9 @@ func RemoteManifest() app.Manifest { } var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), - "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), - "LogsDrilldownDefaultColumns/v1alpha1": v1alpha1.LogsDrilldownDefaultColumnsKind(), + "LogsDrilldown/v1alpha1": v1alpha1.LogsDrilldownKind(), + "LogsDrilldownDefaults/v1alpha1": v1alpha1.LogsDrilldownDefaultsKind(), + "LogsDrilldownDefaultColumns/v1beta1": v1beta1.LogsDrilldownDefaultColumnsKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. diff --git a/apps/logsdrilldown/pkg/app/app.go b/apps/logsdrilldown/pkg/app/app.go index 23260270207..1e3e37851e2 100644 --- a/apps/logsdrilldown/pkg/app/app.go +++ b/apps/logsdrilldown/pkg/app/app.go @@ -11,6 +11,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" logsdrilldownv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1alpha1" + logsdrilldownv1beta1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/apis/logsdrilldown/v1beta1" ) func New(cfg app.Config) (app.App, error) { @@ -32,7 +33,7 @@ func New(cfg app.Config) (app.App, error) { Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultsKind(), }, { - Kind: logsdrilldownv1alpha1.LogsDrilldownDefaultColumnsKind(), + Kind: logsdrilldownv1beta1.LogsDrilldownDefaultColumnsKind(), }, }, } diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go deleted file mode 100644 index c133b65f45b..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownClient struct { - client *resource.TypedClient[*LogsDrilldown, *LogsDrilldownList] -} - -func NewLogsDrilldownClient(client resource.Client) *LogsDrilldownClient { - return &LogsDrilldownClient{ - client: resource.NewTypedClient[*LogsDrilldown, *LogsDrilldownList](client, Kind()), - } -} - -func NewLogsDrilldownClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownClient(c), nil -} - -func (c *LogsDrilldownClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldown, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownClient) Create(ctx context.Context, obj *LogsDrilldown, opts resource.CreateOptions) (*LogsDrilldown, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownClient) Update(ctx context.Context, obj *LogsDrilldown, opts resource.UpdateOptions) (*LogsDrilldown, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldown, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldown, error) { - return c.client.Update(ctx, &LogsDrilldown{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go deleted file mode 100644 index 5d40a873e6b..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldown struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldown - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldown) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldown) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldown) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldown) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldown) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldown) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldown) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldown) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldown) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldown) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldown) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldown) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldown) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldown) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldown) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldown) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldown) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldown) DeepCopy() *LogsDrilldown { - cpy := &LogsDrilldown{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldown) DeepCopyInto(dst *LogsDrilldown) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldown{} - -// +k8s:openapi-gen=true -type LogsDrilldownList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldown `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownList) Copy() resource.ListObject { - cpy := &LogsDrilldownList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldown, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldown); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldown, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldown) - } -} - -func (o *LogsDrilldownList) DeepCopy() *LogsDrilldownList { - cpy := &LogsDrilldownList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownList) DeepCopyInto(dst *LogsDrilldownList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go deleted file mode 100644 index 942794416e8..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldown = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldown{}, &LogsDrilldownList{}, resource.WithKind("LogsDrilldown"), - resource.WithPlural("logsdrilldowns"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldown = resource.Kind{ - Schema: schemaLogsDrilldown, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldown -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldown -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldown -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldown diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go deleted file mode 100644 index faff5c108dd..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_spec_gen.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type Spec struct { - DefaultFields []string `json:"defaultFields"` - PrettifyJSON bool `json:"prettifyJSON"` - WrapLogMessage bool `json:"wrapLogMessage"` - InterceptDismissed bool `json:"interceptDismissed"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{ - DefaultFields: []string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1/logsdrilldown_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go deleted file mode 100644 index 082bec7c874..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v1alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "logsdrilldown.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go deleted file mode 100644 index b66471eb4ba..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownDefaultColumnsClient struct { - client *resource.TypedClient[*LogsDrilldownDefaultColumns, *LogsDrilldownDefaultColumnsList] -} - -func NewLogsDrilldownDefaultColumnsClient(client resource.Client) *LogsDrilldownDefaultColumnsClient { - return &LogsDrilldownDefaultColumnsClient{ - client: resource.NewTypedClient[*LogsDrilldownDefaultColumns, *LogsDrilldownDefaultColumnsList](client, Kind()), - } -} - -func NewLogsDrilldownDefaultColumnsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultColumnsClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownDefaultColumnsClient(c), nil -} - -func (c *LogsDrilldownDefaultColumnsClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldownDefaultColumns, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownDefaultColumnsClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultColumnsList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultColumnsList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownDefaultColumnsClient) Create(ctx context.Context, obj *LogsDrilldownDefaultColumns, opts resource.CreateOptions) (*LogsDrilldownDefaultColumns, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) Update(ctx context.Context, obj *LogsDrilldownDefaultColumns, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownDefaultColumnsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldownDefaultColumns, error) { - return c.client.Update(ctx, &LogsDrilldownDefaultColumns{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownDefaultColumnsClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go deleted file mode 100644 index 3173c28330e..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultColumns struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldownDefaultColumns - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldownDefaultColumns) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldownDefaultColumns) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldownDefaultColumns) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldownDefaultColumns) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldownDefaultColumns) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldownDefaultColumns) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldownDefaultColumns) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldownDefaultColumns) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldownDefaultColumns) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldownDefaultColumns) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldownDefaultColumns) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldownDefaultColumns) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldownDefaultColumns) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldownDefaultColumns) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldownDefaultColumns) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldownDefaultColumns) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldownDefaultColumns) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultColumns) DeepCopy() *LogsDrilldownDefaultColumns { - cpy := &LogsDrilldownDefaultColumns{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultColumns) DeepCopyInto(dst *LogsDrilldownDefaultColumns) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldownDefaultColumns{} - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultColumnsList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldownDefaultColumns `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultColumnsList) Copy() resource.ListObject { - cpy := &LogsDrilldownDefaultColumnsList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldownDefaultColumns, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldownDefaultColumns); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownDefaultColumnsList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownDefaultColumnsList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldownDefaultColumns, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldownDefaultColumns) - } -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopy() *LogsDrilldownDefaultColumnsList { - cpy := &LogsDrilldownDefaultColumnsList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultColumnsList) DeepCopyInto(dst *LogsDrilldownDefaultColumnsList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownDefaultColumnsList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go deleted file mode 100644 index b50be391fc7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldownDefaultColumns = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaultColumns{}, &LogsDrilldownDefaultColumnsList{}, resource.WithKind("LogsDrilldownDefaultColumns"), - resource.WithPlural("logsdrilldowndefaultcolumns"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldownDefaultColumns = resource.Kind{ - Schema: schemaLogsDrilldownDefaultColumns, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldownDefaultColumns -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaultColumns -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldownDefaultColumns -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldownDefaultColumns diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go deleted file mode 100644 index d9cd977aeb9..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_spec_gen.go +++ /dev/null @@ -1,43 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type LogsDefaultColumnsRecords []LogsDefaultColumnsRecord - -// +k8s:openapi-gen=true -type LogsDefaultColumnsRecord struct { - Columns []string `json:"columns"` - Labels LogsDefaultColumnsLabels `json:"labels"` -} - -// NewLogsDefaultColumnsRecord creates a new LogsDefaultColumnsRecord object. -func NewLogsDefaultColumnsRecord() *LogsDefaultColumnsRecord { - return &LogsDefaultColumnsRecord{ - Columns: []string{}, - } -} - -// +k8s:openapi-gen=true -type LogsDefaultColumnsLabels []LogsDefaultColumnsLabel - -// +k8s:openapi-gen=true -type LogsDefaultColumnsLabel struct { - Key string `json:"key"` - Value string `json:"value"` -} - -// NewLogsDefaultColumnsLabel creates a new LogsDefaultColumnsLabel object. -func NewLogsDefaultColumnsLabel() *LogsDefaultColumnsLabel { - return &LogsDefaultColumnsLabel{} -} - -// +k8s:openapi-gen=true -type Spec struct { - Records LogsDefaultColumnsRecords `json:"records"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{} -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1/logsdrilldowndefaultcolumns_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go deleted file mode 100644 index 082bec7c874..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/constants.go +++ /dev/null @@ -1,18 +0,0 @@ -package v1alpha1 - -import "k8s.io/apimachinery/pkg/runtime/schema" - -const ( - // APIGroup is the API group used by all kinds in this package - APIGroup = "logsdrilldown.grafana.app" - // APIVersion is the API version used by all kinds in this package - APIVersion = "v1alpha1" -) - -var ( - // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package - GroupVersion = schema.GroupVersion{ - Group: APIGroup, - Version: APIVersion, - } -) diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go deleted file mode 100644 index cc06a10b1e7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_client_gen.go +++ /dev/null @@ -1,99 +0,0 @@ -package v1alpha1 - -import ( - "context" - - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -type LogsDrilldownDefaultsClient struct { - client *resource.TypedClient[*LogsDrilldownDefaults, *LogsDrilldownDefaultsList] -} - -func NewLogsDrilldownDefaultsClient(client resource.Client) *LogsDrilldownDefaultsClient { - return &LogsDrilldownDefaultsClient{ - client: resource.NewTypedClient[*LogsDrilldownDefaults, *LogsDrilldownDefaultsList](client, Kind()), - } -} - -func NewLogsDrilldownDefaultsClientFromGenerator(generator resource.ClientGenerator) (*LogsDrilldownDefaultsClient, error) { - c, err := generator.ClientFor(Kind()) - if err != nil { - return nil, err - } - return NewLogsDrilldownDefaultsClient(c), nil -} - -func (c *LogsDrilldownDefaultsClient) Get(ctx context.Context, identifier resource.Identifier) (*LogsDrilldownDefaults, error) { - return c.client.Get(ctx, identifier) -} - -func (c *LogsDrilldownDefaultsClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultsList, error) { - return c.client.List(ctx, namespace, opts) -} - -func (c *LogsDrilldownDefaultsClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*LogsDrilldownDefaultsList, error) { - resp, err := c.client.List(ctx, namespace, resource.ListOptions{ - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - for resp.GetContinue() != "" { - page, err := c.client.List(ctx, namespace, resource.ListOptions{ - Continue: resp.GetContinue(), - ResourceVersion: opts.ResourceVersion, - Limit: opts.Limit, - LabelFilters: opts.LabelFilters, - FieldSelectors: opts.FieldSelectors, - }) - if err != nil { - return nil, err - } - resp.SetContinue(page.GetContinue()) - resp.SetResourceVersion(page.GetResourceVersion()) - resp.SetItems(append(resp.GetItems(), page.GetItems()...)) - } - return resp, nil -} - -func (c *LogsDrilldownDefaultsClient) Create(ctx context.Context, obj *LogsDrilldownDefaults, opts resource.CreateOptions) (*LogsDrilldownDefaults, error) { - // Make sure apiVersion and kind are set - obj.APIVersion = GroupVersion.Identifier() - obj.Kind = Kind().Kind() - return c.client.Create(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultsClient) Update(ctx context.Context, obj *LogsDrilldownDefaults, opts resource.UpdateOptions) (*LogsDrilldownDefaults, error) { - return c.client.Update(ctx, obj, opts) -} - -func (c *LogsDrilldownDefaultsClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*LogsDrilldownDefaults, error) { - return c.client.Patch(ctx, identifier, req, opts) -} - -func (c *LogsDrilldownDefaultsClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus Status, opts resource.UpdateOptions) (*LogsDrilldownDefaults, error) { - return c.client.Update(ctx, &LogsDrilldownDefaults{ - TypeMeta: metav1.TypeMeta{ - Kind: Kind().Kind(), - APIVersion: GroupVersion.Identifier(), - }, - ObjectMeta: metav1.ObjectMeta{ - ResourceVersion: opts.ResourceVersion, - Namespace: identifier.Namespace, - Name: identifier.Name, - }, - Status: newStatus, - }, resource.UpdateOptions{ - Subresource: "status", - ResourceVersion: opts.ResourceVersion, - }) -} - -func (c *LogsDrilldownDefaultsClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error { - return c.client.Delete(ctx, identifier, opts) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go deleted file mode 100644 index bb458caeb88..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_codec_gen.go +++ /dev/null @@ -1,28 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "encoding/json" - "io" - - "github.com/grafana/grafana-app-sdk/resource" -) - -// JSONCodec is an implementation of resource.Codec for kubernetes JSON encoding -type JSONCodec struct{} - -// Read reads JSON-encoded bytes from `reader` and unmarshals them into `into` -func (*JSONCodec) Read(reader io.Reader, into resource.Object) error { - return json.NewDecoder(reader).Decode(into) -} - -// Write writes JSON-encoded bytes into `writer` marshaled from `from` -func (*JSONCodec) Write(writer io.Writer, from resource.Object) error { - return json.NewEncoder(writer).Encode(from) -} - -// Interface compliance checks -var _ resource.Codec = &JSONCodec{} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go deleted file mode 100644 index cb7233b22ab..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_metadata_gen.go +++ /dev/null @@ -1,31 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -import ( - time "time" -) - -// metadata contains embedded CommonMetadata and can be extended with custom string fields -// TODO: use CommonMetadata instead of redefining here; currently needs to be defined here -// without external reference as using the CommonMetadata reference breaks thema codegen. -type Metadata struct { - UpdateTimestamp time.Time `json:"updateTimestamp"` - CreatedBy string `json:"createdBy"` - Uid string `json:"uid"` - CreationTimestamp time.Time `json:"creationTimestamp"` - DeletionTimestamp *time.Time `json:"deletionTimestamp,omitempty"` - Finalizers []string `json:"finalizers"` - ResourceVersion string `json:"resourceVersion"` - Generation int64 `json:"generation"` - UpdatedBy string `json:"updatedBy"` - Labels map[string]string `json:"labels"` -} - -// NewMetadata creates a new Metadata object. -func NewMetadata() *Metadata { - return &Metadata{ - Finalizers: []string{}, - Labels: map[string]string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go deleted file mode 100644 index d9354522dd7..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_object_gen.go +++ /dev/null @@ -1,319 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "fmt" - "github.com/grafana/grafana-app-sdk/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" - "time" -) - -// +k8s:openapi-gen=true -type LogsDrilldownDefaults struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ObjectMeta `json:"metadata" yaml:"metadata"` - - // Spec is the spec of the LogsDrilldownDefaults - Spec Spec `json:"spec" yaml:"spec"` - - Status Status `json:"status" yaml:"status"` -} - -func (o *LogsDrilldownDefaults) GetSpec() any { - return o.Spec -} - -func (o *LogsDrilldownDefaults) SetSpec(spec any) error { - cast, ok := spec.(Spec) - if !ok { - return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) - } - o.Spec = cast - return nil -} - -func (o *LogsDrilldownDefaults) GetSubresources() map[string]any { - return map[string]any{ - "status": o.Status, - } -} - -func (o *LogsDrilldownDefaults) GetSubresource(name string) (any, bool) { - switch name { - case "status": - return o.Status, true - default: - return nil, false - } -} - -func (o *LogsDrilldownDefaults) SetSubresource(name string, value any) error { - switch name { - case "status": - cast, ok := value.(Status) - if !ok { - return fmt.Errorf("cannot set status type %#v, not of type Status", value) - } - o.Status = cast - return nil - default: - return fmt.Errorf("subresource '%s' does not exist", name) - } -} - -func (o *LogsDrilldownDefaults) GetStaticMetadata() resource.StaticMetadata { - gvk := o.GroupVersionKind() - return resource.StaticMetadata{ - Name: o.ObjectMeta.Name, - Namespace: o.ObjectMeta.Namespace, - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind, - } -} - -func (o *LogsDrilldownDefaults) SetStaticMetadata(metadata resource.StaticMetadata) { - o.Name = metadata.Name - o.Namespace = metadata.Namespace - o.SetGroupVersionKind(schema.GroupVersionKind{ - Group: metadata.Group, - Version: metadata.Version, - Kind: metadata.Kind, - }) -} - -func (o *LogsDrilldownDefaults) GetCommonMetadata() resource.CommonMetadata { - dt := o.DeletionTimestamp - var deletionTimestamp *time.Time - if dt != nil { - deletionTimestamp = &dt.Time - } - // Legacy ExtraFields support - extraFields := make(map[string]any) - if o.Annotations != nil { - extraFields["annotations"] = o.Annotations - } - if o.ManagedFields != nil { - extraFields["managedFields"] = o.ManagedFields - } - if o.OwnerReferences != nil { - extraFields["ownerReferences"] = o.OwnerReferences - } - return resource.CommonMetadata{ - UID: string(o.UID), - ResourceVersion: o.ResourceVersion, - Generation: o.Generation, - Labels: o.Labels, - CreationTimestamp: o.CreationTimestamp.Time, - DeletionTimestamp: deletionTimestamp, - Finalizers: o.Finalizers, - UpdateTimestamp: o.GetUpdateTimestamp(), - CreatedBy: o.GetCreatedBy(), - UpdatedBy: o.GetUpdatedBy(), - ExtraFields: extraFields, - } -} - -func (o *LogsDrilldownDefaults) SetCommonMetadata(metadata resource.CommonMetadata) { - o.UID = types.UID(metadata.UID) - o.ResourceVersion = metadata.ResourceVersion - o.Generation = metadata.Generation - o.Labels = metadata.Labels - o.CreationTimestamp = metav1.NewTime(metadata.CreationTimestamp) - if metadata.DeletionTimestamp != nil { - dt := metav1.NewTime(*metadata.DeletionTimestamp) - o.DeletionTimestamp = &dt - } else { - o.DeletionTimestamp = nil - } - o.Finalizers = metadata.Finalizers - if o.Annotations == nil { - o.Annotations = make(map[string]string) - } - if !metadata.UpdateTimestamp.IsZero() { - o.SetUpdateTimestamp(metadata.UpdateTimestamp) - } - if metadata.CreatedBy != "" { - o.SetCreatedBy(metadata.CreatedBy) - } - if metadata.UpdatedBy != "" { - o.SetUpdatedBy(metadata.UpdatedBy) - } - // Legacy support for setting Annotations, ManagedFields, and OwnerReferences via ExtraFields - if metadata.ExtraFields != nil { - if annotations, ok := metadata.ExtraFields["annotations"]; ok { - if cast, ok := annotations.(map[string]string); ok { - o.Annotations = cast - } - } - if managedFields, ok := metadata.ExtraFields["managedFields"]; ok { - if cast, ok := managedFields.([]metav1.ManagedFieldsEntry); ok { - o.ManagedFields = cast - } - } - if ownerReferences, ok := metadata.ExtraFields["ownerReferences"]; ok { - if cast, ok := ownerReferences.([]metav1.OwnerReference); ok { - o.OwnerReferences = cast - } - } - } -} - -func (o *LogsDrilldownDefaults) GetCreatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/createdBy"] -} - -func (o *LogsDrilldownDefaults) SetCreatedBy(createdBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy -} - -func (o *LogsDrilldownDefaults) GetUpdateTimestamp() time.Time { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - parsed, _ := time.Parse(time.RFC3339, o.ObjectMeta.Annotations["grafana.com/updateTimestamp"]) - return parsed -} - -func (o *LogsDrilldownDefaults) SetUpdateTimestamp(updateTimestamp time.Time) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) -} - -func (o *LogsDrilldownDefaults) GetUpdatedBy() string { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - return o.ObjectMeta.Annotations["grafana.com/updatedBy"] -} - -func (o *LogsDrilldownDefaults) SetUpdatedBy(updatedBy string) { - if o.ObjectMeta.Annotations == nil { - o.ObjectMeta.Annotations = make(map[string]string) - } - - o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy -} - -func (o *LogsDrilldownDefaults) Copy() resource.Object { - return resource.CopyObject(o) -} - -func (o *LogsDrilldownDefaults) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaults) DeepCopy() *LogsDrilldownDefaults { - cpy := &LogsDrilldownDefaults{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaults) DeepCopyInto(dst *LogsDrilldownDefaults) { - dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion - dst.TypeMeta.Kind = o.TypeMeta.Kind - o.ObjectMeta.DeepCopyInto(&dst.ObjectMeta) - o.Spec.DeepCopyInto(&dst.Spec) - o.Status.DeepCopyInto(&dst.Status) -} - -// Interface compliance compile-time check -var _ resource.Object = &LogsDrilldownDefaults{} - -// +k8s:openapi-gen=true -type LogsDrilldownDefaultsList struct { - metav1.TypeMeta `json:",inline" yaml:",inline"` - metav1.ListMeta `json:"metadata" yaml:"metadata"` - Items []LogsDrilldownDefaults `json:"items" yaml:"items"` -} - -func (o *LogsDrilldownDefaultsList) DeepCopyObject() runtime.Object { - return o.Copy() -} - -func (o *LogsDrilldownDefaultsList) Copy() resource.ListObject { - cpy := &LogsDrilldownDefaultsList{ - TypeMeta: o.TypeMeta, - Items: make([]LogsDrilldownDefaults, len(o.Items)), - } - o.ListMeta.DeepCopyInto(&cpy.ListMeta) - for i := 0; i < len(o.Items); i++ { - if item, ok := o.Items[i].Copy().(*LogsDrilldownDefaults); ok { - cpy.Items[i] = *item - } - } - return cpy -} - -func (o *LogsDrilldownDefaultsList) GetItems() []resource.Object { - items := make([]resource.Object, len(o.Items)) - for i := 0; i < len(o.Items); i++ { - items[i] = &o.Items[i] - } - return items -} - -func (o *LogsDrilldownDefaultsList) SetItems(items []resource.Object) { - o.Items = make([]LogsDrilldownDefaults, len(items)) - for i := 0; i < len(items); i++ { - o.Items[i] = *items[i].(*LogsDrilldownDefaults) - } -} - -func (o *LogsDrilldownDefaultsList) DeepCopy() *LogsDrilldownDefaultsList { - cpy := &LogsDrilldownDefaultsList{} - o.DeepCopyInto(cpy) - return cpy -} - -func (o *LogsDrilldownDefaultsList) DeepCopyInto(dst *LogsDrilldownDefaultsList) { - resource.CopyObjectInto(dst, o) -} - -// Interface compliance compile-time check -var _ resource.ListObject = &LogsDrilldownDefaultsList{} - -// Copy methods for all subresource types - -// DeepCopy creates a full deep copy of Spec -func (s *Spec) DeepCopy() *Spec { - cpy := &Spec{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Spec into another Spec object -func (s *Spec) DeepCopyInto(dst *Spec) { - resource.CopyObjectInto(dst, s) -} - -// DeepCopy creates a full deep copy of Status -func (s *Status) DeepCopy() *Status { - cpy := &Status{} - s.DeepCopyInto(cpy) - return cpy -} - -// DeepCopyInto deep copies Status into another Status object -func (s *Status) DeepCopyInto(dst *Status) { - resource.CopyObjectInto(dst, s) -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go deleted file mode 100644 index bda3e49377d..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_schema_gen.go +++ /dev/null @@ -1,34 +0,0 @@ -// -// Code generated by grafana-app-sdk. DO NOT EDIT. -// - -package v1alpha1 - -import ( - "github.com/grafana/grafana-app-sdk/resource" -) - -// schema is unexported to prevent accidental overwrites -var ( - schemaLogsDrilldownDefaults = resource.NewSimpleSchema("logsdrilldown.grafana.app", "v1alpha1", &LogsDrilldownDefaults{}, &LogsDrilldownDefaultsList{}, resource.WithKind("LogsDrilldownDefaults"), - resource.WithPlural("logsdrilldowndefaults"), resource.WithScope(resource.NamespacedScope)) - kindLogsDrilldownDefaults = resource.Kind{ - Schema: schemaLogsDrilldownDefaults, - Codecs: map[resource.KindEncoding]resource.Codec{ - resource.KindEncodingJSON: &JSONCodec{}, - }, - } -) - -// Kind returns a resource.Kind for this Schema with a JSON codec -func Kind() resource.Kind { - return kindLogsDrilldownDefaults -} - -// Schema returns a resource.SimpleSchema representation of LogsDrilldownDefaults -func Schema() *resource.SimpleSchema { - return schemaLogsDrilldownDefaults -} - -// Interface compliance checks -var _ resource.Schema = kindLogsDrilldownDefaults diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go deleted file mode 100644 index faff5c108dd..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_spec_gen.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type Spec struct { - DefaultFields []string `json:"defaultFields"` - PrettifyJSON bool `json:"prettifyJSON"` - WrapLogMessage bool `json:"wrapLogMessage"` - InterceptDismissed bool `json:"interceptDismissed"` -} - -// NewSpec creates a new Spec object. -func NewSpec() *Spec { - return &Spec{ - DefaultFields: []string{}, - } -} diff --git a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go b/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go deleted file mode 100644 index 9b227b00f44..00000000000 --- a/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1/logsdrilldowndefaults_status_gen.go +++ /dev/null @@ -1,44 +0,0 @@ -// Code generated - EDITING IS FUTILE. DO NOT EDIT. - -package v1alpha1 - -// +k8s:openapi-gen=true -type StatusOperatorState struct { - // lastEvaluation is the ResourceVersion last evaluated - LastEvaluation string `json:"lastEvaluation"` - // state describes the state of the lastEvaluation. - // It is limited to three possible states for machine evaluation. - State StatusOperatorStateState `json:"state"` - // descriptiveState is an optional more descriptive state field which has no requirements on format - DescriptiveState *string `json:"descriptiveState,omitempty"` - // details contains any extra information that is operator-specific - Details map[string]interface{} `json:"details,omitempty"` -} - -// NewStatusOperatorState creates a new StatusOperatorState object. -func NewStatusOperatorState() *StatusOperatorState { - return &StatusOperatorState{} -} - -// +k8s:openapi-gen=true -type Status struct { - // operatorStates is a map of operator ID to operator state evaluations. - // Any operator which consumes this kind SHOULD add its state evaluation information to this field. - OperatorStates map[string]StatusOperatorState `json:"operatorStates,omitempty"` - // additionalFields is reserved for future use - AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"` -} - -// NewStatus creates a new Status object. -func NewStatus() *Status { - return &Status{} -} - -// +k8s:openapi-gen=true -type StatusOperatorStateState string - -const ( - StatusOperatorStateStateSuccess StatusOperatorStateState = "success" - StatusOperatorStateStateInProgress StatusOperatorStateState = "in_progress" - StatusOperatorStateStateFailed StatusOperatorStateState = "failed" -) diff --git a/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go b/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go deleted file mode 100644 index 9deb5d5d3a1..00000000000 --- a/apps/logsdrilldown/pkg/generated/manifestdata/logsdrilldown_manifest.go +++ /dev/null @@ -1,150 +0,0 @@ -// -// This file is generated by grafana-app-sdk -// DO NOT EDIT -// - -package manifestdata - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/kube-openapi/pkg/spec3" - "k8s.io/kube-openapi/pkg/validation/spec" - - logsdrilldownv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldown/v1alpha1" - logsdrilldowndefaultcolumnsv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldowndefaultcolumns/v1alpha1" - logsdrilldowndefaultsv1alpha1 "github.com/grafana/grafana/apps/logsdrilldown/pkg/generated/logsdrilldowndefaults/v1alpha1" -) - -var ( - rawSchemaLogsDrilldownv1alpha1 = []byte(`{"LogsDrilldown":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownv1alpha1, &versionSchemaLogsDrilldownv1alpha1) - rawSchemaLogsDrilldownDefaultsv1alpha1 = []byte(`{"LogsDrilldownDefaults":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"defaultFields":{"items":{"type":"string"},"type":"array"},"interceptDismissed":{"type":"boolean"},"prettifyJSON":{"type":"boolean"},"wrapLogMessage":{"type":"boolean"}},"required":["defaultFields","prettifyJSON","wrapLogMessage","interceptDismissed"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultsv1alpha1, &versionSchemaLogsDrilldownDefaultsv1alpha1) - rawSchemaLogsDrilldownDefaultColumnsv1alpha1 = []byte(`{"LogsDefaultColumnsLabel":{"additionalProperties":false,"properties":{"key":{"type":"string"},"value":{"type":"string"}},"required":["key","value"],"type":"object"},"LogsDefaultColumnsLabels":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsLabel"},"type":"array"},"LogsDefaultColumnsRecord":{"additionalProperties":false,"properties":{"columns":{"items":{"type":"string"},"type":"array"},"labels":{"$ref":"#/components/schemas/LogsDefaultColumnsLabels"}},"required":["columns","labels"],"type":"object"},"LogsDefaultColumnsRecords":{"items":{"$ref":"#/components/schemas/LogsDefaultColumnsRecord"},"type":"array"},"LogsDrilldownDefaultColumns":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"spec":{"additionalProperties":false,"properties":{"records":{"$ref":"#/components/schemas/LogsDefaultColumnsRecords"}},"required":["records"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) - versionSchemaLogsDrilldownDefaultColumnsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaLogsDrilldownDefaultColumnsv1alpha1, &versionSchemaLogsDrilldownDefaultColumnsv1alpha1) -) - -var appManifestData = app.ManifestData{ - AppName: "logsdrilldown", - Group: "logsdrilldown.grafana.app", - PreferredVersion: "v1alpha1", - Versions: []app.ManifestVersion{ - { - Name: "v1alpha1", - Served: true, - Kinds: []app.ManifestVersionKind{ - { - Kind: "LogsDrilldown", - Plural: "LogsDrilldowns", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownv1alpha1, - }, - - { - Kind: "LogsDrilldownDefaults", - Plural: "LogsDrilldownDefaults", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultsv1alpha1, - }, - - { - Kind: "LogsDrilldownDefaultColumns", - Plural: "LogsDrilldownDefaultColumns", - Scope: "Namespaced", - Conversion: false, - Schema: &versionSchemaLogsDrilldownDefaultColumnsv1alpha1, - }, - }, - Routes: app.ManifestVersionRoutes{ - Namespaced: map[string]spec3.PathProps{}, - Cluster: map[string]spec3.PathProps{}, - Schemas: map[string]spec.Schema{}, - }, - }, - }, -} - -func LocalManifest() app.Manifest { - return app.NewEmbeddedManifest(appManifestData) -} - -func RemoteManifest() app.Manifest { - return app.NewAPIServerManifest("logsdrilldown") -} - -var kindVersionToGoType = map[string]resource.Kind{ - "LogsDrilldown/v1alpha1": logsdrilldownv1alpha1.Kind(), - "LogsDrilldownDefaults/v1alpha1": logsdrilldowndefaultsv1alpha1.Kind(), - "LogsDrilldownDefaultColumns/v1alpha1": logsdrilldowndefaultcolumnsv1alpha1.Kind(), -} - -// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. -// If there is no association for the provided Kind and Version, exists will return false. -func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { - goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] - return goType, exists -} - -var customRouteToGoResponseType = map[string]any{} - -// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. -// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. -// If there is no association for the provided kind, version, custom route path, and method, exists will return false. -// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) -func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoParamsType = map[string]runtime.Object{} - -func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -var customRouteToGoRequestBodyType = map[string]any{} - -func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { - if len(path) > 0 && path[0] == '/' { - path = path[1:] - } - goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] - return goType, exists -} - -type GoTypeAssociator struct{} - -func NewGoTypeAssociator() *GoTypeAssociator { - return &GoTypeAssociator{} -} - -func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { - return ManifestGoTypeAssociator(kind, version) -} -func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { - return ManifestCustomRouteQueryAssociator(kind, version, path, verb) -} -func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { - return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) -} diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts similarity index 95% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts rename to apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts index 3c7cf0874f8..f7ba7b0f223 100644 --- a/apps/investigations/plugin/src/generated/investigation/v0alpha1/investigation_object_gen.ts +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/logsdrilldowndefaultcolumns_object_gen.ts @@ -40,7 +40,7 @@ export interface ManagedFieldsEntry { subresource?: string; } -export interface Investigation { +export interface LogsDrilldownDefaultColumns { kind: string; apiVersion: string; metadata: Metadata; diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.metadata.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts similarity index 100% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/types.metadata.gen.ts rename to apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.metadata.gen.ts diff --git a/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts new file mode 100644 index 00000000000..fde99894776 --- /dev/null +++ b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.spec.gen.ts @@ -0,0 +1,38 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. + +export type LogsDefaultColumnsRecords = LogsDefaultColumnsRecord[]; + +export const defaultLogsDefaultColumnsRecords = (): LogsDefaultColumnsRecords => ([]); + +export interface LogsDefaultColumnsRecord { + columns: string[]; + labels: LogsDefaultColumnsLabels; +} + +export const defaultLogsDefaultColumnsRecord = (): LogsDefaultColumnsRecord => ({ + columns: [], + labels: defaultLogsDefaultColumnsLabels(), +}); + +export type LogsDefaultColumnsLabels = LogsDefaultColumnsLabel[]; + +export const defaultLogsDefaultColumnsLabels = (): LogsDefaultColumnsLabels => ([]); + +export interface LogsDefaultColumnsLabel { + key: string; + value: string; +} + +export const defaultLogsDefaultColumnsLabel = (): LogsDefaultColumnsLabel => ({ + key: "", + value: "", +}); + +export interface Spec { + records: LogsDefaultColumnsRecords; +} + +export const defaultSpec = (): Spec => ({ + records: defaultLogsDefaultColumnsRecords(), +}); + diff --git a/apps/investigations/plugin/src/generated/investigation/v0alpha1/types.status.gen.ts b/apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts similarity index 100% rename from apps/investigations/plugin/src/generated/investigation/v0alpha1/types.status.gen.ts rename to apps/logsdrilldown/plugin/src/generated/logsdrilldowndefaultcolumns/v1beta1/types.status.gen.ts diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 0b9ba53e76a..e4866731967 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -8,12 +8,17 @@ replace github.com/grafana/grafana/pkg/apimachinery => ../../pkg/apimachinery replace github.com/grafana/grafana/pkg/apiserver => ../../pkg/apiserver +replace github.com/grafana/grafana/pkg/plugins => ../../pkg/plugins + +replace github.com/grafana/grafana/pkg/semconv => ../../pkg/semconv + require ( github.com/emicklei/go-restful/v3 v3.13.0 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 github.com/grafana/grafana/pkg/apimachinery v0.0.0 + github.com/grafana/grafana/pkg/plugins v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.3 k8s.io/apiserver v0.34.3 @@ -26,18 +31,18 @@ require ( cel.dev/expr v0.25.1 // indirect github.com/Machiel/slugify v1.0.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apache/arrow-go/v18 v18.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect - github.com/aws/smithy-go v1.23.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect + github.com/aws/smithy-go v1.23.2 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect @@ -97,14 +102,14 @@ require ( github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect - github.com/grafana/grafana-aws-sdk v1.3.0 // indirect + github.com/grafana/grafana-aws-sdk v1.4.2 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect github.com/grafana/grafana-plugin-sdk-go v0.284.0 // indirect github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect - github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 // indirect + github.com/grafana/grafana/pkg/semconv v0.0.0 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect - github.com/grafana/sqlds/v4 v4.2.7 // indirect + github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 3a7e9849fad..29738a48cca 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -11,8 +11,8 @@ github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -28,22 +28,22 @@ github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= -github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= -github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= -github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= -github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df h1:GSoSVRLoBaFpOOds6QyY1L8AX7uoY+Ln3BHc22W40X0= github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df/go.mod h1:hiVxq5OP2bUGBRNS3Z/bt/reCLFNbdcST6gISi1fiOM= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -229,22 +229,20 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= -github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= +github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM= +github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= -github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:2HRzUK/xQEYc+8d5If/XSusMcaYq9IptnBSHACiQcOQ= 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/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= -github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA= -github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= +github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= diff --git a/apps/plugins/kinds/meta.cue b/apps/plugins/kinds/meta.cue index b3b506e6101..01dc45adf77 100644 --- a/apps/plugins/kinds/meta.cue +++ b/apps/plugins/kinds/meta.cue @@ -217,6 +217,13 @@ metaV0Alpha1: { title: string description?: string }] + // +listType=atomic + addedFunctions?: [...{ + // +listType=set + targets: [...string] + title: string + description?: string + }] // +listType=set // +listMapKey=id exposedComponents?: [...{ diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go index c88d9b7ff8a..631febbe2fa 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/meta_spec_gen.go @@ -193,6 +193,8 @@ type MetaExtensions struct { AddedComponents []MetaV0alpha1ExtensionsAddedComponents `json:"addedComponents,omitempty"` // +listType=atomic AddedLinks []MetaV0alpha1ExtensionsAddedLinks `json:"addedLinks,omitempty"` + // +listType=atomic + AddedFunctions []MetaV0alpha1ExtensionsAddedFunctions `json:"addedFunctions,omitempty"` // +listType=set // +listMapKey=id ExposedComponents []MetaV0alpha1ExtensionsExposedComponents `json:"exposedComponents,omitempty"` @@ -396,6 +398,21 @@ func NewMetaV0alpha1ExtensionsAddedLinks() *MetaV0alpha1ExtensionsAddedLinks { } } +// +k8s:openapi-gen=true +type MetaV0alpha1ExtensionsAddedFunctions struct { + // +listType=set + Targets []string `json:"targets"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` +} + +// NewMetaV0alpha1ExtensionsAddedFunctions creates a new MetaV0alpha1ExtensionsAddedFunctions object. +func NewMetaV0alpha1ExtensionsAddedFunctions() *MetaV0alpha1ExtensionsAddedFunctions { + return &MetaV0alpha1ExtensionsAddedFunctions{ + Targets: []string{}, + } +} + // +k8s:openapi-gen=true type MetaV0alpha1ExtensionsExposedComponents struct { Id string `json:"id"` diff --git a/apps/plugins/pkg/apis/plugins_manifest.go b/apps/plugins/pkg/apis/plugins_manifest.go index 1cabb46a611..0c52665e75d 100644 --- a/apps/plugins/pkg/apis/plugins_manifest.go +++ b/apps/plugins/pkg/apis/plugins_manifest.go @@ -23,7 +23,7 @@ var ( rawSchemaPluginv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaPluginv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPluginv0alpha1, &versionSchemaPluginv0alpha1) - rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer","None"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"angular":{"additionalProperties":false,"properties":{"detected":{"type":"boolean"}},"required":["detected"],"type":"object"},"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + rawSchemaMetav0alpha1 = []byte(`{"Dependencies":{"additionalProperties":false,"properties":{"extensions":{"additionalProperties":false,"properties":{"exposedComponents":{"description":"+listType=set","items":{"type":"string"},"type":"array"}},"type":"object"},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"enum":["app","datasource","panel"],"type":"string"}},"required":["id","type","name"],"type":"object"},"type":"array"}},"required":["grafanaDependency"],"type":"object"},"EnterpriseFeatures":{"additionalProperties":false,"properties":{"healthDiagnosticsErrors":{"default":false,"description":"Allow additional properties","type":"boolean"}},"type":"object"},"Extensions":{"additionalProperties":false,"properties":{"addedComponents":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedFunctions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"addedLinks":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"title":{"type":"string"}},"required":["targets","title"],"type":"object"},"type":"array"},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","items":{"additionalProperties":false,"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"required":["id"],"type":"object"},"type":"array"}},"type":"object"},"IAM":{"additionalProperties":false,"properties":{"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"Include":{"additionalProperties":false,"properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"enum":["Admin","Editor","Viewer","None"],"type":"string"},"type":{"enum":["dashboard","page","panel","datasource"],"type":"string"},"uid":{"type":"string"}},"type":"object"},"Info":{"additionalProperties":false,"properties":{"author":{"additionalProperties":false,"description":"Optional fields","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","items":{"type":"string"},"type":"array"},"links":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"url":{"type":"string"}},"type":"object"},"type":"array"},"logos":{"additionalProperties":false,"properties":{"large":{"type":"string"},"small":{"type":"string"}},"required":["small","large"],"type":"object"},"screenshots":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"name":{"type":"string"},"path":{"type":"string"}},"type":"object"},"type":"array"},"updated":{"type":"string"},"version":{"type":"string"}},"required":["keywords","logos","updated","version"],"type":"object"},"JSONData":{"additionalProperties":false,"description":"JSON configuration schema for Grafana plugins\nConverted from: https://github.com/grafana/grafana/blob/main/docs/sources/developers/plugins/plugin.schema.json","properties":{"alerting":{"description":"Optional fields","type":"boolean"},"annotations":{"type":"boolean"},"autoEnabled":{"type":"boolean"},"backend":{"type":"boolean"},"buildMode":{"type":"string"},"builtIn":{"type":"boolean"},"category":{"enum":["tsdb","logging","cloud","tracing","profiling","sql","enterprise","iot","other"],"type":"string"},"dependencies":{"$ref":"#/components/schemas/Dependencies","description":"Dependency information"},"enterpriseFeatures":{"$ref":"#/components/schemas/EnterpriseFeatures"},"executable":{"type":"string"},"extensions":{"$ref":"#/components/schemas/Extensions"},"hideFromList":{"type":"boolean"},"iam":{"$ref":"#/components/schemas/IAM"},"id":{"description":"Unique name of the plugin","type":"string"},"includes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Include"},"type":"array"},"info":{"$ref":"#/components/schemas/Info","description":"Metadata for the plugin"},"logs":{"type":"boolean"},"metrics":{"type":"boolean"},"multiValueFilterOperators":{"type":"boolean"},"name":{"description":"Human-readable name of the plugin","type":"string"},"pascalName":{"type":"string"},"preload":{"type":"boolean"},"queryOptions":{"$ref":"#/components/schemas/QueryOptions"},"roles":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Role"},"type":"array"},"routes":{"description":"+listType=atomic","items":{"$ref":"#/components/schemas/Route"},"type":"array"},"skipDataQuery":{"type":"boolean"},"state":{"enum":["alpha","beta"],"type":"string"},"streaming":{"type":"boolean"},"suggestions":{"type":"boolean"},"tracing":{"type":"boolean"},"type":{"description":"Plugin type","enum":["app","datasource","panel","renderer"],"type":"string"}},"required":["id","type","name","info","dependencies"],"type":"object"},"Meta":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"QueryOptions":{"additionalProperties":false,"properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"type":"object"},"Role":{"additionalProperties":false,"properties":{"grants":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"role":{"additionalProperties":false,"properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"action":{"type":"string"},"scope":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"}},"type":"object"},"Route":{"additionalProperties":false,"properties":{"body":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"headers":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"jwtTokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"additionalProperties":false,"properties":{"params":{"additionalProperties":{"additionalProperties":{},"type":"object"},"type":"object"},"scopes":{"description":"+listType=set","items":{"type":"string"},"type":"array"},"url":{"type":"string"}},"type":"object"},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","items":{"additionalProperties":false,"properties":{"content":{"type":"string"},"name":{"type":"string"}},"type":"object"},"type":"array"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"angular":{"additionalProperties":false,"properties":{"detected":{"type":"boolean"}},"required":["detected"],"type":"object"},"baseURL":{"type":"string"},"children":{"description":"+listType=atomic","items":{"type":"string"},"type":"array"},"class":{"enum":["core","external"],"type":"string"},"module":{"additionalProperties":false,"properties":{"hash":{"type":"string"},"loadingStrategy":{"enum":["fetch","script"],"type":"string"},"path":{"type":"string"}},"required":["path"],"type":"object"},"pluginJson":{"$ref":"#/components/schemas/JSONData"},"signature":{"additionalProperties":false,"properties":{"org":{"type":"string"},"status":{"enum":["internal","valid","invalid","modified","unsigned"],"type":"string"},"type":{"enum":["grafana","commercial","community","private","private-glob"],"type":"string"}},"required":["status"],"type":"object"},"translations":{"additionalProperties":{"type":"string"},"type":"object"}},"required":["pluginJson","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaMetav0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaMetav0alpha1, &versionSchemaMetav0alpha1) ) diff --git a/apps/plugins/pkg/app/meta/converter.go b/apps/plugins/pkg/app/meta/converter.go index 2af699f6f9e..b8c0c4371d7 100644 --- a/apps/plugins/pkg/app/meta/converter.go +++ b/apps/plugins/pkg/app/meta/converter.go @@ -367,7 +367,8 @@ func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSOND // Map Extensions if len(jsonData.Extensions.AddedLinks) > 0 || len(jsonData.Extensions.AddedComponents) > 0 || - len(jsonData.Extensions.ExposedComponents) > 0 || len(jsonData.Extensions.ExtensionPoints) > 0 { + len(jsonData.Extensions.ExposedComponents) > 0 || len(jsonData.Extensions.ExtensionPoints) > 0 || + len(jsonData.Extensions.AddedFunctions) > 0 { extensions := &pluginsv0alpha1.MetaExtensions{} if len(jsonData.Extensions.AddedLinks) > 0 { @@ -398,6 +399,20 @@ func jsonDataToMetaJSONData(jsonData plugins.JSONData) pluginsv0alpha1.MetaJSOND } } + if len(jsonData.Extensions.AddedFunctions) > 0 { + extensions.AddedFunctions = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsAddedFunctions, 0, len(jsonData.Extensions.AddedFunctions)) + for _, comp := range jsonData.Extensions.AddedFunctions { + v0Comp := pluginsv0alpha1.MetaV0alpha1ExtensionsAddedFunctions{ + Targets: comp.Targets, + Title: comp.Title, + } + if comp.Description != "" { + v0Comp.Description = &comp.Description + } + extensions.AddedFunctions = append(extensions.AddedFunctions, v0Comp) + } + } + if len(jsonData.Extensions.ExposedComponents) > 0 { extensions.ExposedComponents = make([]pluginsv0alpha1.MetaV0alpha1ExtensionsExposedComponents, 0, len(jsonData.Extensions.ExposedComponents)) for _, comp := range jsonData.Extensions.ExposedComponents { diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go index 228523f598e..2738af49db1 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/connections.go @@ -32,7 +32,7 @@ type ConnectionSecure struct { // Token is the reference of the token used to act as the Connection. // This value is stored securely and cannot be read back - Token common.InlineSecureValue `json:"webhook,omitzero,omitempty"` + Token common.InlineSecureValue `json:"token,omitzero,omitempty"` } func (v ConnectionSecure) IsZero() bool { @@ -116,3 +116,26 @@ type ConnectionList struct { // +listType=atomic Items []Connection `json:"items"` } + +// ExternalRepositoryList lists repositories from an external git provider +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ExternalRepositoryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + // +listType=atomic + Items []ExternalRepository `json:"items"` +} + +type ExternalRepository struct { + // Name of the repository + Name string `json:"name"` + // Owner is the user, organization, or workspace that owns the repository + // For GitHub: organization or user + // For GitLab: namespace (user or group) + // For Bitbucket: workspace + // For pure Git: empty + Owner string `json:"owner,omitempty"` + // URL of the repository + URL string `json:"url"` +} diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go index f06798c0ddd..c1785a048d1 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/register.go @@ -197,6 +197,7 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error { &HistoricJobList{}, &Connection{}, &ConnectionList{}, + &ExternalRepositoryList{}, ) return nil } diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index 48d03d23f99..97b1e752f68 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -262,6 +262,53 @@ func (in *ExportJobOptions) DeepCopy() *ExportJobOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalRepository) DeepCopyInto(out *ExternalRepository) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalRepository. +func (in *ExternalRepository) DeepCopy() *ExternalRepository { + if in == nil { + return nil + } + out := new(ExternalRepository) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalRepositoryList) DeepCopyInto(out *ExternalRepositoryList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ExternalRepository, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalRepositoryList. +func (in *ExternalRepositoryList) DeepCopy() *ExternalRepositoryList { + if in == nil { + return nil + } + out := new(ExternalRepositoryList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ExternalRepositoryList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FileItem) DeepCopyInto(out *FileItem) { *out = *in diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index a96fa2d8a6b..4db11489c98 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -26,6 +26,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.DeleteJobOptions": schema_pkg_apis_provisioning_v0alpha1_DeleteJobOptions(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ErrorDetails": schema_pkg_apis_provisioning_v0alpha1_ErrorDetails(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExportJobOptions": schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepository": schema_pkg_apis_provisioning_v0alpha1_ExternalRepository(ref), + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepositoryList": schema_pkg_apis_provisioning_v0alpha1_ExternalRepositoryList(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.FileItem": schema_pkg_apis_provisioning_v0alpha1_FileItem(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.FileList": schema_pkg_apis_provisioning_v0alpha1_FileList(ref), "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.GitHubConnectionConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubConnectionConfig(ref), @@ -318,7 +320,7 @@ func schema_pkg_apis_provisioning_v0alpha1_ConnectionSecure(ref common.Reference Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"), }, }, - "webhook": { + "token": { SchemaProps: spec.SchemaProps{ Description: "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", Default: map[string]interface{}{}, @@ -544,6 +546,96 @@ func schema_pkg_apis_provisioning_v0alpha1_ExportJobOptions(ref common.Reference } } +func schema_pkg_apis_provisioning_v0alpha1_ExternalRepository(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "name": { + SchemaProps: spec.SchemaProps{ + Description: "Name of the repository", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "owner": { + SchemaProps: spec.SchemaProps{ + Description: "Owner is the user, organization, or workspace that owns the repository For GitHub: organization or user For GitLab: namespace (user or group) For Bitbucket: workspace For pure Git: empty", + Type: []string{"string"}, + Format: "", + }, + }, + "url": { + SchemaProps: spec.SchemaProps{ + Description: "URL of the repository", + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"name", "url"}, + }, + }, + } +} + +func schema_pkg_apis_provisioning_v0alpha1_ExternalRepositoryList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ExternalRepositoryList lists repositories from an external git provider", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepository"), + }, + }, + }, + }, + }, + }, + Required: []string{"items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ExternalRepository", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + func schema_pkg_apis_provisioning_v0alpha1_FileItem(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index b9504855b80..72567e04b90 100644 --- a/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/provisioning/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,6 +1,7 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Paths API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,DeleteJobOptions,Resources +API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ExternalRepositoryList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,FileList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,HistoryList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobResourceSummary,Errors @@ -21,7 +22,6 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioni API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ResourceList,Items API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,TestResults,Errors API rule violation: list_type_missing,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,WebhookStatus,SubscribedEvents -API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSecure,Token API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,ConnectionSpec,GitHub API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobSpec,PullRequest API rule violation: names_match,github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1,JobStatus,URLs diff --git a/apps/provisioning/pkg/connection/connection.go b/apps/provisioning/pkg/connection/connection.go new file mode 100644 index 00000000000..d2043b5af4a --- /dev/null +++ b/apps/provisioning/pkg/connection/connection.go @@ -0,0 +1,16 @@ +package connection + +import ( + "context" +) + +//go:generate mockery --name Connection --structname MockConnection --inpackage --filename connection_mock.go --with-expecter +type Connection interface { + // Validate ensures the resource _looks_ correct. + // It should be called before trying to upsert a resource into the Kubernetes API server. + // This is not an indication that the connection information works, just that they are reasonably configured. + Validate(ctx context.Context) error + + // Mutate performs in place mutation of the underneath resource. + Mutate(context.Context) error +} diff --git a/apps/provisioning/pkg/connection/connection_mock.go b/apps/provisioning/pkg/connection/connection_mock.go new file mode 100644 index 00000000000..3867059d432 --- /dev/null +++ b/apps/provisioning/pkg/connection/connection_mock.go @@ -0,0 +1,128 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockConnection is an autogenerated mock type for the Connection type +type MockConnection struct { + mock.Mock +} + +type MockConnection_Expecter struct { + mock *mock.Mock +} + +func (_m *MockConnection) EXPECT() *MockConnection_Expecter { + return &MockConnection_Expecter{mock: &_m.Mock} +} + +// Mutate provides a mock function with given fields: _a0 +func (_m *MockConnection) Mutate(_a0 context.Context) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Mutate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Mutate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Mutate' +type MockConnection_Mutate_Call struct { + *mock.Call +} + +// Mutate is a helper method to define mock.On call +// - _a0 context.Context +func (_e *MockConnection_Expecter) Mutate(_a0 interface{}) *MockConnection_Mutate_Call { + return &MockConnection_Mutate_Call{Call: _e.mock.On("Mutate", _a0)} +} + +func (_c *MockConnection_Mutate_Call) Run(run func(_a0 context.Context)) *MockConnection_Mutate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockConnection_Mutate_Call) Return(_a0 error) *MockConnection_Mutate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Mutate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Mutate_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with given fields: ctx +func (_m *MockConnection) Validate(ctx context.Context) error { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockConnection_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockConnection_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockConnection_Expecter) Validate(ctx interface{}) *MockConnection_Validate_Call { + return &MockConnection_Validate_Call{Call: _e.mock.On("Validate", ctx)} +} + +func (_c *MockConnection_Validate_Call) Run(run func(ctx context.Context)) *MockConnection_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockConnection_Validate_Call) Return(_a0 error) *MockConnection_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConnection_Validate_Call) RunAndReturn(run func(context.Context) error) *MockConnection_Validate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockConnection creates a new instance of MockConnection. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockConnection(t interface { + mock.TestingT + Cleanup(func()) +}) *MockConnection { + mock := &MockConnection{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/extra_mock.go b/apps/provisioning/pkg/connection/extra_mock.go new file mode 100644 index 00000000000..cc2a1f3d5e2 --- /dev/null +++ b/apps/provisioning/pkg/connection/extra_mock.go @@ -0,0 +1,141 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockExtra is an autogenerated mock type for the Extra type +type MockExtra struct { + mock.Mock +} + +type MockExtra_Expecter struct { + mock *mock.Mock +} + +func (_m *MockExtra) EXPECT() *MockExtra_Expecter { + return &MockExtra_Expecter{mock: &_m.Mock} +} + +// Build provides a mock function with given fields: ctx, r +func (_m *MockExtra) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Build") + } + + var r0 Connection + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok { + r0 = rf(ctx, r) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Connection) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockExtra_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' +type MockExtra_Build_Call struct { + *mock.Call +} + +// Build is a helper method to define mock.On call +// - ctx context.Context +// - r *v0alpha1.Connection +func (_e *MockExtra_Expecter) Build(ctx interface{}, r interface{}) *MockExtra_Build_Call { + return &MockExtra_Build_Call{Call: _e.mock.On("Build", ctx, r)} +} + +func (_c *MockExtra_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockExtra_Build_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Connection)) + }) + return _c +} + +func (_c *MockExtra_Build_Call) Return(_a0 Connection, _a1 error) *MockExtra_Build_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockExtra_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockExtra_Build_Call { + _c.Call.Return(run) + return _c +} + +// Type provides a mock function with no fields +func (_m *MockExtra) Type() v0alpha1.ConnectionType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Type") + } + + var r0 v0alpha1.ConnectionType + if rf, ok := ret.Get(0).(func() v0alpha1.ConnectionType); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(v0alpha1.ConnectionType) + } + + return r0 +} + +// MockExtra_Type_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Type' +type MockExtra_Type_Call struct { + *mock.Call +} + +// Type is a helper method to define mock.On call +func (_e *MockExtra_Expecter) Type() *MockExtra_Type_Call { + return &MockExtra_Type_Call{Call: _e.mock.On("Type")} +} + +func (_c *MockExtra_Type_Call) Run(run func()) *MockExtra_Type_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockExtra_Type_Call) Return(_a0 v0alpha1.ConnectionType) *MockExtra_Type_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockExtra_Type_Call) RunAndReturn(run func() v0alpha1.ConnectionType) *MockExtra_Type_Call { + _c.Call.Return(run) + return _c +} + +// NewMockExtra creates a new instance of MockExtra. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockExtra(t interface { + mock.TestingT + Cleanup(func()) +}) *MockExtra { + mock := &MockExtra{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/factory.go b/apps/provisioning/pkg/connection/factory.go new file mode 100644 index 00000000000..4a0e46d84d2 --- /dev/null +++ b/apps/provisioning/pkg/connection/factory.go @@ -0,0 +1,75 @@ +package connection + +import ( + "context" + "fmt" + "sort" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +//go:generate mockery --name=Extra --structname=MockExtra --inpackage --filename=extra_mock.go --with-expecter +type Extra interface { + Type() provisioning.ConnectionType + Build(ctx context.Context, r *provisioning.Connection) (Connection, error) +} + +//go:generate mockery --name=Factory --structname=MockFactory --inpackage --filename=factory_mock.go --with-expecter +type Factory interface { + Types() []provisioning.ConnectionType + Build(ctx context.Context, r *provisioning.Connection) (Connection, error) +} + +type factory struct { + extras map[provisioning.ConnectionType]Extra + enabled map[provisioning.ConnectionType]struct{} +} + +func ProvideFactory(enabled map[provisioning.ConnectionType]struct{}, extras []Extra) (Factory, error) { + f := &factory{ + enabled: enabled, + extras: make(map[provisioning.ConnectionType]Extra, len(extras)), + } + + for _, e := range extras { + if _, exists := f.extras[e.Type()]; exists { + return nil, fmt.Errorf("connection type %q is already registered", e.Type()) + } + f.extras[e.Type()] = e + } + + return f, nil +} + +func (f *factory) Types() []provisioning.ConnectionType { + var types []provisioning.ConnectionType + for t := range f.enabled { + if _, exists := f.extras[t]; exists { + types = append(types, t) + } + } + + sort.Slice(types, func(i, j int) bool { + return string(types[i]) < string(types[j]) + }) + + return types +} + +func (f *factory) Build(ctx context.Context, c *provisioning.Connection) (Connection, error) { + for _, e := range f.extras { + if e.Type() == c.Spec.Type { + if _, enabled := f.enabled[e.Type()]; !enabled { + return nil, fmt.Errorf("connection type %q is not enabled", e.Type()) + } + + return e.Build(ctx, c) + } + } + + return nil, fmt.Errorf("connection type %q is not supported", c.Spec.Type) +} + +var ( + _ Factory = (*factory)(nil) +) diff --git a/apps/provisioning/pkg/connection/factory_mock.go b/apps/provisioning/pkg/connection/factory_mock.go new file mode 100644 index 00000000000..8fd7023920f --- /dev/null +++ b/apps/provisioning/pkg/connection/factory_mock.go @@ -0,0 +1,143 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package connection + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockFactory is an autogenerated mock type for the Factory type +type MockFactory struct { + mock.Mock +} + +type MockFactory_Expecter struct { + mock *mock.Mock +} + +func (_m *MockFactory) EXPECT() *MockFactory_Expecter { + return &MockFactory_Expecter{mock: &_m.Mock} +} + +// Build provides a mock function with given fields: ctx, r +func (_m *MockFactory) Build(ctx context.Context, r *v0alpha1.Connection) (Connection, error) { + ret := _m.Called(ctx, r) + + if len(ret) == 0 { + panic("no return value specified for Build") + } + + var r0 Connection + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) (Connection, error)); ok { + return rf(ctx, r) + } + if rf, ok := ret.Get(0).(func(context.Context, *v0alpha1.Connection) Connection); ok { + r0 = rf(ctx, r) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Connection) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *v0alpha1.Connection) error); ok { + r1 = rf(ctx, r) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockFactory_Build_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Build' +type MockFactory_Build_Call struct { + *mock.Call +} + +// Build is a helper method to define mock.On call +// - ctx context.Context +// - r *v0alpha1.Connection +func (_e *MockFactory_Expecter) Build(ctx interface{}, r interface{}) *MockFactory_Build_Call { + return &MockFactory_Build_Call{Call: _e.mock.On("Build", ctx, r)} +} + +func (_c *MockFactory_Build_Call) Run(run func(ctx context.Context, r *v0alpha1.Connection)) *MockFactory_Build_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*v0alpha1.Connection)) + }) + return _c +} + +func (_c *MockFactory_Build_Call) Return(_a0 Connection, _a1 error) *MockFactory_Build_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockFactory_Build_Call) RunAndReturn(run func(context.Context, *v0alpha1.Connection) (Connection, error)) *MockFactory_Build_Call { + _c.Call.Return(run) + return _c +} + +// Types provides a mock function with no fields +func (_m *MockFactory) Types() []v0alpha1.ConnectionType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Types") + } + + var r0 []v0alpha1.ConnectionType + if rf, ok := ret.Get(0).(func() []v0alpha1.ConnectionType); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]v0alpha1.ConnectionType) + } + } + + return r0 +} + +// MockFactory_Types_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Types' +type MockFactory_Types_Call struct { + *mock.Call +} + +// Types is a helper method to define mock.On call +func (_e *MockFactory_Expecter) Types() *MockFactory_Types_Call { + return &MockFactory_Types_Call{Call: _e.mock.On("Types")} +} + +func (_c *MockFactory_Types_Call) Run(run func()) *MockFactory_Types_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockFactory_Types_Call) Return(_a0 []v0alpha1.ConnectionType) *MockFactory_Types_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockFactory_Types_Call) RunAndReturn(run func() []v0alpha1.ConnectionType) *MockFactory_Types_Call { + _c.Call.Return(run) + return _c +} + +// NewMockFactory creates a new instance of MockFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *MockFactory { + mock := &MockFactory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/factory_test.go b/apps/provisioning/pkg/connection/factory_test.go new file mode 100644 index 00000000000..4ce6bc96e0e --- /dev/null +++ b/apps/provisioning/pkg/connection/factory_test.go @@ -0,0 +1,309 @@ +package connection + +import ( + "context" + "errors" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestProvideFactory(t *testing.T) { + t.Run("should create factory with valid extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should create factory with empty extras", func(t *testing.T) { + enabled := map[provisioning.ConnectionType]struct{}{} + + factory, err := ProvideFactory(enabled, []Extra{}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should create factory with nil enabled map", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + factory, err := ProvideFactory(nil, []Extra{extra1}) + require.NoError(t, err) + require.NotNil(t, factory) + }) + + t.Run("should return error when duplicate repository types", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.Error(t, err) + assert.Nil(t, factory) + assert.Contains(t, err.Error(), "connection type \"github\" is already registered") + }) +} + +func TestFactory_Types(t *testing.T) { + t.Run("should return only enabled types that have extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 2) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.Contains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should return sorted list of types", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 2) + // github should come before gitlab alphabetically + assert.Equal(t, provisioning.GithubConnectionType, types[0]) + assert.Equal(t, provisioning.GitlabConnectionType, types[1]) + }) + + t.Run("should return empty list when no types are enabled", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{} + + factory, err := ProvideFactory(enabled, []Extra{extra1}) + require.NoError(t, err) + + types := factory.Types() + assert.Empty(t, types) + }) + + t.Run("should not return types that are enabled but have no extras", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 1) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.NotContains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should not return types that have extras but are not enabled", func(t *testing.T) { + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + types := factory.Types() + assert.Len(t, types, 1) + assert.Contains(t, types, provisioning.GithubConnectionType) + assert.NotContains(t, types, provisioning.GitlabConnectionType) + }) + + t.Run("should return empty list when no extras are provided", func(t *testing.T) { + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{}) + require.NoError(t, err) + + types := factory.Types() + assert.Empty(t, types) + }) +} + +func TestFactory_Build(t *testing.T) { + t.Run("should successfully build connection when type is enabled and has extra", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + } + + mockConnection := NewMockConnection(t) + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + extra.EXPECT().Build(ctx, conn).Return(mockConnection, nil) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.NoError(t, err) + assert.Equal(t, mockConnection, result) + }) + + t.Run("should return error when type is not enabled", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GitlabConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection type \"gitlab\" is not enabled") + }) + + t.Run("should return error when type is not supported", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "connection type \"gitlab\" is not supported") + }) + + t.Run("should pass through errors from extra.Build()", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + } + + expectedErr := errors.New("build error") + extra := NewMockExtra(t) + extra.EXPECT().Type().Return(provisioning.GithubConnectionType) + extra.EXPECT().Build(ctx, conn).Return(nil, expectedErr) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, expectedErr, err) + }) + + t.Run("should build with multiple extras registered", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + }, + } + + mockConnection := NewMockConnection(t) + + extra1 := NewMockExtra(t) + extra1.EXPECT().Type().Return(provisioning.GithubConnectionType) + + extra2 := NewMockExtra(t) + extra2.EXPECT().Type().Return(provisioning.GitlabConnectionType) + extra2.EXPECT().Build(ctx, conn).Return(mockConnection, nil) + + enabled := map[provisioning.ConnectionType]struct{}{ + provisioning.GithubConnectionType: {}, + provisioning.GitlabConnectionType: {}, + } + + factory, err := ProvideFactory(enabled, []Extra{extra1, extra2}) + require.NoError(t, err) + + result, err := factory.Build(ctx, conn) + require.NoError(t, err) + assert.Equal(t, mockConnection, result) + }) +} diff --git a/apps/provisioning/pkg/connection/github/client.go b/apps/provisioning/pkg/connection/github/client.go new file mode 100644 index 00000000000..7ddb9a4665e --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/google/go-github/v70/github" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +// API errors that we need to convey after parsing real GH errors (or faking them). +var ( + //lint:ignore ST1005 this is not punctuation + ErrServiceUnavailable = apierrors.NewServiceUnavailable("github is unavailable") +) + +//go:generate mockery --name Client --structname MockClient --inpackage --filename client_mock.go --with-expecter +type Client interface { + // Apps and installations + GetApp(ctx context.Context) (App, error) + GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) +} + +// App represents a Github App. +type App struct { + // ID represents the GH app ID. + ID int64 + // Slug represents the GH app slug. + Slug string + // Owner represents the GH account/org owning the app + Owner string +} + +// AppInstallation represents a Github App Installation. +type AppInstallation struct { + // ID represents the GH installation ID. + ID int64 + // Whether the installation is enabled or not. + Enabled bool +} + +type githubClient struct { + gh *github.Client +} + +func NewClient(client *github.Client) Client { + return &githubClient{client} +} + +// GetApp gets the app by using the given token. +func (r *githubClient) GetApp(ctx context.Context) (App, error) { + app, _, err := r.gh.Apps.Get(ctx, "") + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return App{}, ErrServiceUnavailable + } + return App{}, err + } + + // TODO(ferruvich): do we need any other info? + return App{ + ID: app.GetID(), + Slug: app.GetSlug(), + Owner: app.GetOwner().GetLogin(), + }, nil +} + +// GetAppInstallation gets the installation of the app related to the given token. +func (r *githubClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) { + id, err := strconv.Atoi(installationID) + if err != nil { + return AppInstallation{}, fmt.Errorf("invalid installation ID: %s", installationID) + } + + installation, _, err := r.gh.Apps.GetInstallation(ctx, int64(id)) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable { + return AppInstallation{}, ErrServiceUnavailable + } + return AppInstallation{}, err + } + + // TODO(ferruvich): do we need any other info? + return AppInstallation{ + ID: installation.GetID(), + Enabled: installation.GetSuspendedAt().IsZero(), + }, nil +} diff --git a/apps/provisioning/pkg/connection/github/client_mock.go b/apps/provisioning/pkg/connection/github/client_mock.go new file mode 100644 index 00000000000..c9f009f5021 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client_mock.go @@ -0,0 +1,149 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package github + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the Client type +type MockClient struct { + mock.Mock +} + +type MockClient_Expecter struct { + mock *mock.Mock +} + +func (_m *MockClient) EXPECT() *MockClient_Expecter { + return &MockClient_Expecter{mock: &_m.Mock} +} + +// GetApp provides a mock function with given fields: ctx +func (_m *MockClient) GetApp(ctx context.Context) (App, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetApp") + } + + var r0 App + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (App, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) App); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(App) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetApp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetApp' +type MockClient_GetApp_Call struct { + *mock.Call +} + +// GetApp is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockClient_Expecter) GetApp(ctx interface{}) *MockClient_GetApp_Call { + return &MockClient_GetApp_Call{Call: _e.mock.On("GetApp", ctx)} +} + +func (_c *MockClient_GetApp_Call) Run(run func(ctx context.Context)) *MockClient_GetApp_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockClient_GetApp_Call) Return(_a0 App, _a1 error) *MockClient_GetApp_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetApp_Call) RunAndReturn(run func(context.Context) (App, error)) *MockClient_GetApp_Call { + _c.Call.Return(run) + return _c +} + +// GetAppInstallation provides a mock function with given fields: ctx, installationID +func (_m *MockClient) GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error) { + ret := _m.Called(ctx, installationID) + + if len(ret) == 0 { + panic("no return value specified for GetAppInstallation") + } + + var r0 AppInstallation + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (AppInstallation, error)); ok { + return rf(ctx, installationID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) AppInstallation); ok { + r0 = rf(ctx, installationID) + } else { + r0 = ret.Get(0).(AppInstallation) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, installationID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockClient_GetAppInstallation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAppInstallation' +type MockClient_GetAppInstallation_Call struct { + *mock.Call +} + +// GetAppInstallation is a helper method to define mock.On call +// - ctx context.Context +// - installationID string +func (_e *MockClient_Expecter) GetAppInstallation(ctx interface{}, installationID interface{}) *MockClient_GetAppInstallation_Call { + return &MockClient_GetAppInstallation_Call{Call: _e.mock.On("GetAppInstallation", ctx, installationID)} +} + +func (_c *MockClient_GetAppInstallation_Call) Run(run func(ctx context.Context, installationID string)) *MockClient_GetAppInstallation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockClient_GetAppInstallation_Call) Return(_a0 AppInstallation, _a1 error) *MockClient_GetAppInstallation_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockClient_GetAppInstallation_Call) RunAndReturn(run func(context.Context, string) (AppInstallation, error)) *MockClient_GetAppInstallation_Call { + _c.Call.Return(run) + return _c +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/github/client_test.go b/apps/provisioning/pkg/connection/github/client_test.go new file mode 100644 index 00000000000..bae6d6ac1e9 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/client_test.go @@ -0,0 +1,297 @@ +package github_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/google/go-github/v70/github" + conngh "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + mockhub "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGithubClient_GetApp(t *testing.T) { + tests := []struct { + name string + mockHandler *http.Client + token string + wantApp conngh.App + wantErr error + }{ + { + name: "get app successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + app := &github.App{ + ID: github.Ptr(int64(12345)), + Slug: github.Ptr("my-test-app"), + Owner: &github.User{ + Login: github.Ptr("grafana"), + }, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(app)) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{ + ID: 12345, + Slug: "my-test-app", + Owner: "grafana", + }, + wantErr: nil, + }, + { + name: "service unavailable", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{}, + wantErr: conngh.ErrServiceUnavailable, + }, + { + name: "other error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + })) + }), + ), + ), + token: "test-token", + wantApp: conngh.App{}, + wantErr: &github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + }, + }, + { + name: "unauthorized error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + }, + Message: "Bad credentials", + })) + }), + ), + ), + token: "invalid-token", + wantApp: conngh.App{}, + wantErr: &github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusUnauthorized, + }, + Message: "Bad credentials", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock client + ghClient := github.NewClient(tt.mockHandler) + client := conngh.NewClient(ghClient) + + // Call the method being tested + app, err := client.GetApp(context.Background()) + + // Check the error + if tt.wantErr != nil { + assert.Error(t, err) + assert.Equal(t, tt.wantApp, app) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.wantApp, app) + } + }) + } +} + +func TestGithubClient_GetAppInstallation(t *testing.T) { + tests := []struct { + name string + mockHandler *http.Client + appToken string + installationID string + wantInstallation conngh.AppInstallation + wantErr bool + errContains string + }{ + { + name: "get disabled app installation successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + installation := &github.Installation{ + ID: github.Ptr(int64(67890)), + SuspendedAt: github.Ptr(github.Timestamp{Time: time.Now()}), + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(installation)) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{ + ID: 67890, + Enabled: false, + }, + wantErr: false, + }, + { + name: "get enabled app installation successfully", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + installation := &github.Installation{ + ID: github.Ptr(int64(67890)), + SuspendedAt: nil, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(installation)) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{ + ID: 67890, + Enabled: true, + }, + wantErr: false, + }, + { + name: "invalid installation ID", + mockHandler: mockhub.NewMockedHTTPClient(), + appToken: "test-app-token", + installationID: "not-a-number", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + errContains: "invalid installation ID", + }, + { + name: "service unavailable", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + { + name: "installation not found", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusNotFound, + }, + Message: "Not Found", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "99999", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + { + name: "other error", + mockHandler: mockhub.NewMockedHTTPClient( + mockhub.WithRequestMatchHandler( + mockhub.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusInternalServerError, + }, + Message: "Internal server error", + })) + }), + ), + ), + appToken: "test-app-token", + installationID: "67890", + wantInstallation: conngh.AppInstallation{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock client + ghClient := github.NewClient(tt.mockHandler) + client := conngh.NewClient(ghClient) + + // Call the method being tested + installation, err := client.GetAppInstallation(context.Background(), tt.installationID) + + // Check the error + if tt.wantErr { + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + } else { + assert.NoError(t, err) + } + + // Check the result + assert.Equal(t, tt.wantInstallation, installation) + }) + } +} diff --git a/apps/provisioning/pkg/connection/github/connection.go b/apps/provisioning/pkg/connection/github/connection.go new file mode 100644 index 00000000000..6a2da98ac8d --- /dev/null +++ b/apps/provisioning/pkg/connection/github/connection.go @@ -0,0 +1,192 @@ +package github + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v4" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +//go:generate mockery --name GithubFactory --structname MockGithubFactory --inpackage --filename factory_mock.go --with-expecter +type GithubFactory interface { + New(ctx context.Context, ghToken common.RawSecureValue) Client +} + +type Connection struct { + obj *provisioning.Connection + ghFactory GithubFactory +} + +func NewConnection( + obj *provisioning.Connection, + factory GithubFactory, +) Connection { + return Connection{ + obj: obj, + ghFactory: factory, + } +} + +const ( + //TODO(ferruvich): these probably need to be setup in API configuration. + githubInstallationURL = "https://github.com/settings/installations" + jwtExpirationMinutes = 10 // GitHub Apps JWT tokens expire in 10 minutes maximum +) + +// Mutate performs in place mutation of the underneath resource. +func (c *Connection) Mutate(_ context.Context) error { + // Do nothing in case spec.Github is nil. + // If this field is required, we should fail at validation time. + if c.obj.Spec.GitHub == nil { + return nil + } + + c.obj.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, c.obj.Spec.GitHub.InstallationID) + + // Generate JWT token if private key is being provided. + // Same as for the spec.Github, if such a field is required, Validation will take care of that. + if !c.obj.Secure.PrivateKey.Create.IsZero() { + token, err := generateToken(c.obj.Spec.GitHub.AppID, c.obj.Secure.PrivateKey.Create) + if err != nil { + return fmt.Errorf("failed to generate JWT token: %w", err) + } + + // Store the generated token + c.obj.Secure.Token = common.InlineSecureValue{Create: token} + } + + return nil +} + +// Token generates and returns the Connection token. +func generateToken(appID string, privateKey common.RawSecureValue) (common.RawSecureValue, error) { + // Decode base64-encoded private key + privateKeyPEM, err := base64.StdEncoding.DecodeString(string(privateKey)) + if err != nil { + return "", fmt.Errorf("failed to decode base64 private key: %w", err) + } + + // Parse the private key + key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKeyPEM) + if err != nil { + return "", fmt.Errorf("failed to parse private key: %w", err) + } + + // Create the JWT token + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(jwtExpirationMinutes) * time.Minute)), + Issuer: appID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(key) + if err != nil { + return "", fmt.Errorf("failed to sign JWT token: %w", err) + } + + return common.RawSecureValue(signedToken), nil +} + +// Validate ensures the resource _looks_ correct. +func (c *Connection) Validate(ctx context.Context) error { + list := field.ErrorList{} + + if c.obj.Spec.Type != provisioning.GithubConnectionType { + list = append(list, field.Invalid(field.NewPath("spec", "type"), c.obj.Spec.Type, "invalid connection type")) + + // Doesn't make much sense to continue validating a connection which is not a Github one. + return toError(c.obj.GetName(), list) + } + + if c.obj.Spec.GitHub == nil { + list = append( + list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), + ) + + // Doesn't make much sense to continue validating a connection with no information. + return toError(c.obj.GetName(), list) + } + + if c.obj.Secure.PrivateKey.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) + } + if c.obj.Secure.Token.IsZero() { + list = append(list, field.Required(field.NewPath("secure", "token"), "token must be specified for GitHub connection")) + } + if !c.obj.Secure.ClientSecret.IsZero() { + list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) + } + + // Validate GitHub configuration fields + if c.obj.Spec.GitHub.AppID == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "appID"), "appID must be specified for GitHub connection")) + } + if c.obj.Spec.GitHub.InstallationID == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "installationID"), "installationID must be specified for GitHub connection")) + } + + // In case we have any error above, we don't go forward with the validation, and return the errors. + if len(list) > 0 { + return toError(c.obj.GetName(), list) + } + + // Validating app content via GH API + if err := c.validateAppAndInstallation(ctx); err != nil { + list = append(list, err) + } + + return toError(c.obj.GetName(), list) +} + +// validateAppAndInstallation validates the appID and installationID against the given github token. +func (c *Connection) validateAppAndInstallation(ctx context.Context) *field.Error { + ghClient := c.ghFactory.New(ctx, c.obj.Secure.Token.Create) + + app, err := ghClient.GetApp(ctx) + if err != nil { + if errors.Is(err, ErrServiceUnavailable) { + return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable) + } + return field.Invalid(field.NewPath("spec", "token"), "[REDACTED]", "invalid token") + } + + if fmt.Sprintf("%d", app.ID) != c.obj.Spec.GitHub.AppID { + return field.Invalid(field.NewPath("spec", "appID"), c.obj.Spec.GitHub.AppID, "appID mismatch") + } + + _, err = ghClient.GetAppInstallation(ctx, c.obj.Spec.GitHub.InstallationID) + if err != nil { + if errors.Is(err, ErrServiceUnavailable) { + return field.InternalError(field.NewPath("spec", "token"), ErrServiceUnavailable) + } + return field.Invalid(field.NewPath("spec", "installationID"), c.obj.Spec.GitHub.InstallationID, "invalid installation ID") + } + + return nil +} + +// toError converts a field.ErrorList to an error, returning nil if the list is empty +func toError(name string, list field.ErrorList) error { + if len(list) == 0 { + return nil + } + return apierrors.NewInvalid( + provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), + name, + list, + ) +} + +var ( + _ connection.Connection = (*Connection)(nil) +) diff --git a/apps/provisioning/pkg/connection/github/connection_test.go b/apps/provisioning/pkg/connection/github/connection_test.go new file mode 100644 index 00000000000..6a916db730e --- /dev/null +++ b/apps/provisioning/pkg/connection/github/connection_test.go @@ -0,0 +1,434 @@ +package github + +import ( + "context" + "encoding/base64" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +//nolint:gosec // Test RSA private key (generated for testing purposes only) +const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAoInVbLY9io2Q/wHvUIXlEHg2Qyvd8eRzBAVEJ92DS6fx9H10 +06V0VRm78S0MXyo6i+n8ZAbZ0/R+GWpP2Ephxm0Gs2zo+iO2mpB19xQFI4o6ZTOw +b2WyjSaa2Vr4oyDkqti6AvfjW4VUAu932e08GkgwmmQSHXj7FX2CMWjgUwTTcuaX +65SHNKLNYLUP0HTumLzoZeqDTdoMMpKNdgH9Avr4/8vkVJ0mD6rqvxnw3JHsseNO +WdQTxf2aApBNHIIKxWZ2i/ZmjLNey7kltgjEquGiBdJvip3fHhH5XHdkrXcjRtnw +OJDnDmi5lQwv5yUBOSkbvbXRv/L/m0YLoD/fbwIDAQABAoIBAFfl//hM8/cnuesV ++R1Con/ZAgTXQOdPqPXbmEyniVrkMqMmCdBUOBTcST4s5yg36+RtkeaGpb/ajyyF +PAB2AYDucwvMpudGpJWOYTiOOp4R8hU1LvZfXVrRd1lo6NgQi4NLtNUpOtACeVQ+ +H4Yv0YemXQ47mnuOoRNMK/u3q5NoIdSahWptXBgUno8KklNpUrH3IYWaUxfBzDN3 +2xsVRTn2SfTSyoDmTDdTgptJONmoK1/sV7UsgWksdFc6XyYhsFAZgOGEJrBABRvF +546dyQ0cWxuPyVXpM7CN3tqC5ssvLjElg3LicK1V6gnjpdRnnvX88d1Eh3Uc/9IM +OZInT2ECgYEA6W8sQXTWinyEwl8SDKKMbB2ApIghAcFgdRxprZE4WFxjsYNCNL70 +dnSB7MRuzmxf5W77cV0N7JhH66N8HvY6Xq9olrpQ5dNttR4w8Pyv3wavDe8x7seL +5L2Xtbu7ihDr8Dk27MjiBSin3IxhBP5CJS910+pR6LrAWtEuU+FzFfECgYEAsA6y +qxHhCMXlTnauXhsnmPd1g61q7chW8kLQFYtHMLlQlgjHTW7irDZ9cPbPYDNjwRLO +7KLorcpv2NKe7rqq2ZyCm6hf1b9WnlQjo3dLpNWMu6fhy/smK8MgbRqcWpX+oTKF +79mK6hbY7o6eBzsQHBl7Z+LBNuwYmp9qOodPa18CgYEArv6ipKdcNhFGzRfMRiCN +OHederp6VACNuP2F05IsNUF9kxOdTEFirnKE++P+VU01TqA2azOhPp6iO+ohIGzi +MR06QNSH1OL9OWvasK4dggpWrRGF00VQgDgJRTnpS4WH+lxJ6pRlrAxgWpv6F24s +VAgSQr1Ejj2B+hMasdMvHWECgYBJ4uE4yhgXBnZlp4kmFV9Y4wF+cZkekaVrpn6N +jBYkbKFVVfnOlWqru3KJpgsB5I9IyAvvY68iwIKQDFSG+/AXw4dMrC0MF3DSoZ0T +TU2Br92QI7SvVod+djV1lGVp3ukt3XY4YqPZ+hywgUnw3uiz4j3YK2HLGup4ec6r +IX5DIQKBgHRLzvT3zqtlR1Oh0vv098clLwt+pGzXOxzJpxioOa5UqK13xIpFXbcg +iWUVh5YXCcuqaICUv4RLIEac5xQitk9Is/9IhP0NJ/81rHniosvdSpCeFXzxTImS +B8Uc0WUgheB4+yVKGnYpYaSOgFFI5+1BYUva/wDHLy2pWHz39Usb +-----END RSA PRIVATE KEY-----` + +func TestConnection_Mutate(t *testing.T) { + t.Run("should add URL to Github connection", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + }) + + t.Run("should generate JWT token when private key is provided", func(t *testing.T) { + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue(privateKeyBase64), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) + assert.False(t, c.Secure.Token.Create.IsZero(), "JWT token should be generated") + }) + + t.Run("should do nothing when GitHub config is nil", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GitlabConnectionType, + Gitlab: &provisioning.GitlabConnectionConfig{ + ClientID: "clientID", + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + require.NoError(t, conn.Mutate(context.Background())) + }) + + t.Run("should fail when private key is not base64", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("invalid-key"), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + err := conn.Mutate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to generate JWT token") + assert.Contains(t, err.Error(), "failed to decode base64 private key") + }) + + t.Run("should fail when private key is invalid", func(t *testing.T) { + c := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue(base64.StdEncoding.EncodeToString([]byte("invalid-key"))), + }, + }, + } + + mockFactory := NewMockGithubFactory(t) + conn := NewConnection(c, mockFactory) + + err := conn.Mutate(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to generate JWT token") + assert.Contains(t, err.Error(), "failed to parse private key") + }) +} + +func TestConnection_Validate(t *testing.T) { + tests := []struct { + name string + connection *provisioning.Connection + setupMock func(*MockGithubFactory) + wantErr bool + errMsgContains []string + }{ + { + name: "invalid type returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: "invalid", + }, + }, + wantErr: true, + errMsgContains: []string{"spec.type"}, + }, + { + name: "github type without github config returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github"}, + }, + { + name: "github type without private key returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.privateKey"}, + }, + { + name: "github type without token returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.token"}, + }, + { + name: "github type with client secret returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + ClientSecret: common.InlineSecureValue{ + Create: common.NewSecretValue("test-client-secret"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"secure.clientSecret"}, + }, + { + name: "github type without appID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github.appID"}, + }, + { + name: "github type without installationID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "test-private-key", + }, + Token: common.InlineSecureValue{ + Name: "test-token", + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.github.installationID"}, + }, + { + name: "github type with valid config is valid", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: false, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil) + mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{ID: 456}, nil) + }, + }, + { + name: "problem getting app returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.token", "[REDACTED]"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{}, assert.AnError) + }, + }, + { + name: "mismatched app ID returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.appID"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 444, Slug: "test-app"}, nil) + }, + }, + { + name: "problem when getting installation returns error", + connection: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("test-token"), + }, + }, + }, + wantErr: true, + errMsgContains: []string{"spec.installationID", "456"}, + setupMock: func(mockFactory *MockGithubFactory) { + mockClient := NewMockClient(t) + + mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient) + mockClient.EXPECT().GetApp(mock.Anything).Return(App{ID: 123, Slug: "test-app"}, nil) + mockClient.EXPECT().GetAppInstallation(mock.Anything, "456").Return(AppInstallation{}, assert.AnError) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockFactory := NewMockGithubFactory(t) + if tt.setupMock != nil { + tt.setupMock(mockFactory) + } + + conn := NewConnection(tt.connection, mockFactory) + err := conn.Validate(context.Background()) + if tt.wantErr { + assert.Error(t, err) + for _, msg := range tt.errMsgContains { + assert.Contains(t, err.Error(), msg) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/apps/provisioning/pkg/connection/github/extra.go b/apps/provisioning/pkg/connection/github/extra.go new file mode 100644 index 00000000000..2c207637c61 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/extra.go @@ -0,0 +1,36 @@ +package github + +import ( + "context" + "fmt" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" +) + +type extra struct { + factory GithubFactory +} + +func (e *extra) Type() provisioning.ConnectionType { + return provisioning.GithubConnectionType +} + +func (e *extra) Build(ctx context.Context, connection *provisioning.Connection) (connection.Connection, error) { + logger := logging.FromContext(ctx) + if connection == nil || connection.Spec.GitHub == nil { + logger.Error("connection is nil or github info is nil") + + return nil, fmt.Errorf("invalid github connection") + } + + c := NewConnection(connection, e.factory) + return &c, nil +} + +func Extra(factory GithubFactory) connection.Extra { + return &extra{ + factory: factory, + } +} diff --git a/apps/provisioning/pkg/connection/github/extra_test.go b/apps/provisioning/pkg/connection/github/extra_test.go new file mode 100644 index 00000000000..c5bcc8279d9 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/extra_test.go @@ -0,0 +1,126 @@ +package github_test + +import ( + "context" + "testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestExtra_Type(t *testing.T) { + t.Run("should return GithubConnectionType", func(t *testing.T) { + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result := e.Type() + assert.Equal(t, provisioning.GithubConnectionType, result) + }) +} + +func TestExtra_Build(t *testing.T) { + t.Run("should successfully build connection", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Create: common.NewSecretValue("test-private-key"), + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + + e := github.Extra(mockFactory) + + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should handle different connection configurations", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "another-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "789", + InstallationID: "101112", + }, + }, + Secure: provisioning.ConnectionSecure{ + PrivateKey: common.InlineSecureValue{ + Name: "existing-private-key", + }, + Token: common.InlineSecureValue{ + Name: "existing-token", + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + + e := github.Extra(mockFactory) + + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should build connection with background context", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) + + t.Run("should always pass empty token to factory.New", func(t *testing.T) { + ctx := context.Background() + conn := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, + Spec: provisioning.ConnectionSpec{ + Type: provisioning.GithubConnectionType, + GitHub: &provisioning.GitHubConnectionConfig{ + AppID: "123", + InstallationID: "456", + }, + }, + Secure: provisioning.ConnectionSecure{ + Token: common.InlineSecureValue{ + Create: common.NewSecretValue("some-token"), + }, + }, + } + + mockFactory := github.NewMockGithubFactory(t) + e := github.Extra(mockFactory) + result, err := e.Build(ctx, conn) + require.NoError(t, err) + require.NotNil(t, result) + }) +} diff --git a/apps/provisioning/pkg/connection/github/factory.go b/apps/provisioning/pkg/connection/github/factory.go new file mode 100644 index 00000000000..2399f3c9f69 --- /dev/null +++ b/apps/provisioning/pkg/connection/github/factory.go @@ -0,0 +1,39 @@ +package github + +import ( + "context" + "net/http" + + "github.com/google/go-github/v70/github" + "golang.org/x/oauth2" + + common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" +) + +// Factory creates new GitHub clients. +// It exists only for the ability to test the code easily. +type Factory struct { + // Client allows overriding the client to use in the GH client returned. It exists primarily for testing. + // FIXME: we should replace in this way. We should add some options pattern for the factory. + Client *http.Client +} + +func ProvideFactory() GithubFactory { + return &Factory{} +} + +func (r *Factory) New(ctx context.Context, ghToken common.RawSecureValue) Client { + if r.Client != nil { + return NewClient(github.NewClient(r.Client)) + } + + if !ghToken.IsZero() { + tokenSrc := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: string(ghToken)}, + ) + tokenClient := oauth2.NewClient(ctx, tokenSrc) + return NewClient(github.NewClient(tokenClient)) + } + + return NewClient(github.NewClient(&http.Client{})) +} diff --git a/apps/provisioning/pkg/connection/github/factory_mock.go b/apps/provisioning/pkg/connection/github/factory_mock.go new file mode 100644 index 00000000000..a9e1424b62d --- /dev/null +++ b/apps/provisioning/pkg/connection/github/factory_mock.go @@ -0,0 +1,86 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package github + +import ( + context "context" + + v0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + mock "github.com/stretchr/testify/mock" +) + +// MockGithubFactory is an autogenerated mock type for the GithubFactory type +type MockGithubFactory struct { + mock.Mock +} + +type MockGithubFactory_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGithubFactory) EXPECT() *MockGithubFactory_Expecter { + return &MockGithubFactory_Expecter{mock: &_m.Mock} +} + +// New provides a mock function with given fields: ctx, ghToken +func (_m *MockGithubFactory) New(ctx context.Context, ghToken v0alpha1.RawSecureValue) Client { + ret := _m.Called(ctx, ghToken) + + if len(ret) == 0 { + panic("no return value specified for New") + } + + var r0 Client + if rf, ok := ret.Get(0).(func(context.Context, v0alpha1.RawSecureValue) Client); ok { + r0 = rf(ctx, ghToken) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(Client) + } + } + + return r0 +} + +// MockGithubFactory_New_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'New' +type MockGithubFactory_New_Call struct { + *mock.Call +} + +// New is a helper method to define mock.On call +// - ctx context.Context +// - ghToken v0alpha1.RawSecureValue +func (_e *MockGithubFactory_Expecter) New(ctx interface{}, ghToken interface{}) *MockGithubFactory_New_Call { + return &MockGithubFactory_New_Call{Call: _e.mock.On("New", ctx, ghToken)} +} + +func (_c *MockGithubFactory_New_Call) Run(run func(ctx context.Context, ghToken v0alpha1.RawSecureValue)) *MockGithubFactory_New_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(v0alpha1.RawSecureValue)) + }) + return _c +} + +func (_c *MockGithubFactory_New_Call) Return(_a0 Client) *MockGithubFactory_New_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubFactory_New_Call) RunAndReturn(run func(context.Context, v0alpha1.RawSecureValue) Client) *MockGithubFactory_New_Call { + _c.Call.Return(run) + return _c +} + +// NewMockGithubFactory creates a new instance of MockGithubFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGithubFactory(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGithubFactory { + mock := &MockGithubFactory{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/apps/provisioning/pkg/connection/mutator.go b/apps/provisioning/pkg/connection/mutator.go deleted file mode 100644 index 30291669905..00000000000 --- a/apps/provisioning/pkg/connection/mutator.go +++ /dev/null @@ -1,28 +0,0 @@ -package connection - -import ( - "fmt" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" -) - -const ( - githubInstallationURL = "https://github.com/settings/installations" -) - -func MutateConnection(connection *provisioning.Connection) error { - switch connection.Spec.Type { - case provisioning.GithubConnectionType: - // Do nothing in case spec.Github is nil. - // If this field is required, we should fail at validation time. - if connection.Spec.GitHub == nil { - return nil - } - - connection.Spec.URL = fmt.Sprintf("%s/%s", githubInstallationURL, connection.Spec.GitHub.InstallationID) - return nil - default: - // TODO: we need to setup the URL for bitbucket and gitlab. - return nil - } -} diff --git a/apps/provisioning/pkg/connection/mutator_test.go b/apps/provisioning/pkg/connection/mutator_test.go deleted file mode 100644 index a25aabd10a1..00000000000 --- a/apps/provisioning/pkg/connection/mutator_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package connection_test - -import ( - "testing" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/connection" - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestMutateConnection(t *testing.T) { - t.Run("should add URL to Github connection", func(t *testing.T) { - c := &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - }, - } - - require.NoError(t, connection.MutateConnection(c)) - assert.Equal(t, "https://github.com/settings/installations/456", c.Spec.URL) - }) -} diff --git a/apps/provisioning/pkg/connection/validator.go b/apps/provisioning/pkg/connection/validator.go deleted file mode 100644 index c2537e3af2f..00000000000 --- a/apps/provisioning/pkg/connection/validator.go +++ /dev/null @@ -1,104 +0,0 @@ -package connection - -import ( - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -func ValidateConnection(connection *provisioning.Connection) error { - list := field.ErrorList{} - - if connection.Spec.Type == "" { - list = append(list, field.Required(field.NewPath("spec", "type"), "type must be specified")) - } - - switch connection.Spec.Type { - case provisioning.GithubConnectionType: - list = append(list, validateGithubConnection(connection)...) - case provisioning.BitbucketConnectionType: - list = append(list, validateBitbucketConnection(connection)...) - case provisioning.GitlabConnectionType: - list = append(list, validateGitlabConnection(connection)...) - default: - list = append( - list, field.NotSupported( - field.NewPath("spec", "type"), - connection.Spec.Type, - []provisioning.ConnectionType{ - provisioning.GithubConnectionType, - provisioning.BitbucketConnectionType, - provisioning.GitlabConnectionType, - }), - ) - } - - return toError(connection.GetName(), list) -} - -func validateGithubConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.GitHub == nil { - list = append( - list, field.Required(field.NewPath("spec", "github"), "github info must be specified for GitHub connection"), - ) - } - - if connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "privateKey"), "privateKey must be specified for GitHub connection")) - } - if !connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "clientSecret"), "clientSecret is forbidden in GitHub connection")) - } - - return list -} - -func validateBitbucketConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.Bitbucket == nil { - list = append( - list, field.Required(field.NewPath("spec", "bitbucket"), "bitbucket info must be specified in Bitbucket connection"), - ) - } - if connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Bitbucket connection")) - } - if !connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Bitbucket connection")) - } - - return list -} - -func validateGitlabConnection(connection *provisioning.Connection) field.ErrorList { - list := field.ErrorList{} - - if connection.Spec.Gitlab == nil { - list = append( - list, field.Required(field.NewPath("spec", "gitlab"), "gitlab info must be specified in Gitlab connection"), - ) - } - if connection.Secure.ClientSecret.IsZero() { - list = append(list, field.Required(field.NewPath("secure", "clientSecret"), "clientSecret must be specified for Gitlab connection")) - } - if !connection.Secure.PrivateKey.IsZero() { - list = append(list, field.Forbidden(field.NewPath("secure", "privateKey"), "privateKey is forbidden in Gitlab connection")) - } - - return list -} - -// toError converts a field.ErrorList to an error, returning nil if the list is empty -func toError(name string, list field.ErrorList) error { - if len(list) == 0 { - return nil - } - return apierrors.NewInvalid( - provisioning.ConnectionResourceInfo.GroupVersionKind().GroupKind(), - name, - list, - ) -} diff --git a/apps/provisioning/pkg/connection/validator_test.go b/apps/provisioning/pkg/connection/validator_test.go deleted file mode 100644 index 23d4b01b800..00000000000 --- a/apps/provisioning/pkg/connection/validator_test.go +++ /dev/null @@ -1,253 +0,0 @@ -package connection_test - -import ( - "testing" - - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" - "github.com/grafana/grafana/apps/provisioning/pkg/connection" - common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - "github.com/stretchr/testify/assert" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -func TestValidateConnection(t *testing.T) { - tests := []struct { - name string - connection *provisioning.Connection - wantErr bool - errMsg string - }{ - { - name: "empty type returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{}, - }, - wantErr: true, - errMsg: "spec.type", - }, - { - name: "invalid type returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: "invalid", - }, - }, - wantErr: true, - errMsg: "spec.type", - }, - { - name: "github type without github config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.github", - }, - { - name: "github type without private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "github type with client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "github type with github config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GithubConnectionType, - GitHub: &provisioning.GitHubConnectionConfig{ - AppID: "123", - InstallationID: "456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - }, - }, - wantErr: false, - }, - { - name: "bitbucket type without bitbucket config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.bitbucket", - }, - { - name: "bitbucket type without client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "bitbucket type with private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "bitbucket type with bitbucket config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.BitbucketConnectionType, - Bitbucket: &provisioning.BitbucketConnectionConfig{ - ClientID: "client-123", - }, - }, - Secure: provisioning.ConnectionSecure{ - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: false, - }, - { - name: "gitlab type without gitlab config returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - }, - }, - wantErr: true, - errMsg: "spec.gitlab", - }, - { - name: "gitlab type without client secret returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - }, - wantErr: true, - errMsg: "secure.clientSecret", - }, - { - name: "gitlab type with private key returns error", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - Secure: provisioning.ConnectionSecure{ - PrivateKey: common.InlineSecureValue{ - Name: "test-private-key", - }, - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: true, - errMsg: "secure.privateKey", - }, - { - name: "gitlab type with gitlab config is valid", - connection: &provisioning.Connection{ - ObjectMeta: metav1.ObjectMeta{Name: "test-connection"}, - Spec: provisioning.ConnectionSpec{ - Type: provisioning.GitlabConnectionType, - Gitlab: &provisioning.GitlabConnectionConfig{ - ClientID: "client-456", - }, - }, - Secure: provisioning.ConnectionSecure{ - ClientSecret: common.InlineSecureValue{ - Name: "test-client-secret", - }, - }, - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := connection.ValidateConnection(tt.connection) - if tt.wantErr { - assert.Error(t, err) - if tt.errMsg != "" { - assert.Contains(t, err.Error(), tt.errMsg) - } - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/apps/provisioning/pkg/controller/connection_status.go b/apps/provisioning/pkg/controller/connection_status.go new file mode 100644 index 00000000000..0d8a0002f41 --- /dev/null +++ b/apps/provisioning/pkg/controller/connection_status.go @@ -0,0 +1,40 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// ConnectionStatusPatcher provides methods to patch Connection status subresources. +type ConnectionStatusPatcher struct { + client client.ProvisioningV0alpha1Interface +} + +// NewConnectionStatusPatcher creates a new ConnectionStatusPatcher. +func NewConnectionStatusPatcher(client client.ProvisioningV0alpha1Interface) *ConnectionStatusPatcher { + return &ConnectionStatusPatcher{ + client: client, + } +} + +// Patch applies JSON patch operations to a Connection's status subresource. +func (p *ConnectionStatusPatcher) Patch(ctx context.Context, conn *provisioning.Connection, patchOperations ...map[string]interface{}) error { + patch, err := json.Marshal(patchOperations) + if err != nil { + return fmt.Errorf("unable to marshal patch data: %w", err) + } + + _, err = p.client.Connections(conn.Namespace). + Patch(ctx, conn.Name, types.JSONPatchType, patch, metav1.PatchOptions{}, "status") + if err != nil { + return fmt.Errorf("unable to update connection status: %w", err) + } + + return nil +} diff --git a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go index 8ac26b192c9..f5be635560d 100644 --- a/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go +++ b/apps/provisioning/pkg/generated/applyconfiguration/provisioning/v0alpha1/connectionsecure.go @@ -13,7 +13,7 @@ import ( type ConnectionSecureApplyConfiguration struct { PrivateKey *commonv0alpha1.InlineSecureValue `json:"privateKey,omitempty"` ClientSecret *commonv0alpha1.InlineSecureValue `json:"clientSecret,omitempty"` - Token *commonv0alpha1.InlineSecureValue `json:"webhook,omitempty"` + Token *commonv0alpha1.InlineSecureValue `json:"token,omitempty"` } // ConnectionSecureApplyConfiguration constructs a declarative configuration of the ConnectionSecure type for use with diff --git a/apps/secret/kinds/v1beta1/keeper.cue b/apps/secret/kinds/v1beta1/keeper.cue index 36e20198ab7..4ade465d1b5 100644 --- a/apps/secret/kinds/v1beta1/keeper.cue +++ b/apps/secret/kinds/v1beta1/keeper.cue @@ -30,11 +30,22 @@ KeeperSpec: { } #AWSConfig: { - accessKeyID: #CredentialValue - secretAccessKey: #CredentialValue + region: string + accessKey?: #AWSAccessKey + assumeRole?: #AWSAssumeRole kmsKeyID?: string } +#AWSAccessKey: { + accessKeyID: #CredentialValue + secretAccessKey: #CredentialValue +} + +#AWSAssumeRole: { + assumeRoleArn: string + externalID: string +} + #AzureConfig: { keyVaultName: string tenantID: string diff --git a/apps/secret/pkg/apis/secret/v1beta1/keeper_spec_gen.go b/apps/secret/pkg/apis/secret/v1beta1/keeper_spec_gen.go index 76d232465c8..806a3df8294 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/keeper_spec_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/keeper_spec_gen.go @@ -4,14 +4,26 @@ package v1beta1 // +k8s:openapi-gen=true type KeeperAWSConfig struct { - AccessKeyID KeeperCredentialValue `json:"accessKeyID"` - SecretAccessKey KeeperCredentialValue `json:"secretAccessKey"` - KmsKeyID *string `json:"kmsKeyID,omitempty"` + Region string `json:"region"` + AccessKey *KeeperAWSAccessKey `json:"accessKey,omitempty"` + AssumeRole *KeeperAWSAssumeRole `json:"assumeRole,omitempty"` + KmsKeyID *string `json:"kmsKeyID,omitempty"` } // NewKeeperAWSConfig creates a new KeeperAWSConfig object. func NewKeeperAWSConfig() *KeeperAWSConfig { - return &KeeperAWSConfig{ + return &KeeperAWSConfig{} +} + +// +k8s:openapi-gen=true +type KeeperAWSAccessKey struct { + AccessKeyID KeeperCredentialValue `json:"accessKeyID"` + SecretAccessKey KeeperCredentialValue `json:"secretAccessKey"` +} + +// NewKeeperAWSAccessKey creates a new KeeperAWSAccessKey object. +func NewKeeperAWSAccessKey() *KeeperAWSAccessKey { + return &KeeperAWSAccessKey{ AccessKeyID: *NewKeeperCredentialValue(), SecretAccessKey: *NewKeeperCredentialValue(), } @@ -36,6 +48,17 @@ func NewKeeperCredentialValue() *KeeperCredentialValue { return &KeeperCredentialValue{} } +// +k8s:openapi-gen=true +type KeeperAWSAssumeRole struct { + AssumeRoleArn string `json:"assumeRoleArn"` + ExternalID string `json:"externalID"` +} + +// NewKeeperAWSAssumeRole creates a new KeeperAWSAssumeRole object. +func NewKeeperAWSAssumeRole() *KeeperAWSAssumeRole { + return &KeeperAWSAssumeRole{} +} + // +k8s:openapi-gen=true type KeeperAzureConfig struct { KeyVaultName string `json:"keyVaultName"` diff --git a/apps/secret/pkg/apis/secret/v1beta1/keeper_type.go b/apps/secret/pkg/apis/secret/v1beta1/keeper_type.go index 0feeac75dd1..6f61acf1a77 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/keeper_type.go +++ b/apps/secret/pkg/apis/secret/v1beta1/keeper_type.go @@ -12,6 +12,7 @@ const ( AzureKeeperType KeeperType = "azure" GCPKeeperType KeeperType = "gcp" HashiCorpKeeperType KeeperType = "hashicorp" + SystemKeeperType KeeperType = "system" ) func (kt KeeperType) String() string { @@ -20,9 +21,31 @@ func (kt KeeperType) String() string { // KeeperConfig is an interface that all keeper config types must implement. type KeeperConfig interface { + // Returns the name of the keeper + GetName() string Type() KeeperType } +type NamedKeeperConfig[T interface { + Type() KeeperType +}] struct { + Name string + Cfg T +} + +func NewNamedKeeperConfig[T interface { + Type() KeeperType +}](keeperName string, cfg T) *NamedKeeperConfig[T] { + return &NamedKeeperConfig[T]{Name: keeperName, Cfg: cfg} +} + +func (c *NamedKeeperConfig[T]) GetName() string { + return c.Name +} +func (c *NamedKeeperConfig[T]) Type() KeeperType { + return c.Cfg.Type() +} + func (s *KeeperSpec) GetType() KeeperType { if s.Aws != nil { return AWSKeeperType @@ -43,7 +66,7 @@ func (s *KeeperSpec) GetType() KeeperType { type SystemKeeperConfig struct{} func (*SystemKeeperConfig) Type() KeeperType { - return "system" + return SystemKeeperType } func (s *KeeperAWSConfig) Type() KeeperType { diff --git a/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go b/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go index 101e4bb55b3..4d295874224 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go +++ b/apps/secret/pkg/apis/secret/v1beta1/zz_openapi_gen.go @@ -14,6 +14,8 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.Keeper": schema_pkg_apis_secret_v1beta1_Keeper(ref), + "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAccessKey": schema_pkg_apis_secret_v1beta1_KeeperAWSAccessKey(ref), + "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAssumeRole": schema_pkg_apis_secret_v1beta1_KeeperAWSAssumeRole(ref), "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSConfig": schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref), "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAzureConfig": schema_pkg_apis_secret_v1beta1_KeeperAzureConfig(ref), "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperCredentialValue": schema_pkg_apis_secret_v1beta1_KeeperCredentialValue(ref), @@ -79,7 +81,7 @@ func schema_pkg_apis_secret_v1beta1_Keeper(ref common.ReferenceCallback) common. } } -func schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { +func schema_pkg_apis_secret_v1beta1_KeeperAWSAccessKey(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ SchemaProps: spec.SchemaProps{ @@ -97,6 +99,65 @@ func schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref common.ReferenceCallback Ref: ref("github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperCredentialValue"), }, }, + }, + Required: []string{"accessKeyID", "secretAccessKey"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperCredentialValue"}, + } +} + +func schema_pkg_apis_secret_v1beta1_KeeperAWSAssumeRole(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "assumeRoleArn": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "externalID": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"assumeRoleArn", "externalID"}, + }, + }, + } +} + +func schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "region": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "accessKey": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAccessKey"), + }, + }, + "assumeRole": { + SchemaProps: spec.SchemaProps{ + Ref: ref("github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAssumeRole"), + }, + }, "kmsKeyID": { SchemaProps: spec.SchemaProps{ Type: []string{"string"}, @@ -104,11 +165,11 @@ func schema_pkg_apis_secret_v1beta1_KeeperAWSConfig(ref common.ReferenceCallback }, }, }, - Required: []string{"accessKeyID", "secretAccessKey"}, + Required: []string{"region"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperCredentialValue"}, + "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAccessKey", "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1.KeeperAWSAssumeRole"}, } } diff --git a/conf/defaults.ini b/conf/defaults.ini index 363ca39d0c4..080d4e62fe0 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -336,7 +336,7 @@ rudderstack_data_plane_url = rudderstack_sdk_url = # Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 -rudderstack_v3_sdk_url = +rudderstack_v3_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config rudderstack_config_url = @@ -2079,8 +2079,14 @@ enable = # To enable features by default, set `Expression: "true"` in: # https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go +# The feature_toggles section supports feature flags of a number of types, +# including boolean, string, integer, float, and structured values, following the OpenFeature specification. +# # feature1 = true # feature2 = false +# feature3 = "foobar" +# feature4 = 1.5 +# feature5 = { "foo": "bar" } [feature_toggles.openfeature] # This is EXPERIMENTAL. Please, do not use this section @@ -2234,6 +2240,8 @@ encryption_provider = secret_key.v1 # These flags are required in on-prem installations for GitSync to work # +# Whether to register the MT CRUD API +register_api_server = true # Whether to create the MT secrets management database run_secrets_db_migrations = true # Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. @@ -2279,3 +2287,10 @@ allow_image_rendering = true # will check if there has been any changes to the repository not propagated by a webhook. # The minimum value is 10 seconds. min_sync_interval = 10s + +#################################### Unified Storage #################################### +[unified_storage] +# index_path is the path where unified storage can store its index files for search. +# If empty, defaults to "/unified-search/bleve" (see [paths] section). +# Please note that sharing the same index_path between multiple running Grafana instances is not supported. +index_path = diff --git a/conf/sample.ini b/conf/sample.ini index 530b14c87ac..b4bc6027abf 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -323,7 +323,7 @@ ;rudderstack_sdk_url = # Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1 -;rudderstack_v3_sdk_url = +;rudderstack_v3_sdk_url = # Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config ;rudderstack_config_url = @@ -1913,7 +1913,7 @@ default_datasource_uid = # client_queue_max_size is the maximum size in bytes of the client queue # for Live connections. Defaults to 4MB. -;client_queue_max_size = +;client_queue_max_size = #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] @@ -1996,9 +1996,14 @@ default_datasource_uid = ;enable = feature1,feature2 +# The feature_toggles section supports feature flags of a number of types, +# including boolean, string, integer, float, and structured values, following the OpenFeature specification. + ;feature1 = true ;feature2 = false - +;feature3 = "foobar" +;feature4 = 1.5 +;feature5 = { "foo": "bar" } [date_formats] # For information on what formatting patterns that are supported https://momentjs.com/docs/#/displaying/ @@ -2123,6 +2128,8 @@ default_datasource_uid = # These flags are required in on-prem installations for GitSync to work # +# Whether to register the MT CRUD API +;register_api_server = true # Whether to create the MT secrets management database ;run_secrets_db_migrations = true # Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. diff --git a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json index 4333993ea8e..ac11fd803b9 100644 --- a/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json +++ b/devenv/dev-dashboards/panel-piechart/panel_test_piechart.json @@ -248,7 +248,7 @@ "legend": { "values": ["percent"], "displayMode": "table", - "placement": "bottom" + "placement": "right" }, "pieType": "pie", "reduceOptions": { @@ -256,7 +256,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -272,6 +272,15 @@ "timeFrom": null, "timeShift": null, "title": "Percent", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "^Backend-(.*)$", + "renamePattern": "b-$1" + } + } + ], "type": "piechart" }, { @@ -311,7 +320,7 @@ "legend": { "values": ["value"], "displayMode": "table", - "placement": "bottom" + "placement": "right" }, "pieType": "pie", "reduceOptions": { @@ -319,7 +328,7 @@ "fields": "", "values": false }, - "showLegend": false, + "showLegend": true, "strokeWidth": 1, "text": {} }, @@ -335,6 +344,15 @@ "timeFrom": null, "timeShift": null, "title": "Value", + "transformations": [ + { + "id": "renameByRegex", + "options": { + "regex": "(.*)", + "renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood" + } + } + ], "type": "piechart" }, { diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index ef800415547..66ba7b6988c 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -48,6 +48,23 @@ scopes: operator: equals value: kids + # This scope appears in multiple places in the tree. + # The defaultPath determines which path is shown when this scope is selected + # (e.g., from a URL or programmatically), even if another path also links to it. + shared-service: + title: Shared Service + # Path from the root node down to the direct scopeNode. + # Node names are hierarchical (parent-child), so use the full names. + # This points to: gdev-scopes > production > shared-service-prod + defaultPath: + - gdev-scopes + - gdev-scopes-production + - gdev-scopes-production-shared-service-prod + filters: + - key: service + operator: equals + value: shared + tree: gdev-scopes: title: gdev-scopes @@ -68,6 +85,13 @@ tree: nodeType: leaf linkId: app2 linkType: scope + # This node links to 'shared-service' scope. + # The scope's defaultPath points here (production > gdev-scopes). + shared-service-prod: + title: Shared Service + nodeType: leaf + linkId: shared-service + linkType: scope test-cases: title: Test cases nodeType: container @@ -83,6 +107,15 @@ tree: nodeType: leaf linkId: test-case-2 linkType: scope + # This node also links to the same 'shared-service' scope. + # However, the scope's defaultPath points to the production path, + # so selecting this scope will expand the tree to production > shared-service-prod. + shared-service-test: + title: Shared Service (also in Production) + subTitle: defaultPath points to Production + nodeType: leaf + linkId: shared-service + linkType: scope test-case-redirect: title: Test case with redirect nodeType: leaf diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index e3f4de80d21..4487c5b69ef 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -51,8 +51,9 @@ type Config struct { // ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec type ScopeConfig struct { - Title string `yaml:"title"` - Filters []ScopeFilterConfig `yaml:"filters"` + Title string `yaml:"title"` + DefaultPath []string `yaml:"defaultPath,omitempty"` + Filters []ScopeFilterConfig `yaml:"filters"` } // ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter @@ -116,9 +117,20 @@ func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec { for i, f := range cfg.Filters { filters[i] = convertFilter(f) } + + // Prefix defaultPath elements with the gdev prefix + var defaultPath []string + if len(cfg.DefaultPath) > 0 { + defaultPath = make([]string, len(cfg.DefaultPath)) + for i, p := range cfg.DefaultPath { + defaultPath[i] = prefix + "-" + p + } + } + return v0alpha1.ScopeSpec{ - Title: cfg.Title, - Filters: filters, + Title: cfg.Title, + DefaultPath: defaultPath, + Filters: filters, } } diff --git a/docs/sources/administration/plugin-management/plugin-sign.md b/docs/sources/administration/plugin-management/plugin-sign.md index 7850996d0f6..54d65baacde 100644 --- a/docs/sources/administration/plugin-management/plugin-sign.md +++ b/docs/sources/administration/plugin-management/plugin-sign.md @@ -25,7 +25,7 @@ Plugin signature verification, also known as _signing_, is a security measure to Learn more at [plugin policies](https://grafana.com/legal/plugins/). -## How does verifiction work? +## How does verification work? At startup, Grafana verifies the signatures of every plugin in the plugin directory. diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index 15215275b53..29347497881 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -428,12 +428,25 @@ Or using a Kubernetes format, for example `kubernetes-dashboard.json`: You _must_ use the Kubernetes resource format to provision dashboards v2 / dynamic dashboards. -It later polls that path every `updateIntervalSeconds` for updates to the dashboard files and updates its database. - {{< admonition type="note" >}} Grafana installs dashboards at the root level if you don't set the `folder` field. {{< /admonition >}} +#### Detect updates to provisioned dashboards files + +After Grafana provisions your dashboards, it checks the filesystem for changes and updates dashboards as needed. + +The mechanism Grafana uses to do this depends on your `updateIntervalSeconds` value: + +- **More than 10 seconds**: Grafana polls the path at that interval. +- **10 seconds or less**: Grafana watches the filesystem for changes and updates dashboards when it detects them. + +{{< admonition type="note" >}} +When `updateIntervalSeconds` is 10 or less, Grafana relies on filesystem watch events to detect changes. +Depending on your filesystem and how you mount or sync dashboard files (for example, Docker bind mounts or some network filesystems), those events might not reach Grafana. +To work around this, set `updateIntervalSeconds` to more than 10 to force polling, or update your setup so filesystem watch events are propagated. +{{< /admonition >}} + #### Make changes to a provisioned dashboard You can make changes to a provisioned dashboard in the Grafana UI but its not possible to automatically save the changes back to the provisioning source. diff --git a/docs/sources/alerting/alerting-rules/create-recording-rules/_index.md b/docs/sources/alerting/alerting-rules/create-recording-rules/_index.md index a56d3ac5bb2..bcf9663aca9 100644 --- a/docs/sources/alerting/alerting-rules/create-recording-rules/_index.md +++ b/docs/sources/alerting/alerting-rules/create-recording-rules/_index.md @@ -48,6 +48,14 @@ Recording rules can be helpful in various scenarios, such as: The evaluation group of the recording rule determines how often the metric is pre-computed. +## Recommendations + +- **Use frequent evaluation intervals**. Set frequent evaluation intervals for recording rules. Long intervals, such as an hour, can cause the recorded metric to be stale and lead to misaligned alert rule evaluations, especially when combined with a long pending period. +- **Align alert evaluation with recording frequency**. The evaluation interval of an alert rule that depends on a recorded metric should be aligned with the recording rule's interval. If a recording rule runs every 3 minutes, the alert rule should also be evaluated at a similar frequency to ensure it acts on fresh data. +- **Use `_over_time` functions for instant queries**. Since all alert rules are ultimately executed as an instant query, you can use functions like `max_over_time(my_metric[5m])` as an instant query. This allows you to get an aggregated value over a period without using a range query and a reduce expression. + +## Types of recording rules + Similar to alert rules, Grafana supports two types of recording rules: 1. [Grafana-managed recording rules](ref:grafana-managed-recording-rules), which can query any Grafana data source supported by alerting. It's the recommended option. diff --git a/docs/sources/alerting/best-practices/_index.md b/docs/sources/alerting/best-practices/_index.md deleted file mode 100644 index 4e08dd8ef37..00000000000 --- a/docs/sources/alerting/best-practices/_index.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/ -description: This section provides a set of guides for useful alerting practices and recommendations -keywords: - - grafana -labels: - products: - - cloud - - enterprise - - oss -menuTitle: Best practices -title: Grafana Alerting best practices -weight: 170 ---- - -# Grafana Alerting best practices - -This section provides a set of guides and examples of best practices for Grafana Alerting. Here you can learn more about how to handle common alert management problems and you can see examples of more advanced usage of Grafana Alerting. - -{{< section >}} - -Designing and configuring an alert management set up that works takes time. Here are some additional tips on how to create an effective alert management set up: - -{{< shared id="alert-planning-fundamentals" >}} - -**Which are the key metrics for your business that you want to monitor and alert on?** - -- Find events that are important to know about and not so trivial or frequent that recipients ignore them. -- Alerts should only be created for big events that require immediate attention or intervention. -- Consider quality over quantity. - -**How do you want to organize your alerts and notifications?** - -- Be selective about who you set to receive alerts. Consider sending them to the right teams, whoever is on call, and the specific channels. -- Think carefully about priority and severity levels. -- Automate as far as possible provisioning Alerting resources with the API or Terraform. - -**Which information should you include in notifications?** - -- Consider who the alert receivers and responders are. -- Share information that helps responders identify and address potential issues. -- Link alerts to dashboards to guide responders on which data to investigate. - -**How can you reduce alert fatigue?** - -- Avoid noisy, unnecessary alerts by using silences, mute timings, or pausing alert rule evaluation. -- Continually tune your alert rules to review effectiveness. Remove alert rules to avoid duplication or ineffective alerts. -- Continually review your thresholds and evaluation rules. - -**How should you configure recording rules?** - -- Use frequent evaluation intervals. It is recommended to set a frequent evaluation interval for recording rules. Long intervals, such as an hour, can cause the recorded metric to be stale and lead to misaligned alert rule evaluations, especially when combined with a long pending period. -- Understand query types. Grafana Alerting uses both **Instant** and **Range** queries. Instant queries fetch a single data point, while Range queries fetch a series of data points over time. When using a Range query in an alert condition, you must use a Reduce expression to aggregate the series into a single value. -- Align alert evaluation with recording frequency. The evaluation interval of an alert rule that depends on a recorded metric should be aligned with the recording rule's interval. If a recording rule runs every 3 minutes, the alert rule should also be evaluated at a similar frequency to ensure it acts on fresh data. -- Use `_over_time` functions for instant queries. Since all alert rules are ultimately executed as an instant query, you can use functions like `max_over_time(my_metric[1h])` as an instant query. This allows you to get an aggregated value over a period without using a range query and a reduce expression. - -{{< /shared >}} diff --git a/docs/sources/alerting/examples/_index.md b/docs/sources/alerting/examples/_index.md new file mode 100644 index 00000000000..3b46838b78a --- /dev/null +++ b/docs/sources/alerting/examples/_index.md @@ -0,0 +1,22 @@ +--- +canonical: https://grafana.com/docs/grafana/latest/alerting/examples/ +description: This section provides a set of guides for useful alerting practices and recommendations +keywords: + - grafana +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Examples +title: Examples +weight: 180 +--- + +# Examples + +This section provides practical examples that show how to work with different types of alerting data, apply alert design patterns, reuse alert logic, and take advantage of specific Grafana Alerting features. + +This section includes: + +{{< section >}} diff --git a/docs/sources/alerting/best-practices/dynamic-labels.md b/docs/sources/alerting/examples/dynamic-labels.md similarity index 98% rename from docs/sources/alerting/best-practices/dynamic-labels.md rename to docs/sources/alerting/examples/dynamic-labels.md index 7a9586ea6d9..6223e7ebff6 100644 --- a/docs/sources/alerting/best-practices/dynamic-labels.md +++ b/docs/sources/alerting/examples/dynamic-labels.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/dynamic-labels +aliases: + - ../best-practices/dynamic-labels/ # /docs/grafana//alerting/best-practices/dynamic-labels/ +canonical: https://grafana.com/docs/grafana/latest/alerting/examples/dynamic-labels description: This example shows how to define dynamic labels based on query values, along with important behavior to keep in mind when using them. keywords: - grafana @@ -10,7 +12,7 @@ labels: - cloud - enterprise - oss -menuTitle: Examples of dynamic labels +menuTitle: Dynamic labels title: Example of dynamic labels in alert instances weight: 1104 refs: diff --git a/docs/sources/alerting/best-practices/dynamic-thresholds.md b/docs/sources/alerting/examples/dynamic-thresholds.md similarity index 97% rename from docs/sources/alerting/best-practices/dynamic-thresholds.md rename to docs/sources/alerting/examples/dynamic-thresholds.md index e749bc1d943..5ce850842a4 100644 --- a/docs/sources/alerting/best-practices/dynamic-thresholds.md +++ b/docs/sources/alerting/examples/dynamic-thresholds.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/dynamic-thresholds +aliases: + - ../best-practices/dynamic-thresholds/ # /docs/grafana//alerting/best-practices/dynamic-thresholds/ +canonical: https://grafana.com/docs/grafana/latest/alerting/examples/dynamic-thresholds description: This example shows how to use a distinct threshold value per dimension using multi-dimensional alerts and a Math expression. keywords: - grafana @@ -10,7 +12,7 @@ labels: - cloud - enterprise - oss -menuTitle: Examples of dynamic thresholds +menuTitle: Dynamic thresholds title: Example of dynamic thresholds per dimension weight: 1105 refs: diff --git a/docs/sources/alerting/best-practices/high-cardinality-alerts.md b/docs/sources/alerting/examples/high-cardinality-alerts.md similarity index 97% rename from docs/sources/alerting/best-practices/high-cardinality-alerts.md rename to docs/sources/alerting/examples/high-cardinality-alerts.md index 9df60ac6bc6..74d080fbee5 100644 --- a/docs/sources/alerting/best-practices/high-cardinality-alerts.md +++ b/docs/sources/alerting/examples/high-cardinality-alerts.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/high-cardinality-alerts/ +aliases: + - ../best-practices/high-cardinality-alerts/ # /docs/grafana//alerting/best-practices/high-cardinality-alerts/ +canonical: https://grafana.com/docs/grafana/latest/alerting/examples/high-cardinality-alerts/ description: Learn how to detect and alert on high-cardinality metrics that can overload your metrics backend and increase observability costs. keywords: - grafana @@ -8,7 +10,7 @@ labels: - cloud - enterprise - oss -menuTitle: Examples of high-cardinality alerts +menuTitle: High-cardinality alerts title: Examples of high-cardinality alerts weight: 1105 refs: diff --git a/docs/sources/alerting/best-practices/multi-dimensional-alerts.md b/docs/sources/alerting/examples/multi-dimensional-alerts.md similarity index 96% rename from docs/sources/alerting/best-practices/multi-dimensional-alerts.md rename to docs/sources/alerting/examples/multi-dimensional-alerts.md index f612df6aa37..fa6d7dd04c1 100644 --- a/docs/sources/alerting/best-practices/multi-dimensional-alerts.md +++ b/docs/sources/alerting/examples/multi-dimensional-alerts.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/multi-dimensional-alerts/ +aliases: + - ../best-practices/multi-dimensional-alerts/ # /docs/grafana//alerting/best-practices/multi-dimensional-alerts/ +canonical: https://grafana.com/docs/grafana/latest/alerting/examples/multi-dimensional-alerts/ description: This example shows how a single alert rule can generate multiple alert instances using time series data. keywords: - grafana @@ -8,7 +10,7 @@ labels: - cloud - enterprise - oss -menuTitle: Examples of multi-dimensional alerts +menuTitle: Multi-dimensional alerts title: Example of multi-dimensional alerts on time series data weight: 1101 refs: diff --git a/docs/sources/alerting/best-practices/table-data.md b/docs/sources/alerting/examples/table-data.md similarity index 96% rename from docs/sources/alerting/best-practices/table-data.md rename to docs/sources/alerting/examples/table-data.md index a39530ef5a8..342475cb7fa 100644 --- a/docs/sources/alerting/best-practices/table-data.md +++ b/docs/sources/alerting/examples/table-data.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/table-data +aliases: + - ../best-practices/table-data/ # /docs/grafana//alerting/best-practices/table-data/ +canonical: https://grafana.com/docs/grafana/latest/alerting/examples/table-data description: This example shows how to create an alert rule using table data. keywords: - grafana @@ -8,7 +10,7 @@ labels: - cloud - enterprise - oss -menuTitle: Examples of table data +menuTitle: Table data title: Example of alerting on tabular data weight: 1102 refs: diff --git a/docs/sources/alerting/best-practices/trace-based-alerts.md b/docs/sources/alerting/examples/trace-based-alerts.md similarity index 98% rename from docs/sources/alerting/best-practices/trace-based-alerts.md rename to docs/sources/alerting/examples/trace-based-alerts.md index d908ecd7385..974089b4643 100644 --- a/docs/sources/alerting/best-practices/trace-based-alerts.md +++ b/docs/sources/alerting/examples/trace-based-alerts.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/trace-based-alerts/ +aliases: + - ../best-practices/trace-based-alerts/ # /docs/grafana//alerting/best-practices/trace-based-alerts/ +canonical: https://grafana.com/docs/grafana/latest/alerting/examples/trace-based-alerts/ description: This guide provides introductory examples and distinct approaches for setting up trace-based alerts in Grafana. keywords: - grafana @@ -8,7 +10,7 @@ labels: - cloud - enterprise - oss -title: Examples of trace-based alerts +title: Trace-based alerts weight: 1103 refs: testdata-data-source: diff --git a/docs/sources/alerting/best-practices/tutorials.md b/docs/sources/alerting/examples/tutorials.md similarity index 87% rename from docs/sources/alerting/best-practices/tutorials.md rename to docs/sources/alerting/examples/tutorials.md index fbef5f36797..97fa7d6a754 100644 --- a/docs/sources/alerting/best-practices/tutorials.md +++ b/docs/sources/alerting/examples/tutorials.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/tutorials/ +aliases: + - ../best-practices/tutorials/ # /docs/grafana//alerting/best-practices/tutorials/ +canonical: https://grafana.com/docs/grafana/latest/alerting/examples/tutorials/ description: This section provides a set of step-by-step tutorials guides to get started with Grafana Aletings. keywords: - grafana diff --git a/docs/sources/alerting/guides/_index.md b/docs/sources/alerting/guides/_index.md new file mode 100644 index 00000000000..69875878533 --- /dev/null +++ b/docs/sources/alerting/guides/_index.md @@ -0,0 +1,35 @@ +--- +canonical: https://grafana.com/docs/grafana/latest/alerting/guides/ +description: This section provides a set of guides for useful alerting practices and recommendations +keywords: + - grafana +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Guides +title: Guides +weight: 170 +refs: + examples: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/examples/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/examples/ + tutorials: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/examples/tutorials/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/examples/tutorials/ +--- + +# Guides + +Guides in the Grafana Alerting documentation provide best practices and practical recommendations to help you move from a basic alerting setup to real-world use cases. + +These guides cover topics such as: + +{{< section >}} + +For more hands-on examples, refer to [Examples](ref:examples) and [Tutorials](ref:tutorials). diff --git a/docs/sources/alerting/guides/best-practices.md b/docs/sources/alerting/guides/best-practices.md new file mode 100644 index 00000000000..d1010390569 --- /dev/null +++ b/docs/sources/alerting/guides/best-practices.md @@ -0,0 +1,201 @@ +--- +aliases: + - ../best-practices/ # /docs/grafana//alerting/best-practices/ +canonical: https://grafana.com/docs/grafana/latest/alerting/guides/best-practices/ +description: Designing and configuring an effective alerting system takes time. This guide focuses on building alerting systems that scale with real-world operations. +keywords: + - grafana + - alerting + - guide +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Best practices +title: Best practices +weight: 1010 +refs: + recovery-threshold: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rules/queries-conditions/#recovery-threshold + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/fundamentals/alert-rules/queries-conditions/#recovery-threshold + keep-firing-for: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rule-evaluation/#keep-firing-for + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/fundamentals/alert-rule-evaluation/#keep-firing-for + pending-period: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rule-evaluation/#pending-period + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/fundamentals/alert-rule-evaluation/#pending-period + silences: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/configure-notifications/create-silence/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/create-silence/ + timing-options: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/notifications/group-alert-notifications/#timing-options + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/group-alert-notifications/#timing-options + group-alert-notifications: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/notifications/group-alert-notifications/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/group-alert-notifications/ + notification-policies: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/notifications/notification-policies/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/notification-policies/ + annotations: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/alert-rules/annotation-label/#annotations + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/alert-rules/annotation-label/#annotations + multi-dimensional-alerts: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/examples/multi-dimensional-alerts/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/examples/multi-dimensional-alerts/ +--- + +# Alerting best practices + +Designing and configuring an effective alerting system takes time. This guide focuses on building alerting systems that scale with real-world operations. + +The practices described here are intentionally high-level and apply regardless of tooling. Whether you use Prometheus, Grafana Alerting, or another stack, the same constraints apply: complex systems, imperfect signals, and humans on call. + +Alerting is never finished. It evolves with incidents, organizational changes, and the systems it’s meant to protect. + +{{< shared id="alert-planning-fundamentals" >}} + +## Prioritize symptoms, but don’t ignore infrastructure signals + +Alerts should primarily detect user-facing failures, not internal component behavior. Users don't care that a pod restarted; they care when the application is slow or failing. Symptom-based alerts tie directly to user impact. + +Reliability metrics that impact users—latency, errors, availability—are better paging signals than infrastructure events or internal errors. + +That said, infrastructure signals still matter. They can act as early warning indicators and are often useful when alerting maturity is low. A sustained spike in CPU or memory usage might not justify a page, but it can help explain or anticipate symptom-based failures. + +Infrastructure alerts tend to be noisy and are often ignored when treated like paging signals. They are usually better suited for lower-severity channels such as dashboards, alert lists, or non-paging destinations like a dedicated Slack channel, where they can be monitored without interrupting on-call. + +The key is balance as your alerting matures. Use infrastructure alerts to support diagnosis and prevention, not as a replacement for symptom-based alerts. + +## Escalate priority based on confidence + +Alert priority is often tied to user impact and the urgency to respond, but confidence should determine when escalation is necessary. + +In this context, escalation defines how responders are notified as confidence grows. This can include increasing alert priority, widening notification, paging additional responders, or opening an incident once intervention is clearly required. + +Early signals are often ambiguous, and confidence in a non-transient failure is usually low. Paging too early creates noise; paging too late means users are impacted for longer before anyone acts. A small or sudden increase in latency may not justify immediate action, but it can indicate a failure in progress. + +Confidence increases as signals become stronger or begin to correlate. + +Escalation is justified when issues are sustained or reinforced by multiple signals. For example, high latency combined with a rising error rate, or the same event firing over a sustained period. These patterns reduce the chance of transient noise and increase the likelihood of real impact. + +Use confidence in user impact to drive escalation and avoid unnecessary pages. + +## Scope alerts for scalability and actionability + +In distributed systems, avoid creating separate alert rules for every host, service, or endpoint. Instead, define alert rules that scale automatically using [multi-dimensional alert rules](ref:multi-dimensional-alerts). This reduces rule duplication and allows alerting to scale as the system grows. + +Start simple. Default to a single dimension such as `service` or `endpoint` to keep alerts manageable. Add dimensions only when they improve actionability. For example, when missing a dimension like `region` hides failures or doesn't provide enough information to act quickly. + +Additional dimensions like `region` or `instance` can help identify the root cause, but more isn't always better. + +## Design alerts for first responders and clear actions + +Alerts should be designed for the first responder, not the person who created the alert. Anyone on call should be able to understand what's wrong and what to do next without deep knowledge of the system or alert configuration. + +Avoid vague alerts that force responders to spend time figuring out context. Every alert should clearly explain why it exists, what triggered it, and how to investigate. Use [annotations](ref:annotations) to link to relevant dashboards and runbooks, which are essential for faster resolution. + +Alerts should indicate a real problem and be actionable, even if the impact is low. Informational alerts add noise without improving reliability. + +If no action is possible, it shouldn't be an alert—consider using a dashboard instead. Over time, alerts behave like technical debt: easy to create, costly to maintain, and hard to remove. + +Review alerts often and remove those that don’t lead to action. + +## Alerts should have an owner and system scope + +Alerts without ownership are often ignored. Every alert must have an owner: a team responsible for maintaining the alert and responding when it fires. + +Alerts must also define a system scope, such as a service or infrastructure component. Scope provides organizational context and connects alerts with ownership. Defining clear scopes is easier when services are treated as first-class entities, and organizations are built around service ownership. + +> [Service Center in Grafana Cloud](/docs/grafana-cloud/alerting-and-irm/service-center/) can help operate a service-oriented view of your system and align alert scope with ownership. + +After scope, ownership, and alert priority are defined, routing determines where alerts go and how they escalate. **Notification routing is as important as the alerts**. + +Alerts should be delivered to the right team and channel based on priority, ownership, and team workflows. Use [notification policies](ref:notification-policies) to define a routing tree that matches the context of your service or scope: + +- Define a parent policy for default routing within the scope. +- Define nested policies for specific cases or higher-priority issues. + +## Prevent notification overload with alert grouping + +Without alert grouping, responders can receive many notifications for the same underlying problem. + +For example, a database failure can trigger several alerts at the same time like increased latency, higher error rates, and internal errors. Paging separately for each symptom quickly turns into notification spam, even though there is a single root cause. + +[Notification grouping](ref:group-alert-notifications) consolidates related alerts into a single notification. Instead of receiving multiple pages for the same issue, responders get one alert that represents the incident and includes all related firing alerts. + +Grouping should follow operational boundaries such as service or owner, as defined by notification policies. Downstream or cascading failures should be grouped together so they surface as one issue rather than many. + +## Mitigate flapping alerts + +Short-lived failure spikes often trigger alerts that auto-resolve quickly. Alerting on transient failures creates noise and leads responders to ignore them. + +Require issues to persist before alerting. Set a [pending period](ref:pending-period) to define how long a condition must remain true before firing. For example, instead of alerting immediately on high error rate, require it to stay above the threshold for some minutes. + +Also, stabilize alerts by tuning query ranges and aggregations. Using raw data makes alerts sensitive to noise. Instead, evaluate over a time window and aggregate the data to smooth short spikes. + +```promql +# Reacts to transient spikes. Avoid this. +cpu_usage > 90 + +# Smooth fluctuations. +avg_over_time(cpu_usage[5m]) > 90 +``` + +For latency and error-based alerts, percentiles are often more useful than averages: + +```promql +quantile_over_time(0.95, http_duration_seconds[5m]) > 3 +``` + +Finally, avoid rapid resolve-and-fire notifications by using [`keep_firing_for`](ref:keep-firing-for) or [recovery thresholds](ref:recovery-threshold) to keep alerts active briefly during recovery. Both options reduce flapping and unnecessary notifications. + +## Graduate symptom-based alerts into SLOs + +When a symptom-based alert fires frequently, it usually indicates a reliability concern that should be measured and managed more deliberately. This is often a sign that the alert could evolve into an [SLO](/docs/grafana-cloud/alerting-and-irm/slo/). + +Traditional alerts create pressure to react immediately, while error budgets introduce a buffer of time to act, changing how urgency is handled. Alerts can then be defined in terms of error budget burn rate rather than reacting to every minor deviation. + +SLOs also align distinct teams around common reliability goals by providing a shared definition of what "good" looks like. They help consolidate multiple symptom alerts into a single user-facing objective. + +For example, instead of several teams alerting on high latency, a single SLO can be used across teams to capture overall API performance. + +## Integrate alerting into incident post-mortems + +Every incident is an opportunity to improve alerting. After each incident, evaluate whether alerts helped responders act quickly or added unnecessary noise. + +Assess which alerts fired, and how they influenced incident response. Review whether alerts triggered too late, too early, or without enough context, and adjust thresholds, priority, or escalation based on what actually happened. + +Use [silences](ref:silences) during active incidents to reduce repeated notifications, but scope them carefully to avoid silencing unrelated alerts. + +Post-mortems should evaluate alerts with root causes and lessons learned. If responders lacked key information during the incident, enrich alerts with additional context, dashboards, or better guidance. + +## Alerts should be continuously improved + +Alerting is an iterative process. Alerts that aren’t reviewed and refined lose effectiveness as systems and traffic patterns change. + +Schedule regular reviews of existing alerts. Remove alerts that don’t lead to action, and tune alerts or thresholds that fire too often without providing useful signal. Reduce false positives to combat alert fatigue. + +Prioritize clarity and simplicity in alert design. Simpler alerts are easier to understand, maintain, and trust under pressure. Favor fewer high-quality, actionable alerts over a large number of low-value ones. + +Use dashboards and observability tools for investigation, not alerts. + +{{< /shared >}} diff --git a/docs/sources/alerting/best-practices/connectivity-errors.md b/docs/sources/alerting/guides/connectivity-errors.md similarity index 98% rename from docs/sources/alerting/best-practices/connectivity-errors.md rename to docs/sources/alerting/guides/connectivity-errors.md index 42d992b0cb0..18e12f5bc49 100644 --- a/docs/sources/alerting/best-practices/connectivity-errors.md +++ b/docs/sources/alerting/guides/connectivity-errors.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/connectivity-errors/ +aliases: + - ../best-practices/connectivity-errors/ # /docs/grafana//alerting/best-practices/connectivity-errors/ +canonical: https://grafana.com/docs/grafana/latest/alerting/guides/connectivity-errors/ description: Learn how to detect and handle connectivity issues in alerts using Prometheus, Grafana Alerting, or both. keywords: - grafana @@ -14,7 +16,7 @@ labels: - oss menuTitle: Handle connectivity errors title: Handle connectivity errors in alerts -weight: 1010 +weight: 1020 refs: pending-period: - pattern: /docs/grafana/ diff --git a/docs/sources/alerting/best-practices/missing-data.md b/docs/sources/alerting/guides/missing-data.md similarity index 98% rename from docs/sources/alerting/best-practices/missing-data.md rename to docs/sources/alerting/guides/missing-data.md index 06346ce3d05..c2d2500cdfd 100644 --- a/docs/sources/alerting/best-practices/missing-data.md +++ b/docs/sources/alerting/guides/missing-data.md @@ -1,5 +1,7 @@ --- -canonical: https://grafana.com/docs/grafana/latest/alerting/best-practices/missing-data/ +aliases: + - ../best-practices/missing-data/ # /docs/grafana//alerting/best-practices/missing-data/ +canonical: https://grafana.com/docs/grafana/latest/alerting/guides/missing-data/ description: Learn how to detect missing metrics and design alerts that handle gaps in data in Prometheus and Grafana Alerting. keywords: - grafana @@ -14,7 +16,7 @@ labels: - oss menuTitle: Handle missing data title: Handle missing data in Grafana Alerting -weight: 1020 +weight: 1030 refs: connectivity-errors-guide: - pattern: /docs/grafana/ diff --git a/docs/sources/alerting/monitor-status/view-alert-rules.md b/docs/sources/alerting/monitor-status/view-alert-rules.md index b60ede12328..e5410d48765 100644 --- a/docs/sources/alerting/monitor-status/view-alert-rules.md +++ b/docs/sources/alerting/monitor-status/view-alert-rules.md @@ -41,9 +41,13 @@ Select a group to expand it and view the list of alert rules within that group. The list view includes a number of filters to simplify managing large volumes of alerts. +## Filter and save searches + Click the **Filter** button to open the filter popup. You can filter by name, label, folder/namespace, evaluation group, data source, contact point, rule source, rule state, rule type, and the health of the alert rule from the popup menu. Click **Apply** at the bottom of the filter popup to enact the filters as you search. -{{< figure src="/media/docs/alerting/alerting-list-view-filter.png" max-width="750px" alt="Alert rule filter options" >}} +Click the **Saved searches** button to open the list of previously saved searches, or click **+ Save current search** to add your current search to the saved searches list. You can also rename a saved search or set it as a default search. When you set a saved search as the default search, the Alert rules page opens with the search applied. + +{{< figure src="/media/docs/alerting/alerting-saved-searches.png" max-width="750px" alt="Alert rule filter options" >}} ## Change alert rules list view diff --git a/docs/sources/as-code/observability-as-code/schema-v2/_index.md b/docs/sources/as-code/observability-as-code/schema-v2/_index.md index 9be869a8391..65c73a49cbe 100644 --- a/docs/sources/as-code/observability-as-code/schema-v2/_index.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/_index.md @@ -186,7 +186,7 @@ For the JSON and field usage notes, refer to the [links schema documentation](ht ### `tags` -The tags associated with the dashboard: +Tags associated with the dashboard. Each tag can be up to 50 characters long. ` [...string]` diff --git a/docs/sources/datasources/aws-cloudwatch/_index.md b/docs/sources/datasources/aws-cloudwatch/_index.md index bf3a14c590f..9cd5ed87a09 100644 --- a/docs/sources/datasources/aws-cloudwatch/_index.md +++ b/docs/sources/datasources/aws-cloudwatch/_index.md @@ -105,6 +105,11 @@ refs: destination: /docs/grafana//panels-visualizations/visualizations/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/visualizations/ + cloudwatch-troubleshooting: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/troubleshooting/ --- # Amazon CloudWatch data source @@ -119,6 +124,7 @@ The following documents will help you get started working with the CloudWatch da - [CloudWatch query editor](ref:cloudwatch-query-editor) - [Templates and variables](ref:cloudwatch-template-variables) - [Configure AWS authentication](ref:cloudwatch-aws-authentication) +- [Troubleshoot CloudWatch issues](ref:cloudwatch-troubleshooting) ## Import pre-configured dashboards diff --git a/docs/sources/datasources/aws-cloudwatch/troubleshooting/index.md b/docs/sources/datasources/aws-cloudwatch/troubleshooting/index.md new file mode 100644 index 00000000000..77ce4106bf8 --- /dev/null +++ b/docs/sources/datasources/aws-cloudwatch/troubleshooting/index.md @@ -0,0 +1,519 @@ +--- +aliases: + - ../../data-sources/aws-cloudwatch/troubleshooting/ +description: Troubleshooting guide for the Amazon CloudWatch data source in Grafana +keywords: + - grafana + - cloudwatch + - aws + - troubleshooting + - errors + - authentication + - query +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot Amazon CloudWatch data source issues +weight: 500 +refs: + configure-cloudwatch: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/configure/ + cloudwatch-aws-authentication: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/aws-authentication/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/aws-authentication/ + cloudwatch-template-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/template-variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/template-variables/ + cloudwatch-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/aws-cloudwatch/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/aws-cloudwatch/query-editor/ + private-data-source-connect: + - pattern: /docs/grafana/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ +--- + +# Troubleshoot Amazon CloudWatch data source issues + +This document provides solutions to common issues you may encounter when configuring or using the Amazon CloudWatch data source. For configuration instructions, refer to [Configure CloudWatch](ref:configure-cloudwatch). + +{{< admonition type="note" >}} +The data source health check validates both metrics and logs permissions. If your IAM policy only grants access to one of these (for example, metrics-only or logs-only), the health check displays a red status. However, the service you have permissions for is still usable—you can query metrics or logs based on whichever permissions are configured. +{{< /admonition >}} + +## Authentication errors + +These errors occur when AWS credentials are invalid, missing, or don't have the required permissions. + +### "Access Denied" or "Not authorized to perform this operation" + +**Symptoms:** + +- Save & test fails with "Access Denied" +- Queries return authorization errors +- Namespaces, metrics, or dimensions don't load + +**Possible causes and solutions:** + +| Cause | Solution | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| IAM policy missing required permissions | Attach the appropriate IAM policy to your user or role. For metrics, you need `cloudwatch:ListMetrics`, `cloudwatch:GetMetricData`, and related permissions. For logs, you need `logs:DescribeLogGroups`, `logs:StartQuery`, `logs:GetQueryResults`, and related permissions. Refer to [Configure CloudWatch](ref:configure-cloudwatch) for complete policy examples. | +| Incorrect access key or secret key | Verify the credentials in the AWS Console under **IAM** > **Users** > your user > **Security credentials**. Generate new credentials if necessary. | +| Credentials have expired | For temporary credentials, generate new ones. For access keys, verify they haven't been deactivated or deleted. | +| Wrong AWS region | Verify the default region in the data source configuration matches where your resources are located. | +| Assume Role ARN is incorrect | Verify the role ARN format: `arn:aws:iam:::role/`. Check that the role exists in the AWS Console. | + +### "Unable to assume role" + +**Symptoms:** + +- Authentication fails when using Assume Role ARN +- Error message references STS or AssumeRole + +**Solutions:** + +1. Verify the trust relationship on the IAM role allows the Grafana credentials to assume it. +1. Check the trust policy includes the correct principal (the user or role running Grafana). +1. If using an external ID, ensure it matches exactly in both the role's trust policy and the Grafana data source configuration. +1. Verify the base credentials have the `sts:AssumeRole` permission. +1. Check that the role ARN is correct and the role exists. + +**Example trust policy:** + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::user/" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "" + } + } + } + ] +} +``` + +### AWS SDK Default authentication not working + +**Symptoms:** + +- Data source test fails when using AWS SDK Default +- Works locally but fails in production + +**Solutions:** + +1. Verify AWS credentials are configured in the environment where Grafana runs. +1. Check for credentials in the default locations: + - Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) + - Shared credentials file (`~/.aws/credentials`) + - EC2 instance metadata (if running on EC2) + - ECS task role (if running in ECS) + - EKS service account (if running in EKS) +1. Ensure the Grafana process has permission to read the credentials file. +1. For EKS with IRSA, set the pod's security context to allow user 472 (grafana) to access the projected token. Refer to [AWS authentication](ref:cloudwatch-aws-authentication) for details. + +### Credentials file not found + +**Symptoms:** + +- Error indicates credentials file cannot be read +- Authentication fails with "Credentials file" option + +**Solutions:** + +1. Create the credentials file at `~/.aws/credentials` for the user running the `grafana-server` service. +1. Verify the file has correct permissions (`0644`). +1. If the file exists but isn't working, move it to `/usr/share/grafana/` and set permissions to `0644`. +1. Ensure the profile name in the data source configuration matches a profile in the credentials file. + +## Connection errors + +These errors occur when Grafana cannot reach AWS CloudWatch endpoints. + +### "Request timed out" or connection failures + +**Symptoms:** + +- Data source test times out +- Queries fail with timeout errors +- Intermittent connection issues + +**Solutions:** + +1. Verify network connectivity from the Grafana server to AWS endpoints. +1. Check firewall rules allow outbound HTTPS (port 443) to AWS services. +1. If using a VPC, ensure proper NAT gateway or VPC endpoint configuration. +1. For Grafana Cloud connecting to private resources, configure [Private data source connect](ref:private-data-source-connect). +1. Check if the default region is correct—incorrect regions may cause longer timeouts. +1. Increase the timeout settings if queries involve large data volumes. + +### Custom endpoint configuration issues + +**Symptoms:** + +- Connection fails when using a custom endpoint +- Endpoint URL rejected + +**Solutions:** + +1. Verify the endpoint URL format is correct. +1. Ensure the endpoint is accessible from the Grafana server. +1. Check that the endpoint supports the required AWS APIs. +1. For VPC endpoints, verify the endpoint policy allows the required actions. + +## CloudWatch Metrics query errors + +These errors occur when querying CloudWatch Metrics. + +### "No data" or empty results + +**Symptoms:** + +- Query executes without error but returns no data +- Charts show "No data" message + +**Possible causes and solutions:** + +| Cause | Solution | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Time range doesn't contain data | Expand the dashboard time range. CloudWatch metrics have different retention periods based on resolution. | +| Wrong namespace or metric name | Verify the namespace (for example, `AWS/EC2`) and metric name (for example, `CPUUtilization`) are correct. | +| Incorrect dimensions | Ensure dimension names and values match your AWS resources exactly. | +| Match Exact enabled incorrectly | When Match Exact is enabled, all dimensions must be specified. Try disabling it to see if metrics appear. | +| Period too large | Reduce the period setting or set it to "auto" to ensure data points are returned for your time range. | +| Custom metrics not configured | Add custom metric namespaces in the data source configuration under **Namespaces of Custom Metrics**. | + +### "Metric not found" or metrics don't appear in drop-down + +**Symptoms:** + +- Expected metrics don't appear in the query editor +- Metric drop-down is empty for a namespace + +**Solutions:** + +1. Verify the metric exists in the selected region. +1. For custom metrics, add the namespace to **Namespaces of Custom Metrics** in the data source configuration. +1. Check that the IAM policy includes `cloudwatch:ListMetrics` permission. +1. CloudWatch limits `ListMetrics` to 500 results per page. To retrieve more metrics, increase the `list_metrics_page_limit` setting in the [Grafana configuration file](https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/configure/#configure-the-data-source-with-grafanaini). +1. Use the Query Inspector to verify the API request and response. + +### Dimension values not loading + +**Symptoms:** + +- Dimension value drop-down doesn't populate +- Wildcard searches return no results + +**Solutions:** + +1. Verify the IAM policy includes `cloudwatch:ListMetrics` permission. +1. Check that the namespace and metric are selected before dimension values can load. +1. For EC2 dimensions, ensure `ec2:DescribeTags` and `ec2:DescribeInstances` permissions are granted. +1. Dimension values require existing metrics—if no metrics match, no values appear. + +### "Too many data points" or API throttling + +**Symptoms:** + +- Queries fail with throttling errors +- Performance degrades with multiple panels + +**Solutions:** + +1. Increase the period setting to reduce the number of data points. +1. Reduce the time range of your queries. +1. Use fewer dimensions or wildcard queries per panel. +1. Request a quota increase for `GetMetricData` requests per second in the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/). +1. Enable query caching in Grafana to reduce API calls. + +### Metric math expression errors + +**Symptoms:** + +- Expression returns errors +- Referenced metrics not found + +**Solutions:** + +1. Verify each referenced metric has a unique ID set. +1. Check that metric IDs start with a lowercase letter and contain only letters, numbers, and underscores. +1. Ensure all referenced metrics are in the same query. +1. Verify the expression syntax follows [AWS Metric Math](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html) documentation. +1. Metric math expressions can't be used with Grafana alerting if they reference other query rows. + +## CloudWatch Logs query errors + +These errors occur when querying CloudWatch Logs. + +### "Query failed" or logs don't appear + +**Symptoms:** + +- Log queries return errors +- No log data is displayed + +**Solutions:** + +1. Verify log group names are correct and exist in the selected region. +1. Check the IAM policy includes `logs:StartQuery`, `logs:GetQueryResults`, and `logs:DescribeLogGroups` permissions. +1. Ensure the time range contains log data. +1. Verify the query syntax is valid. For CloudWatch Logs Insights QL, test the query in the AWS Console. +1. Select the correct query language (Logs Insights QL, OpenSearch PPL, or OpenSearch SQL) based on your query syntax. + +### Log query timeout + +**Symptoms:** + +- Query runs for a long time then fails +- Error mentions timeout + +**Solutions:** + +1. Increase the **Query timeout result** setting in the data source configuration (default is 30 minutes). +1. Narrow the time range to reduce the amount of data scanned. +1. Add filters to your query to limit results. +1. Break complex queries into smaller, more focused queries. +1. For alerting, the timeout defined in the [Grafana configuration file](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#unified_alerting) takes precedence. + +### Log groups not appearing in selector + +**Symptoms:** + +- Log group selector is empty +- Can't find expected log groups + +**Solutions:** + +1. Verify the IAM policy includes `logs:DescribeLogGroups` permission. +1. Check that log groups exist in the selected region. +1. For cross-account observability, ensure proper IAM permissions for `oam:ListSinks` and `oam:ListAttachedLinks`. +1. Use prefix search to filter log groups if you have many groups. +1. Verify the selected account (for cross-account) contains the expected log groups. + +### OpenSearch SQL query errors + +**Symptoms:** + +- OpenSearch SQL queries fail +- Syntax errors with SQL queries + +**Solutions:** + +1. Specify the log group identifier or ARN in the `FROM` clause: + + ```sql + SELECT * FROM `log_group_name` WHERE `@message` LIKE '%error%' + ``` + +1. For multiple log groups, use the `logGroups` function: + + ```sql + SELECT * FROM `logGroups(logGroupIdentifier: ['LogGroup1', 'LogGroup2'])` + ``` + +1. Amazon CloudWatch supports only a subset of OpenSearch SQL commands. Refer to the [CloudWatch Logs documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/CWL_AnalyzeLogData_Languages.html) for supported syntax. + +## Template variable errors + +These errors occur when using template variables with the CloudWatch data source. + +### Variables return no values + +**Symptoms:** + +- Variable drop-down is empty +- Dashboard fails to load with variable errors + +**Solutions:** + +1. Verify the data source connection is working. +1. Check that the IAM policy includes permissions for the variable query type: + - **Regions:** No additional permissions needed. + - **Namespaces:** No additional permissions needed. + - **Metrics:** Requires `cloudwatch:ListMetrics`. + - **Dimension Values:** Requires `cloudwatch:ListMetrics`. + - **EC2 Instance Attributes:** Requires `ec2:DescribeInstances`. + - **EBS Volume IDs:** Requires `ec2:DescribeVolumes`. + - **Resource ARNs:** Requires `tag:GetResources`. + - **Log Groups:** Requires `logs:DescribeLogGroups`. +1. For dependent variables, ensure parent variables have valid selections. +1. Verify the region is set correctly (use "default" for the data source's default region). + +For more information on template variables, refer to [CloudWatch template variables](ref:cloudwatch-template-variables). + +### Multi-value template variables cause query failures + +**Symptoms:** + +- Queries fail when selecting multiple dimension values +- Error about search expression limits + +**Solutions:** + +1. Search expressions are limited to 1,024 characters. Reduce the number of selected values. +1. Use the asterisk (`*`) wildcard instead of selecting "All" to query all metrics for a dimension. +1. Multi-valued template variables are only supported for dimension values—not for Region, Namespace, or Metric Name. + +## Cross-account observability errors + +These errors occur when using CloudWatch cross-account observability features. + +### Cross-account queries fail + +**Symptoms:** + +- Can't query metrics or logs from linked accounts +- Monitoring account badge doesn't appear + +**Solutions:** + +1. Verify cross-account observability is configured in the AWS CloudWatch console. +1. Add the required IAM permissions: + + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Action": ["oam:ListSinks", "oam:ListAttachedLinks"], + "Effect": "Allow", + "Resource": "*" + } + ] + } + ``` + +1. Check that the monitoring account and source accounts are properly linked in AWS. +1. Cross-account observability works within a single region—verify all accounts are in the same region. +1. EC2 Instance Attributes can't be queried across accounts because they use the EC2 API, not the CloudWatch API. + +## Quota and pricing issues + +These issues relate to AWS service quotas and cost management. + +### API throttling errors + +**Symptoms:** + +- "Rate exceeded" errors +- Dashboard panels intermittently fail to load + +**Solutions:** + +1. Reduce the frequency of dashboard refreshes. +1. Increase the period setting to reduce `GetMetricData` requests. +1. Enable query caching in Grafana (available in Grafana Enterprise and Grafana Cloud). +1. Request a quota increase in the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/). +1. Consider consolidating similar queries using metric math. + +### Unexpectedly high CloudWatch costs + +**Symptoms:** + +- AWS CloudWatch costs are higher than expected +- Frequent API calls from Grafana + +**Solutions:** + +1. The `GetMetricData` API doesn't qualify for the CloudWatch API free tier. +1. Reduce dashboard auto-refresh frequency. +1. Increase the period setting to reduce data points returned. +1. Use query caching to reduce repeated API calls. +1. Review variable query settings—set variable refresh to "On dashboard load" instead of "On time range change." +1. Avoid using wildcards in dimensions when possible, as they generate search expressions with multiple API calls. + +## Other common issues + +These issues don't produce specific error messages but are commonly encountered. + +### Custom metrics don't appear + +**Symptoms:** + +- Custom metrics from applications or agents don't show in the namespace drop-down +- Only standard AWS namespaces are visible + +**Solutions:** + +1. Add your custom metric namespace to the **Namespaces of Custom Metrics** field in the data source configuration. +1. Separate multiple namespaces with commas (for example, `CWAgent,CustomNamespace`). +1. Verify custom metrics have been published to CloudWatch in the selected region. + +### Pre-configured dashboards not working + +**Symptoms:** + +- Imported dashboards show no data +- Dashboard variables don't load + +**Solutions:** + +1. Verify the data source name in the dashboard matches your CloudWatch data source. +1. Check that the dashboard's AWS region setting matches where your resources are located. +1. Ensure the IAM policy grants access to the required services (EC2, Lambda, RDS, etc.). +1. Verify resources exist and are emitting metrics in the selected region. + +### X-Ray trace links not appearing + +**Symptoms:** + +- Log entries don't show X-Ray trace links +- `@xrayTraceId` field not appearing + +**Solutions:** + +1. Verify an X-Ray data source is configured and linked in the CloudWatch data source settings. +1. Ensure your logs contain the `@xrayTraceId` field. +1. Update log queries to include `@xrayTraceId` in the fields, for example: `fields @message, @xrayTraceId`. +1. Configure your application to log X-Ray trace IDs. Refer to the [AWS X-Ray documentation](https://docs.aws.amazon.com/xray/latest/devguide/xray-services.html). + +## Enable debug logging + +To capture detailed error information for troubleshooting: + +1. Set the Grafana log level to `debug` in the configuration file: + + ```ini + [log] + level = debug + ``` + +1. Review logs in `/var/log/grafana/grafana.log` (or your configured log location). +1. Look for CloudWatch-specific entries that include request and response details. +1. Reset the log level to `info` after troubleshooting to avoid excessive log volume. + +## Get additional help + +If you've tried the solutions above and still encounter issues: + +1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues. +1. Review the [CloudWatch plugin GitHub issues](https://github.com/grafana/grafana/issues) for known bugs. +1. Consult the [AWS CloudWatch documentation](https://docs.aws.amazon.com/cloudwatch/) for service-specific guidance. +1. Contact Grafana Support if you're an Enterprise, Cloud Pro, or Cloud Contracted user. +1. When reporting issues, include: + - Grafana version + - AWS region + - Error messages (redact sensitive information) + - Steps to reproduce + - Query configuration (redact credentials and account IDs) diff --git a/docs/sources/datasources/graphite/_index.md b/docs/sources/datasources/graphite/_index.md index fba9d616f38..78972af5781 100644 --- a/docs/sources/datasources/graphite/_index.md +++ b/docs/sources/datasources/graphite/_index.md @@ -111,3 +111,4 @@ After installing and configuring the Graphite data source you can: - Add [transformations](ref:transformations) - Add [annotations](ref:annotate-visualizations) - Set up [alerting](ref:alerting) +- [Troubleshoot](troubleshooting/) common issues with the Graphite data source diff --git a/docs/sources/datasources/graphite/troubleshooting/index.md b/docs/sources/datasources/graphite/troubleshooting/index.md new file mode 100644 index 00000000000..41702c6b975 --- /dev/null +++ b/docs/sources/datasources/graphite/troubleshooting/index.md @@ -0,0 +1,174 @@ +--- +description: Troubleshoot common issues with the Graphite data source. +keywords: + - grafana + - graphite + - troubleshooting + - guide +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot Graphite data source issues +weight: 400 +refs: + configure-graphite: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/graphite/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/graphite/configure/ + query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/graphite/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/graphite/query-editor/ +--- + +# Troubleshoot Graphite data source issues + +This document provides solutions for common issues you might encounter when using the Graphite data source. + +## Connection issues + +Use the following troubleshooting steps to resolve connection problems between Grafana and your Graphite server. + +**Data source test fails with "Unable to connect":** + +If the data source test fails, verify the following: + +- The URL in your data source configuration is correct and accessible from the Grafana server. +- The Graphite server is running and accepting connections. +- Any firewall rules or network policies allow traffic between Grafana and the Graphite server. +- If using TLS, ensure your certificates are valid and properly configured. + +To test connectivity, run the following command from the Grafana server: + +```sh +curl -v /render +``` + +Replace _``_ with your Graphite server URL. A successful connection returns a response from the Graphite server. + +**Authentication errors:** + +If you receive 401 or 403 errors: + +- Verify your Basic Auth username and password are correct. +- Ensure the **With Credentials** toggle is enabled if your Graphite server requires cookies for authentication. +- Check that your TLS client certificates are valid and match what the server expects. + +For detailed authentication configuration, refer to [Configure the Graphite data source](ref:configure-graphite). + +## Query issues + +Use the following troubleshooting steps to resolve problems with Graphite queries. + +**No data returned:** + +If your query returns no data: + +- Verify the metric path exists in your Graphite server by testing directly in the Graphite web interface. +- Check that the time range in Grafana matches when data was collected. +- Ensure wildcards in your query match existing metrics. +- Confirm your query syntax is correct for your Graphite version. + +**HTTP 500 errors with HTML content:** + +Graphite-web versions before 1.6 return HTTP 500 errors with full HTML stack traces when a query fails. If you see error messages containing HTML tags: + +- Check the Graphite server logs for the full error details. +- Verify your query syntax is valid. +- Ensure the requested time range doesn't exceed your Graphite server's capabilities. +- Check that all functions used in your query are supported by your Graphite version. + +**Parser errors in the query editor:** + +If the query editor displays parser errors: + +- Check for unbalanced parentheses in function calls. +- Verify that function arguments are in the correct format. +- Ensure metric paths don't contain unsupported characters. + +For query syntax help, refer to [Graphite query editor](ref:query-editor). + +## Version and feature issues + +Use the following troubleshooting steps to resolve problems related to Graphite versions and features. + +**Functions missing from the query editor:** + +If expected functions don't appear in the query editor: + +- Verify the correct Graphite version is selected in the data source configuration. +- The available functions depend on the configured version. For example, tag-based functions require Graphite 1.1 or later. +- If using a custom Graphite installation with additional functions, ensure the version setting matches your server. + +**Tag-based queries not working:** + +If `seriesByTag()` or other tag functions fail: + +- Confirm your Graphite server is version 1.1 or later. +- Verify the Graphite version setting in your data source configuration matches your actual server version. +- Check that tags are properly configured in your Graphite server. + +## Performance issues + +Use the following troubleshooting steps to address slow queries or timeouts. + +**Queries timing out:** + +If queries consistently time out: + +- Increase the **Timeout** setting in the data source configuration. +- Reduce the time range of your query. +- Use more specific metric paths instead of broad wildcards. +- Consider using `summarize()` or `consolidateBy()` functions to reduce the amount of data returned. +- Check your Graphite server's performance and resource utilization. + +**Slow autocomplete in the query editor:** + +If metric path autocomplete is slow: + +- This often indicates a large number of metrics in your Graphite server. +- Use more specific path prefixes to narrow the search scope. +- Check your Graphite server's index performance. + +## MetricTank-specific issues + +If you're using MetricTank as your Graphite backend, use the following troubleshooting steps. + +**Rollup indicator not appearing:** + +If the rollup indicator doesn't display when expected: + +- Verify **Metrictank** is selected as the Graphite backend type in the data source configuration. +- Ensure the **Rollup indicator** toggle is enabled. +- The indicator only appears when data aggregation actually occurs. + +**Unexpected data aggregation:** + +If you see unexpected aggregation in your data: + +- Check the rollup configuration in your MetricTank instance. +- Adjust the time range or use `consolidateBy()` to control aggregation behavior. +- Review the query processing metadata in the panel inspector for details on how data was processed. + +## Get additional help + +If you continue to experience issues: + +- Check the [Grafana community forums](https://community.grafana.com/) for similar issues and solutions. +- Review the [Graphite documentation](https://graphite.readthedocs.io/) for additional configuration options. +- Contact [Grafana Support](https://grafana.com/support/) if you're an Enterprise, Cloud Pro, or Cloud Advanced customer. + +When reporting issues, include the following information: + +- Grafana version +- Graphite version (for example, 1.1.x) and backend type (Default or MetricTank) +- Authentication method (Basic Auth, TLS, or none) +- Error messages (redact sensitive information) +- Steps to reproduce the issue +- Relevant configuration such as data source settings, timeout values, and Graphite version setting (redact passwords and other credentials) +- Sample query (if applicable, with sensitive data redacted) diff --git a/docs/sources/datasources/mssql/_index.md b/docs/sources/datasources/mssql/_index.md index a5e00da6dcb..b7ea159e972 100644 --- a/docs/sources/datasources/mssql/_index.md +++ b/docs/sources/datasources/mssql/_index.md @@ -99,12 +99,27 @@ refs: destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching - pattern: /docs/grafana-cloud/ destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching + mssql-troubleshoot: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + postgres: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/postgres/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/postgres/ + mysql: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mysql/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mysql/ --- # Microsoft SQL Server (MSSQL) data source Grafana ships with built-in support for Microsoft SQL Server (MSSQL). -You can query and visualize data from any Microsoft SQL Server 2005 or newer, including the Microsoft Azure SQL Database. +You can query and visualize data from any Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database. Use this data source to create dashboards, explore SQL data, and monitor MSSQL-based workloads in real time. @@ -113,10 +128,33 @@ The following documentation helps you get started working with the Microsoft SQL - [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source) - [Microsoft SQL Server query editor](ref:mssql-query-editor) - [Microsoft SQL Server template variables](ref:mssql-template-variables) +- [Troubleshoot Microsoft SQL Server data source issues](ref:mssql-troubleshoot) -## Get the most out of the data source +## Supported versions -After installing and configuring the Microsoft SQL Server data source, you can: +This data source supports the following Microsoft SQL Server versions: + +- Microsoft SQL Server 2005 and newer +- Microsoft Azure SQL Database +- Azure SQL Managed Instance + +Grafana recommends using the latest available service pack for your SQL Server version for optimal compatibility. + +## Key capabilities + +The Microsoft SQL Server data source supports: + +- **Time series queries:** Visualize metrics over time using the built-in time grouping macros. +- **Table queries:** Display query results in table format for any valid SQL query. +- **Template variables:** Create dynamic dashboards with variable-driven queries. +- **Annotations:** Overlay events from SQL Server on your dashboard graphs. +- **Alerting:** Create alerts based on SQL Server query results. +- **Stored procedures:** Execute stored procedures and visualize results. +- **Macros:** Simplify queries with built-in macros for time filtering and grouping. + +## Additional resources + +After configuring the Microsoft SQL Server data source, you can: - Create a wide variety of [visualizations](ref:visualizations) - Configure and use [templates and variables](ref:variables) @@ -124,3 +162,8 @@ After installing and configuring the Microsoft SQL Server data source, you can: - Add [annotations](ref:annotate-visualizations) - Set up [alerting](ref:alerting) - Optimize performance with [query caching](ref:query-caching) + +## Related data sources + +- [PostgreSQL](ref:postgres) - For PostgreSQL databases. +- [MySQL](ref:mysql) - For MySQL and MariaDB databases. diff --git a/docs/sources/datasources/mssql/configure/index.md b/docs/sources/datasources/mssql/configure/index.md index 7ce6398f1cc..f41deeb51dd 100644 --- a/docs/sources/datasources/mssql/configure/index.md +++ b/docs/sources/datasources/mssql/configure/index.md @@ -89,6 +89,26 @@ refs: destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana - pattern: /docs/grafana-cloud/ destination: /docs/grafana//setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana + mssql-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/query-editor/ + mssql-template-variables: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/template-variables/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/template-variables/ + alerting: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/ + mssql-troubleshoot: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/troubleshooting/ --- # Configure the Microsoft SQL Server data source @@ -97,13 +117,28 @@ This document provides instructions for configuring the Microsoft SQL Server dat ## Before you begin -- Grafana comes with a built-in MSSQL data source plugin, eliminating the need to install a plugin. +Before configuring the Microsoft SQL Server data source, ensure you have the following: -- You must have the `Organization administrator` role to configure the MSSQL data source. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system. +- **Grafana permissions:** You must have the `Organization administrator` role to configure data sources. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system. -- Familiarize yourself with your MSSQL security configuration and gather any necessary security certificates and client keys. +- **A running SQL Server instance:** Microsoft SQL Server 2005 or newer, Azure SQL Database, or Azure SQL Managed Instance. -- Verify that data from MSSQL is being written to your Grafana instance. +- **Network access:** Grafana must be able to reach your SQL Server. The default port is `1433`. + +- **Authentication credentials:** Depending on your authentication method, you need one of: + - SQL Server login credentials (username and password). + - Windows/Kerberos credentials and configuration (not supported in Grafana Cloud). + - Azure Entra ID app registration or managed identity. + +- **Security certificates:** If using encrypted connections, gather any necessary TLS/SSL certificates. + +{{< admonition type="note" >}} +Grafana ships with a built-in Microsoft SQL Server data source plugin. No additional installation is required. +{{< /admonition >}} + +{{< admonition type="tip" >}} +**Grafana Cloud users:** If your SQL Server is in a private network, you can configure [Private data source connect](ref:private-data-source-connect) to establish connectivity. +{{< /admonition >}} ## Add the MSSQL data source @@ -382,3 +417,48 @@ datasources: secureJsonData: password: 'Password!' ``` + +### Configure with Terraform + +You can configure the Microsoft SQL Server data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs). + +For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation. + +#### Terraform example + +The following example creates a basic Microsoft SQL Server data source: + +```hcl +resource "grafana_data_source" "mssql" { + name = "MSSQL" + type = "mssql" + url = "localhost:1433" + user = "grafana" + + json_data_encoded = jsonencode({ + database = "grafana" + maxOpenConns = 100 + maxIdleConns = 100 + maxIdleConnsAuto = true + connMaxLifetime = 14400 + connectionTimeout = 0 + encrypt = "false" + }) + + secure_json_data_encoded = jsonencode({ + password = "Password!" + }) +} +``` + +For all available configuration options, refer to the [Grafana provider data source resource documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source). + +## Next steps + +After configuring your Microsoft SQL Server data source, you can: + +- [Write queries](ref:mssql-query-editor) using the query editor to explore and visualize your data +- [Create template variables](ref:mssql-template-variables) to build dynamic, reusable dashboards +- [Add annotations](ref:annotate-visualizations) to overlay SQL Server events on your graphs +- [Set up alerting](ref:alerting) to create alert rules based on your SQL Server data +- [Troubleshoot issues](ref:mssql-troubleshoot) if you encounter problems with your data source diff --git a/docs/sources/datasources/mssql/troubleshooting/index.md b/docs/sources/datasources/mssql/troubleshooting/index.md new file mode 100644 index 00000000000..a62f3eb59e1 --- /dev/null +++ b/docs/sources/datasources/mssql/troubleshooting/index.md @@ -0,0 +1,333 @@ +--- +description: Troubleshoot common problems with the Microsoft SQL Server data source in Grafana +keywords: + - grafana + - MSSQL + - Microsoft + - SQL + - troubleshooting + - errors +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Troubleshooting +title: Troubleshoot Microsoft SQL Server data source issues +weight: 400 +refs: + configure-mssql-data-source: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/configure/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/configure/ + mssql-query-editor: + - pattern: /docs/grafana/ + destination: /docs/grafana//datasources/mssql/query-editor/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//datasources/mssql/query-editor/ + private-data-source-connect: + - pattern: /docs/grafana/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/ +--- + +# Troubleshoot Microsoft SQL Server data source issues + +This document provides solutions to common issues you may encounter when configuring or using the Microsoft SQL Server (MSSQL) data source in Grafana. + +## Connection errors + +These errors occur when Grafana cannot establish or maintain a connection to the Microsoft SQL Server. + +### Unable to connect to the server + +**Error message:** "Unable to open tcp connection" or "dial tcp: connection refused" + +**Cause:** Grafana cannot establish a network connection to the SQL Server. + +**Solution:** + +1. Verify that the SQL Server is running and accessible. +1. Check that the host and port are correct in the data source configuration. The default SQL Server port is `1433`. +1. Ensure there are no firewall rules blocking the connection between Grafana and SQL Server. +1. Verify that SQL Server is configured to allow remote connections. +1. For Grafana Cloud, ensure you have configured [Private data source connect](ref:private-data-source-connect) if your SQL Server instance is not publicly accessible. + +### Connection timeout + +**Error message:** "Connection timed out" or "I/O timeout" + +**Cause:** The connection to SQL Server timed out before receiving a response. + +**Solution:** + +1. Check the network latency between Grafana and SQL Server. +1. Verify that SQL Server is not overloaded or experiencing performance issues. +1. Increase the **Connection timeout** setting in the data source configuration under **Additional settings**. +1. Check if any network devices (load balancers, proxies) are timing out the connection. + +### Encryption-related connection failures + +**Error message:** "TLS handshake failed" or "certificate verify failed" + +**Cause:** There is a mismatch between the encryption settings in Grafana and what the SQL Server supports or requires. + +**Solution:** + +1. For older versions of SQL Server (2008, 2008R2), set the **Encrypt** option to **Disable** or **False** in the data source configuration. +1. Verify that the SQL Server has a valid SSL certificate if encryption is enabled. +1. Check that the certificate is trusted by the Grafana server. +1. Ensure you're using the latest available service pack for your SQL Server version for optimal compatibility. + +### Named instance connection issues + +**Error message:** "Cannot connect to named instance" or connection fails when using instance name + +**Cause:** Grafana cannot resolve the SQL Server named instance. + +**Solution:** + +1. Use the format `hostname\instancename` or `hostname\instancename,port` in the **Host** field. +1. Verify that the SQL Server Browser service is running on the SQL Server machine. +1. If the Browser service is unavailable, specify the port number directly: `hostname,port`. +1. Check that UDP port 1434 is open if using the SQL Server Browser service. + +## Authentication errors + +These errors occur when there are issues with authentication credentials or permissions. + +### Login failed for user + +**Error message:** "Login failed for user 'username'" or "Authentication failed" + +**Cause:** The authentication credentials are invalid or the user doesn't have permission to access the database. + +**Solution:** + +1. Verify that the username and password are correct. +1. Check that the user exists in SQL Server and is enabled. +1. Ensure the user has access to the specified database. +1. For Windows Authentication, verify that the credentials are in the correct format (`DOMAIN\User`). +1. Check that the SQL Server authentication mode allows the type of login you're using (SQL Server Authentication, Windows Authentication, or Mixed Mode). + +### Access denied to database + +**Error message:** "Cannot open database 'dbname' requested by the login" + +**Cause:** The authenticated user doesn't have permission to access the specified database. + +**Solution:** + +1. Verify that the database name is correct in the data source configuration. +1. Ensure the user is mapped to the database with appropriate permissions. +1. Grant at least `SELECT` permission on the required tables: + + ```sql + USE [your_database] + GRANT SELECT ON dbo.YourTable TO [your_user] + ``` + +1. Check that the user doesn't have any conflicting permissions from the public role. + +### Windows Authentication (Kerberos) issues + +**Error message:** "Kerberos authentication failed" or "Cannot initialize Kerberos" + +**Cause:** Kerberos configuration is incorrect or incomplete. + +**Solution:** + +1. Verify that the Kerberos configuration file (`krb5.conf`) path is correct in the data source settings. +1. For keytab authentication, ensure the keytab file exists and is readable by Grafana. +1. Check that the realm and KDC settings are correct in the Kerberos configuration. +1. Verify DNS is correctly resolving the KDC servers. +1. Ensure the service principal name (SPN) is registered for the SQL Server instance. + +{{< admonition type="note" >}} +Kerberos authentication is not supported in Grafana Cloud. +{{< /admonition >}} + +### Azure Entra ID authentication errors + +**Error message:** "AADSTS error codes" or "Azure AD authentication failed" + +**Cause:** Azure Entra ID (formerly Azure AD) authentication is misconfigured. + +**Solution:** + +1. For **App Registration** authentication: + - Verify the tenant ID, client ID, and client secret are correct. + - Ensure the app registration has been added as a user in the Azure SQL database. + - Check that the client secret hasn't expired. + +1. For **Managed Identity** authentication: + - Verify `managed_identity_enabled = true` is set in the Grafana server configuration. + - Ensure the managed identity has been added to the Azure SQL database. + - Confirm the Azure resource hosting Grafana has managed identity enabled. + +1. For **Current User** authentication: + - Ensure `user_identity_enabled = true` is set in the Grafana server configuration. + - Verify the app registration is configured to issue both Access Tokens and ID Tokens. + - Check that the required API permissions are configured (`user_impersonation` for Azure SQL). + +For detailed Azure authentication configuration, refer to [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source). + +## Query errors + +These errors occur when there are issues with query syntax or configuration. + +### Time column not found or invalid + +**Error message:** "Could not find time column" or time series visualization shows no data + +**Cause:** The query doesn't return a properly formatted `time` column for time series visualization. + +**Solution:** + +1. Ensure your query includes a column named `time` when using the **Time series** format. +1. Use the `$__time()` macro to rename your date column: `$__time(your_date_column)`. +1. Verify the time column is of a valid SQL date/time type (`datetime`, `datetime2`, `date`) or contains Unix epoch values. +1. Ensure the result set is sorted by the time column using `ORDER BY`. + +### Macro expansion errors + +**Error message:** "Error parsing query" or macros appear unexpanded in the query + +**Cause:** Grafana macros are being used incorrectly. + +**Solution:** + +1. Verify macro syntax: use `$__timeFilter(column)` not `$_timeFilter(column)`. +1. Macros don't work inside stored procedures—use explicit date parameters instead. +1. Check that the column name passed to macros exists in your table. +1. View the expanded query by clicking **Generated SQL** after running the query to debug macro expansion. + +### Timezone and time shift issues + +**Cause:** Time series data appears shifted or doesn't align with expected times. + +**Solution:** + +1. Store timestamps in UTC in your database to avoid timezone issues. +1. Time macros (`$__time`, `$__timeFilter`, etc.) always expand to UTC values. +1. If your timestamps are stored in local time, convert them to UTC in your query: + + ```sql + SELECT + your_datetime_column AT TIME ZONE 'Your Local Timezone' AT TIME ZONE 'UTC' AS time, + value + FROM your_table + ``` + +1. Don't pass timezone parameters to time macros—they're not supported. + +### Query returns too many rows + +**Error message:** "Result set too large" or browser becomes unresponsive + +**Cause:** The query returns more data than can be efficiently processed. + +**Solution:** + +1. Add time filters using `$__timeFilter(column)` to limit data to the dashboard time range. +1. Use aggregations (`AVG`, `SUM`, `COUNT`) with `GROUP BY` instead of returning raw rows. +1. Add a `TOP` clause to limit results: `SELECT TOP 1000 ...`. +1. Use the `$__timeGroup()` macro to aggregate data into time intervals. + +### Stored procedure returns no data + +**Cause:** Stored procedure output isn't being captured correctly. + +**Solution:** + +1. Ensure the stored procedure uses `SELECT` statements, not just variable assignments. +1. Remove `SET NOCOUNT ON` if present, or ensure it's followed by a `SELECT` statement. +1. Verify the stored procedure parameters are being passed correctly. +1. Test the stored procedure directly in SQL Server Management Studio with the same parameters. + +For more information on using stored procedures, refer to the [query editor documentation](ref:mssql-query-editor). + +## Performance issues + +These issues relate to slow queries or high resource usage. + +### Slow query execution + +**Cause:** Queries take a long time to execute. + +**Solution:** + +1. Reduce the dashboard time range to limit data volume. +1. Add indexes to columns used in `WHERE` clauses and time filters. +1. Use aggregations instead of returning individual rows. +1. Increase the **Min time interval** setting to reduce the number of data points. +1. Review the query execution plan in SQL Server Management Studio to identify bottlenecks. + +### Connection pool exhaustion + +**Error message:** "Too many connections" or "Connection pool exhausted" + +**Cause:** Too many concurrent connections to the database. + +**Solution:** + +1. Increase the **Max open** connection limit in the data source configuration. +1. Enable **Auto max idle** to automatically manage idle connections. +1. Reduce the number of panels querying the same data source simultaneously. +1. Check for long-running queries that might be holding connections. + +## Other common issues + +The following issues don't produce specific error messages but are commonly encountered. + +### System databases appear in queries + +**Cause:** Queries accidentally access system databases. + +**Solution:** + +1. The query editor automatically excludes `tempdb`, `model`, `msdb`, and `master` from the database dropdown. +1. Always specify the database in your data source configuration to restrict access. +1. Ensure the database user only has permissions on the intended database. + +### Template variable queries fail + +**Cause:** Variable queries return unexpected results or errors. + +**Solution:** + +1. Verify the variable query syntax is valid SQL that returns a single column. +1. Check that the data source connection is working. +1. Ensure the user has permission to access the tables referenced in the variable query. +1. Test the query in the query editor before using it as a variable query. + +### Data appears incorrect or misaligned + +**Cause:** Data formatting or type conversion issues. + +**Solution:** + +1. Use explicit column aliases to ensure consistent naming: `SELECT value AS metric`. +1. Verify numeric columns are actually numeric types, not strings. +1. Check for `NULL` values that might affect aggregations. +1. Use the `FILL` option in `$__timeGroup()` macro to handle missing data points. + +## Get additional help + +If you continue to experience issues after following this troubleshooting guide: + +1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues. +1. Review the [Grafana GitHub issues](https://github.com/grafana/grafana/issues) for known bugs. +1. Enable debug logging in Grafana to capture detailed error information. +1. Check SQL Server logs for additional error details. +1. Contact Grafana Support if you're an Enterprise or Cloud customer. + +When reporting issues, include: + +- Grafana version +- SQL Server version +- Error messages (redact sensitive information) +- Steps to reproduce +- Relevant query examples (redact sensitive data) diff --git a/docs/sources/developer-resources/api-reference/http-api/dashboard.md b/docs/sources/developer-resources/api-reference/http-api/dashboard.md index abef6a8f5e6..1b5d32bb9f4 100644 --- a/docs/sources/developer-resources/api-reference/http-api/dashboard.md +++ b/docs/sources/developer-resources/api-reference/http-api/dashboard.md @@ -231,6 +231,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the folder under which the dashboard should be created. - **spec** – The dashboard json. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http @@ -521,6 +525,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/message** - Optional field, to set a commit message for the version history. - **spec** – The dashboard json. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http diff --git a/docs/sources/developer-resources/api-reference/http-api/folder.md b/docs/sources/developer-resources/api-reference/http-api/folder.md index f042a3a5a3e..6a6b18abeba 100644 --- a/docs/sources/developer-resources/api-reference/http-api/folder.md +++ b/docs/sources/developer-resources/api-reference/http-api/folder.md @@ -259,6 +259,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be created. Requires nested folders to be enabled. - **spec.title** – The title of the folder. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http @@ -337,6 +341,10 @@ JSON Body schema: - **metadata.annotations.grafana.app/folder** - Optional field, the unique identifier of the parent folder under which the folder should be - update this to move the folder under a different parent folder. Requires nested folders to be enabled. - **spec.title** – The title of the folder. +{{< admonition type="note" >}} +Custom labels and annotations in the metadata field are supported on some instances, with full support planned for all instances when these APIs reach general availability. If they are not yet supported on your instance, they will be ignored. +{{< /admonition >}} + **Example Response**: ```http diff --git a/docs/sources/developer-resources/api-reference/http-api/library_element.md b/docs/sources/developer-resources/api-reference/http-api/library_element.md index ea241c220e1..ce367c485ef 100644 --- a/docs/sources/developer-resources/api-reference/http-api/library_element.md +++ b/docs/sources/developer-resources/api-reference/http-api/library_element.md @@ -41,7 +41,8 @@ Query parameters: - `sortDirection`: Sort order of elements. Use `alpha-asc` for ascending and `alpha-desc` for descending sort order. - `typeFilter`: A comma separated list of types to filter the elements by. - `excludeUid`: Element UID to exclude from search results. -- `folderFilter`: A comma separated list of folder IDs to filter the elements by. +- `folderFilter`: **Deprecated.** A comma separated list of folder IDs to filter the elements by. Use `folderFilterUIDs` instead. +- `folderFilterUIDs`: A comma separated list of folder UIDs to filter the elements by. - `perPage`: The number of results per page; default is 100. - `page`: The page for a set of records, given that only `perPage` records are returned at a time. Numbering starts at `1`. diff --git a/docs/sources/developer-resources/api-reference/http-api/preferences.md b/docs/sources/developer-resources/api-reference/http-api/preferences.md index 1cd350ee059..079fa1ebce9 100644 --- a/docs/sources/developer-resources/api-reference/http-api/preferences.md +++ b/docs/sources/developer-resources/api-reference/http-api/preferences.md @@ -25,7 +25,7 @@ Keys: - **theme** - One of: `light`, `dark`, or an empty string for the default theme - **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead. - **homeDashboardUID**: The `:uid` of a dashboard -- **timezone** - One of: `utc`, `browser`, or an empty string for the default +- **timezone** - Any valid IANA timezone string (e.g., `America/New_York`, `Europe/London`), `utc`, `browser`, or an empty string for the default. Omitting a key will cause the current value to be replaced with the system default value. diff --git a/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md b/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md index 77df611f904..27c65e7bd02 100644 --- a/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md +++ b/docs/sources/setup-grafana/configure-access/configure-authentication/anonymous-auth/index.md @@ -38,13 +38,6 @@ Users can now view anonymous usage statistics, including the count of devices an The number of anonymous devices is not limited by default. The configuration option `device_limit` allows you to enforce a limit on the number of anonymous devices. This enables you to have greater control over the usage within your Grafana instance and keep the usage within the limits of your environment. Once the limit is reached, any new devices that try to access Grafana will be denied access. -To display anonymous users and devices for versions 10.2, 10.3, 10.4, you need to enable the feature toggle `displayAnonymousStats` - -```bash -[feature_toggles] -enable = displayAnonymousStats -``` - ## Configuration Example: @@ -67,3 +60,15 @@ device_limit = ``` If you change your organization name in the Grafana UI this setting needs to be updated to match the new name. + +## Licensing for anonymous access + +Grafana Enterprise (self-managed) licenses anonymous access as active users. + +Anonymous access lets people use Grafana without login credentials. It was an early way to share dashboards, but Public dashboards gives you a more secure way to share dashboards. + +### How anonymous usage is counted + +Grafana estimates anonymous active users from anonymous devices: + +- **Counting rule**: Grafana counts 1 anonymous user for every 3 anonymous devices detected. diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 67c361b2bdc..d9125991c12 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2836,9 +2836,11 @@ For more information about Grafana Enterprise, refer to [Grafana Enterprise](../ Keys of features to enable, separated by space. -#### `FEATURE_TOGGLE_NAME = false` +#### `FEATURE_NAME = ` -Some feature toggles for stable features are on by default. Use this setting to disable an on-by-default feature toggle with the name FEATURE_TOGGLE_NAME, for example, `exploreMixedDatasource = false`. +Use a key-value pair to set feature flag values explicitly, overriding any default values. A few different types are supported, following the OpenFeature specification. See the defaults.ini file for more details. + +For example, to disable an on-by-default feature toggle named `exploreMixedDatasource`, specify `exploreMixedDatasource = false`.
diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 16c97f6d10a..b7f55555e07 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -66,7 +66,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- | `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes | | `tabularNumbers` | Use fixed-width numbers globally in the UI | | | `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | Yes | -| `tempoSearchBackendMigration` | Run search queries through the tempo backend | | | `opentsdbBackendMigration` | Run queries through the data source backend | | ## Public preview feature toggles diff --git a/docs/sources/shared/datasources/tempo-editor-traceql.md b/docs/sources/shared/datasources/tempo-editor-traceql.md index 54496c339e3..003a449fd96 100644 --- a/docs/sources/shared/datasources/tempo-editor-traceql.md +++ b/docs/sources/shared/datasources/tempo-editor-traceql.md @@ -135,9 +135,12 @@ You can use the **Span Limit** field in **Options** section of the TraceQL query This field sets the maximum number of spans to return for each span set. By default, the maximum value that you can set for the **Span Limit** value (or the spss query) is 100. In Tempo configuration, this value is controlled by the `max_spans_per_span_set` parameter and can be modified by your Tempo administrator. -Grafana Cloud users can contact Grafana Support to request a change. Entering a value higher than the default results in an error. +{{< admonition type="note" >}} +Changing the value of `max_spans_per_span_set` isn't supported in Grafana Cloud. +{{< /admonition >}} + ### Focus on traces or spans Under **Options**, you can choose to display the table as **Traces** or **Spans** focused. diff --git a/docs/sources/tutorials/alerting-get-started-pt6/index.md b/docs/sources/tutorials/alerting-get-started-pt6/index.md index 470330237e2..edf5e61e178 100644 --- a/docs/sources/tutorials/alerting-get-started-pt6/index.md +++ b/docs/sources/tutorials/alerting-get-started-pt6/index.md @@ -23,6 +23,8 @@ killercoda: This tutorial is a continuation of the [Get started with Grafana Alerting - Route alerts using dynamic labels](http://www.grafana.com/tutorials/alerting-get-started-pt5/) tutorial. +{{< youtube id="mqj_hN24zLU" >}} + In this tutorial you will learn how to: diff --git a/docs/sources/visualizations/dashboards/manage-dashboards/index.md b/docs/sources/visualizations/dashboards/manage-dashboards/index.md index f42892d4f39..b65fcecd758 100644 --- a/docs/sources/visualizations/dashboards/manage-dashboards/index.md +++ b/docs/sources/visualizations/dashboards/manage-dashboards/index.md @@ -124,6 +124,8 @@ For more information about dashboard permissions, refer to [Dashboard permission ## Restore deleted dashboards {{% admonition type="caution" %}} +Restoring deleted dashboards is currently in private preview. Grafana Labs offers support on a best-effort basis, and breaking changes might occur prior to the feature being made generally available. + The feature is only available in Grafana Cloud. {{% /admonition %}} diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts index 19ad38f16d5..867337ba088 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-keybindings.spec.ts @@ -51,6 +51,8 @@ test.describe('Dashboard keybindings with new layouts', { tag: ['@dashboards'] } await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible(); + // Press Escape to close tooltip on the close button + await page.keyboard.press('Escape'); // Press Escape to close inspector await page.keyboard.press('Escape'); diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts index 4b5ea9574f8..15a41047fe6 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-edit-variables.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@grafana/plugin-e2e'; -import { flows, type Variable } from './utils'; +import { flows, saveDashboard, type Variable } from './utils'; test.use({ featureToggles: { @@ -64,20 +64,7 @@ test.describe( label: 'VariableUnderTest', }; - // common steps to add a new variable - await flows.newEditPaneVariableClick(dashboardPage, selectors); - await flows.newEditPanelCommonVariableInputs(dashboardPage, selectors, variable); - - // set the textbox variable value - const type = 'variable-type Value'; - const fieldLabel = dashboardPage.getByGrafanaSelector( - selectors.components.PanelEditor.OptionsPane.fieldLabel(type) - ); - await expect(fieldLabel).toBeVisible(); - const inputField = fieldLabel.locator('input'); - await expect(inputField).toBeVisible(); - await inputField.fill(variable.value); - await inputField.blur(); + await flows.addNewTextBoxVariable(dashboardPage, variable); // select the variable in the dashboard and confirm the variable value is set await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItem).click(); @@ -140,5 +127,94 @@ test.describe( await expect(panelContent).toBeVisible(); await expect(markdownContent).toContainText('VariableUnderTest: 10m'); }); + test('can hide a variable', async ({ dashboardPage, selectors, page }) => { + const variable: Variable = { + type: 'textbox', + name: 'VariableUnderTest', + value: 'foo', + label: 'VariableUnderTest', + }; + + await saveDashboard(dashboardPage, page, selectors, 'can hide a variable'); + await flows.addNewTextBoxVariable(dashboardPage, variable); + + // check the variable is visible in the dashboard + const variableLabel = dashboardPage.getByGrafanaSelector( + selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label) + ); + await expect(variableLabel).toBeVisible(); + // hide the variable + await dashboardPage + .getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalDisplaySelect) + .click(); + await page.getByText('Hidden', { exact: true }).click(); + + // check that the variable is still visible + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeVisible(); + + // save dashboard and exit edit mode and check variable is not visible + await saveDashboard(dashboardPage, page, selectors); + await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeHidden(); + // refresh and check that variable isn't visible + await page.reload(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeHidden(); + // check that the variable is visible in edit mode + await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeVisible(); + }); + + test('can hide variable under the controls menu', async ({ dashboardPage, selectors, page }) => { + const variable: Variable = { + type: 'textbox', + name: 'VariableUnderTest', + value: 'foo', + label: 'VariableUnderTest', + }; + await saveDashboard(dashboardPage, page, selectors, 'can hide a variable in controls menu'); + + await flows.addNewTextBoxVariable(dashboardPage, variable); + + // check the variable is visible in the dashboard + const variableLabel = dashboardPage.getByGrafanaSelector( + selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label) + ); + await expect(variableLabel).toBeVisible(); + // hide the variable + await dashboardPage + .getByGrafanaSelector(selectors.pages.Dashboard.Settings.Variables.Edit.General.generalDisplaySelect) + .click(); + await page.getByText('Controls menu', { exact: true }).click(); + + // check that the variable is hidden under the controls menu + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeHidden(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.ControlsButton).click(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeVisible(); + + // save dashboard and refresh + await saveDashboard(dashboardPage, page, selectors); + await page.reload(); + + //check that the variable is hidden under the controls menu + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeHidden(); + await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.ControlsButton).click(); + await expect( + dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels(variable.label!)) + ).toBeVisible(); + }); } ); diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index 69851994812..ade6825b7c1 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -79,6 +79,20 @@ export const flows = { await variableLabelInput.blur(); } }, + async addNewTextBoxVariable(dashboardPage: DashboardPage, variable: Variable) { + await flows.newEditPaneVariableClick(dashboardPage, selectors); + await flows.newEditPanelCommonVariableInputs(dashboardPage, selectors, variable); + // set the textbox variable value + const type = 'variable-type Value'; + const fieldLabel = dashboardPage.getByGrafanaSelector( + selectors.components.PanelEditor.OptionsPane.fieldLabel(type) + ); + await expect(fieldLabel).toBeVisible(); + const inputField = fieldLabel.locator('input'); + await expect(inputField).toBeVisible(); + await inputField.fill(variable.value); + await inputField.blur(); + }, }; export type Variable = { @@ -89,8 +103,16 @@ export type Variable = { value: string; }; -export async function saveDashboard(dashboardPage: DashboardPage, page: Page, selectors: E2ESelectorGroups) { +export async function saveDashboard( + dashboardPage: DashboardPage, + page: Page, + selectors: E2ESelectorGroups, + title?: string +) { await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.saveButton).click(); + if (title) { + await page.getByTestId(selectors.components.Drawer.DashboardSaveDrawer.saveAsTitleInput).fill(title); + } await dashboardPage.getByGrafanaSelector(selectors.components.Drawer.DashboardSaveDrawer.saveButton).click(); await expect(page.getByText('Dashboard saved')).toBeVisible(); } diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index f874cefa27c..dd83b3a0cfd 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -58,6 +58,8 @@ test.describe( await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible(); + // Press Escape to close tooltip on the close button + await page.keyboard.press('Escape'); // Press Escape to close inspector await page.keyboard.press('Escape'); diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 6dddba81820..ac10cb2b735 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -82,9 +82,9 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table'] await expect(getCellHeight(page, 1, longTextColIdx)).resolves.toBeLessThan(100); // click cell inspect, check that cell inspection pops open in the side as we'd expect. - await loremIpsumCell.getByLabel('Inspect value').click(); const loremIpsumText = await loremIpsumCell.textContent(); expect(loremIpsumText).toBeDefined(); + await loremIpsumCell.getByLabel('Inspect value').click(); await expect(page.getByRole('dialog').getByText(loremIpsumText!)).toBeVisible(); }); diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 597f2b94a1a..f633ed5b4eb 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1021,11 +1021,6 @@ "count": 2 } }, - "public/app/core/actions/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 4 - } - }, "public/app/core/components/AccessControl/PermissionList.tsx": { "no-restricted-syntax": { "count": 1 @@ -1161,11 +1156,6 @@ "count": 2 } }, - "public/app/core/config.ts": { - "no-barrel-files/no-barrel-files": { - "count": 2 - } - }, "public/app/core/navigation/types.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -2610,11 +2600,6 @@ "count": 2 } }, - "public/app/features/explore/hooks/useStateSync/index.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/features/explore/spec/helper/setup.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -4030,11 +4015,6 @@ "count": 1 } }, - "public/app/plugins/datasource/parca/webpack.config.ts": { - "no-barrel-files/no-barrel-files": { - "count": 1 - } - }, "public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx": { "no-restricted-syntax": { "count": 1 @@ -4103,7 +4083,7 @@ "count": 1 }, "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 1 } }, "public/app/plugins/datasource/tempo/resultTransformer.ts": { diff --git a/eslint.config.js b/eslint.config.js index bd1be26465a..5e44ffebaf4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -585,6 +585,8 @@ module.exports = [ // FIXME: Remove once all enterprise issues are fixed - // we don't have a suppressions file/approach for enterprise code yet ...enterpriseIgnores, + // Ignore decoupled plugin webpack configs + 'public/app/**/webpack.config.ts', ], rules: { 'no-barrel-files/no-barrel-files': 'error', diff --git a/go.mod b/go.mod index fa38e9ec99d..102310173d3 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( buf.build/gen/go/parca-dev/parca/protocolbuffers/go v1.36.2-20250703125925-3f0fcf4bff96.1 // @grafana/observability-traces-and-profiling cloud.google.com/go/kms v1.22.0 // @grafana/grafana-backend-group cloud.google.com/go/storage v1.55.0 // @grafana/grafana-backend-group - connectrpc.com/connect v1.18.1 // @grafana/observability-traces-and-profiling + connectrpc.com/connect v1.19.1 // @grafana/observability-traces-and-profiling cuelang.org/go v0.11.1 // @grafana/grafana-as-code dario.cat/mergo v1.0.2 // @grafana/grafana-app-platform-squad filippo.io/age v1.2.1 // @grafana/identity-access-team @@ -25,20 +25,22 @@ require ( github.com/Masterminds/semver v1.5.0 // @grafana/grafana-backend-group github.com/Masterminds/semver/v3 v3.4.0 // @grafana/grafana-developer-enablement-squad github.com/Masterminds/sprig/v3 v3.3.0 // @grafana/grafana-backend-group - github.com/ProtonMail/go-crypto v1.1.6 // @grafana/plugins-platform-backend github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/grafana-backend-group github.com/alicebob/miniredis/v2 v2.34.0 // @grafana/alerting-backend github.com/andybalholm/brotli v1.2.0 // @grafana/partner-datasources github.com/apache/arrow-go/v18 v18.4.1 // @grafana/plugins-platform-backend github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources - github.com/aws/aws-sdk-go-v2 v1.39.1 // @grafana/aws-datasources + github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources + github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @grafana/grafana-operator-experience-squad github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources - github.com/aws/smithy-go v1.23.1 // @grafana/aws-datasources + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad + github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // @grafana/grafana-operator-experience-squad + github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend github.com/blang/semver/v4 v4.0.0 // indirect; @grafana/grafana-developer-enablement-squad @@ -99,7 +101,7 @@ require ( github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend github.com/grafana/grafana-app-sdk v0.48.7 // @grafana/grafana-app-platform-squad github.com/grafana/grafana-app-sdk/logging v0.48.7 // @grafana/grafana-app-platform-squad - github.com/grafana/grafana-aws-sdk v1.3.0 // @grafana/aws-datasources + github.com/grafana/grafana-aws-sdk v1.4.2 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // @grafana/partner-datasources github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-google-sdk-go v0.4.2 // @grafana/partner-datasources @@ -110,15 +112,14 @@ require ( github.com/grafana/nanogit v0.3.0 // indirect; @grafana/grafana-git-ui-sync-team github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling - github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling + github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f // @grafana/observability-traces-and-profiling github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // @grafana/grafana-backend-group github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // @grafana/identity-access-team github.com/hashicorp/go-hclog v1.6.3 // @grafana/plugins-platform-backend github.com/hashicorp/go-multierror v1.1.1 // @grafana/alerting-squad - github.com/hashicorp/go-plugin v1.7.0 // @grafana/plugins-platform-backend - github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 // @grafana/plugins-platform-backend + github.com/hashicorp/go-plugin v1.7.0 // indirect; @grafana/plugins-platform-backend github.com/hashicorp/go-version v1.7.0 // @grafana/grafana-backend-group github.com/hashicorp/golang-lru/v2 v2.0.7 // @grafana/alerting-backend github.com/hashicorp/hcl/v2 v2.24.0 // @grafana/alerting-backend @@ -146,6 +147,7 @@ require ( github.com/olekukonko/tablewriter v0.0.5 // @grafana/grafana-backend-group github.com/open-feature/go-sdk v1.16.0 // @grafana/grafana-backend-group github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.6 // @grafana/grafana-backend-group + github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.6 // @grafana/grafana-backend-group github.com/openfga/api/proto v0.0.0-20250909172242-b4b2a12f5c67 // @grafana/identity-access-team github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20251027165255-0f8f255e5f6c // @grafana/identity-access-team github.com/openfga/openfga v1.11.1 // @grafana/identity-access-team @@ -246,7 +248,6 @@ require ( github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/folder v0.0.0 // @grafana/grafana-search-and-storage github.com/grafana/grafana/apps/iam v0.0.0 // @grafana/identity-access-team - github.com/grafana/grafana/apps/investigations v0.0.0 // @fcjack @matryer github.com/grafana/grafana/apps/logsdrilldown v0.0.0 // @grafana/observability-logs github.com/grafana/grafana/apps/playlist v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/plugins v0.0.0 // @grafana/plugins-platform-backend @@ -280,7 +281,6 @@ replace ( github.com/grafana/grafana/apps/dashboard => ./apps/dashboard github.com/grafana/grafana/apps/folder => ./apps/folder github.com/grafana/grafana/apps/iam => ./apps/iam - github.com/grafana/grafana/apps/investigations => ./apps/investigations github.com/grafana/grafana/apps/logsdrilldown => ./apps/logsdrilldown github.com/grafana/grafana/apps/playlist => ./apps/playlist github.com/grafana/grafana/apps/plugins => ./apps/plugins @@ -340,23 +340,21 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect - github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect @@ -393,7 +391,6 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cheekybits/genny v1.0.0 // indirect github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // indirect - github.com/cloudflare/circl v1.6.1 // indirect github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -454,7 +451,6 @@ require ( github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/grafana/jsonparser v0.0.0-20240425183733-ea80629e1a32 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect - github.com/grafana/sqlds/v4 v4.2.7 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/hashicorp/consul/api v1.31.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -491,7 +487,6 @@ require ( github.com/jhump/protoreflect v1.17.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect @@ -543,7 +538,6 @@ require ( github.com/oklog/run v1.1.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect - github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.6 // indirect github.com/open-telemetry/opentelemetry-collector-contrib/internal/coreinternal v0.124.1 // indirect github.com/open-telemetry/opentelemetry-collector-contrib/pkg/core/xidutils v0.124.1 // indirect github.com/open-telemetry/opentelemetry-collector-contrib/pkg/translator/jaeger v0.124.1 // indirect @@ -660,10 +654,8 @@ require ( require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling -require github.com/Machiel/slugify v1.0.1 // @grafana/plugins-platform-backend - require ( - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect @@ -681,7 +673,9 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/google/gnostic v0.7.1 // indirect github.com/gophercloud/gophercloud/v2 v2.9.0 // indirect + github.com/grafana/sqlds/v5 v5.0.3 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/go-archive v0.1.0 // indirect diff --git a/go.sum b/go.sum index 7d2582cf711..0a828108d08 100644 --- a/go.sum +++ b/go.sum @@ -627,8 +627,8 @@ cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoIS cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vfKf5Af+to4M= cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA= cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw= -connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= -connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= contrib.go.opencensus.io/exporter/ocagent v0.6.0/go.mod h1:zmKjrJcdo0aYcVS7bmEeSEBLPA9YJp5bjrofdU3pIXs= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I= cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= @@ -679,8 +679,7 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+ github.com/Azure/azure-storage-blob-go v0.15.0 h1:rXtgp8tN1p29GvpGgfJetavIG0V7OgcSXPpwp3tx6qk= github.com/Azure/azure-storage-blob-go v0.15.0/go.mod h1:vbjsVbX0dlxnRc4FFMPsS9BsJWPcne7GB7onqlPvz58= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-autorest v11.2.8+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= @@ -738,8 +737,6 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= -github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= -github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -762,8 +759,6 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.5 h1:zl/OfRA6nftbBK9qTohYBJ5xvw6C/oNKizR7cZGl3cI= github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -850,24 +845,24 @@ github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2z github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= -github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= -github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= +github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY= -github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= -github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= +github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y= +github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA= +github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 h1:cTXRdLkpBanlDwISl+5chq5ui1d1YWg4PWMR9c3kXyw= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84/go.mod h1:kwSy5X7tfIHN39uucmjQVs2LvDdXEjQucgQQEqCggEo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw= github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 h1:Nn3qce+OHZuMj/edx4its32uxedAmquCDxtZkrdeiD4= @@ -876,12 +871,12 @@ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 h1:e5cbPZYTIY2nUEFie github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0/go.mod h1:UseIHRfrm7PqeZo6fcTb6FUCXzCnh1KJbQbmOfxArGM= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 h1:IfMb3Ar8xEaWjgH/zeVHYD8izwJdQgRP5mKCTDt4GNk= github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2/go.mod h1:35jGWx7ECvCwTsApqicFYzZ7JFEnBc6oHUuOQ3xIS54= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U= github.com/aws/aws-sdk-go-v2/service/kms v1.41.2 h1:zJeUxFP7+XP52u23vrp4zMcVhShTWbNO8dHV6xCSvFo= @@ -892,14 +887,16 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 h1:Pwbxovp github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6/go.mod h1:Z4xLt5mXspLKjBV92i165wAJ/3T6TIv4n7RtIS8pWV0= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 h1:0reDqfEN+tB+sozj2r92Bep8MEwBZgtAXTND1Kk9OXg= github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= -github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M= -github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 h1:w6a0H79HrHf3lr+zrw+pSzR5B+caiQFAKiNHlrUcnoc= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1/go.mod h1:c6Vg0BRiU7v0MVhHupw90RyL120QBwAMLbDCzptGeMk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs= +github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= +github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= +github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f/go.mod h1:2stgcRjl6QmW+gU2h5E7BQXg4HU0gzxKWDuT5HviN9s= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 h1:60m4tnanN1ctzIu4V3bfCNJ39BiOPSm1gHFlFjTkRE0= github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= @@ -1029,8 +1026,6 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -1501,6 +1496,8 @@ github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PU github.com/google/flatbuffers v2.0.8+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic v0.7.1 h1:t5Kc7j/8kYr8t2u11rykRrPPovlEMG4+xdc/SpekATs= +github.com/google/gnostic v0.7.1/go.mod h1:KSw6sxnxEBFM8jLPfJd46xZP+yQcfE8XkiqfZx5zR28= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -1649,8 +1646,8 @@ github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfU github.com/grafana/grafana-app-sdk v0.48.7/go.mod h1:DWsaaH39ZMHwSOSoUBaeW8paMrRaYsjRYlLwCJYd78k= github.com/grafana/grafana-app-sdk/logging v0.48.7 h1:Oa5qg473gka5+W/WQk61Xbw4YdAv+wV2Z4bJtzeCaQw= github.com/grafana/grafana-app-sdk/logging v0.48.7/go.mod h1:5u3KalezoBAAo2Y3ytDYDAIIPvEqFLLDSxeiK99QxDU= -github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= -github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= +github.com/grafana/grafana-aws-sdk v1.4.2 h1:GrUEoLbs46r8rG/GZL4L2b63Bo+rkIYKdtCT7kT5KkM= +github.com/grafana/grafana-aws-sdk v1.4.2/go.mod h1:1qnZdYs6gQzxxF0dDodaE7Rn9fiMzuhwvtaAZ7ySnhY= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 h1:JOzchPgptwJdruYoed7x28lFDwhzs7kssResYsnC0iI= @@ -1683,14 +1680,14 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= -github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae h1:35W3Wjp9KWnSoV/DuymmyIj5aHE0CYlDQ5m2KeXUPAc= -github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae/go.mod h1:6CJ1uXmLZ13ufpO9xE4pST+DyaBt0uszzrV0YnoaVLQ= +github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0= +github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 h1:SDGrP81Vcd102L3UJEryRd1eestRw73wt+b8vnVEFe0= github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56/go.mod h1:S4+611dxnKt8z/ulbvaJzcgSHsuhjVc1QHNTcr1R7Fw= -github.com/grafana/sqlds/v4 v4.2.7 h1:sFQhsS7DBakNMdxa++yOfJ9BVvkZwFJ0B95o57K0/XA= -github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v5 v5.0.3 h1:+yUMUxfa0WANQsmS9xtTFSRX1Q55Iv1B9EjlrW4VlBU= +github.com/grafana/sqlds/v5 v5.0.3/go.mod h1:GKeTTiC+GeR1X0z3f0Iee+hZnNgN62uQpj5XVMx5Uew= github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec h1:wnzJov9RhSHGaTYGzTygL4qq986fLen8xSqnQgaMd28= github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec/go.mod h1:j1IY7J2rUz7TcTjFVVx6HCpyTlYOJPtXuGRZ7sI+vSo= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= @@ -1756,8 +1753,6 @@ github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5O github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= -github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 h1:gCNiM4T5xEc4IpT8vM50CIO+AtElr5kO9l2Rxbq+Sz8= -github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2/go.mod h1:6ZM4ZdwClyAsiU2uDBmRHCvq0If/03BMbF9U+U7G5pA= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= @@ -1882,10 +1877,6 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= -github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= -github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= -github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= diff --git a/go.work b/go.work index 4114bf30976..9d69ea23ed5 100644 --- a/go.work +++ b/go.work @@ -18,7 +18,6 @@ use ( ./apps/example ./apps/folder ./apps/iam - ./apps/investigations ./apps/logsdrilldown ./apps/playlist ./apps/plugins @@ -34,6 +33,7 @@ use ( ./pkg/build ./pkg/build/wire // skip:golangci-lint ./pkg/codegen + ./pkg/plugins ./pkg/plugins/codegen ./pkg/promlib ./pkg/semconv diff --git a/go.work.sum b/go.work.sum index f676971746a..0300e5becbc 100644 --- a/go.work.sum +++ b/go.work.sum @@ -280,6 +280,7 @@ github.com/Azure/go-amqp v0.17.0/go.mod h1:9YJ3RhxRT1gquYnzpZO1vcYMMpAdJT+QEg6fw github.com/Azure/go-amqp v1.4.0 h1:Xj3caqi4comOF/L1Uc5iuBxR/pB6KumejC01YQOqOR4= github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest/autorest v0.11.18/go.mod h1:dSiJPy22c3u0OtOKDNttNgqpNFY/GeWa7GH/Pz56QRA= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= @@ -423,21 +424,31 @@ github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1 h1:nMp7diZObd4XEVUR0pEvn7/E13JI github.com/aws/aws-msk-iam-sasl-signer-go v1.0.1/go.mod h1:MVYeeOhILFFemC/XlYTClvBjYZrg/EPd3ts885KrNTI= github.com/aws/aws-sdk-go-v2 v1.36.5/go.mod h1:EYrzvCCN9CMUTa5+6lf6MM4tq3Zjp8UhSGR/cBsjai0= github.com/aws/aws-sdk-go-v2 v1.38.1/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= github.com/aws/aws-sdk-go-v2/config v1.29.17/go.mod h1:9P4wwACpbeXs9Pm9w1QTh6BwWwJjwYvJ1iCt5QbCXh8= github.com/aws/aws-sdk-go-v2/config v1.31.2/go.mod h1:17ft42Yb2lF6OigqSYiDAiUcX4RIkEMY6XxEMJsrAes= +github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= github.com/aws/aws-sdk-go-v2/credentials v1.17.70/go.mod h1:M+lWhhmomVGgtuPOhO85u4pEa3SmssPTdcYpP/5J/xc= github.com/aws/aws-sdk-go-v2/credentials v1.18.6/go.mod h1:/jdQkh1iVPa01xndfECInp1v1Wnp70v3K4MvtlLGVEc= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5 h1:oUEqVqonG3xuarrsze1KVJ30KagNYDemikTbdu8KlN8= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.5/go.mod h1:VNM08cHlOsIbSHRqb6D/M2L4kKXfJv3A2/f0GNbOQSc= github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87 h1:oDPArGgCrG/4aTi86ij3S2PB59XXkTSKYVNQlmqRHXQ= github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression v1.7.87/go.mod h1:ZeQC4gVarhdcWeM1c90DyBLaBCNhEeAbKUXwVI/byvw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32/go.mod h1:h4Sg6FQdexC1yYG9RDnOvLbW1a/P986++/Y/a+GyEM8= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.4/go.mod h1:9xzb8/SV62W6gHQGC/8rrvgNXU6ZoYM3sAIJCIrXJxY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.69/go.mod h1:GJj8mmO6YT6EqgduWocwhMoxTLFitkhIrK+owzrYL2I= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36/go.mod h1:Q1lnJArKRXkenyog6+Y+zr7WDpk4e6XlR6gs20bbeNo= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.4/go.mod h1:l4bdfCD7XyyZA9BolKBo1eLqgaJxl0/x91PL4Yqe0ao= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36/go.mod h1:UdyGa7Q91id/sdyHPwth+043HhmP6yP9MBHgbZM0xo8= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.4/go.mod h1:yDmJgqOiH4EA8Hndnv4KwAo8jCGTSnM5ASG1nBI+toA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0 h1:A99gjqZDbdhjtjJVZrmVzVKO2+p3MSg35bDWtbMQVxw= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.44.0/go.mod h1:mWB0GE1bqcVSvpW7OtFA0sKuHk52+IqtnsYU2jUfYAs= @@ -445,11 +456,13 @@ github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0 h1:0wOCTKrmwkyC8Bk7 github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.26.0/go.mod h1:He/RikglWUczbkV+fkdpcV/3GdL/rTRNVy7VaUiezMo= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17 h1:x187MqiHwBGjMGAed8Y8K1VGuCtFvQvXb24r+bwmSdo= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.17/go.mod h1:mC9qMbA6e1pwEq6X3zDGtZRXMG2YaElJkbJlMVHLs5I= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17/go.mod h1:ygpklyoaypuyDvOM5ujWGrYWpAK3h7ugnmKCU/76Ys4= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.4/go.mod h1:nLEfLnVMmLvyIG58/6gsSA03F1voKGaCfHV7+lR8S7s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0 h1:JPXkrQk5OS/+Q81fKH97Ll/Vmmy0p9vwHhxw+V+tVjg= github.com/aws/aws-sdk-go-v2/service/kinesis v1.33.0/go.mod h1:dJngkoVMrq0K7QvRkdRZYM4NUp6cdWa2GBdpm8zoY8U= @@ -483,14 +496,18 @@ github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1 h1:OwMzNDe5VVTXD4kGmeK/FtqAITiV github.com/aws/aws-sdk-go-v2/service/ssm v1.60.1/go.mod h1:IyVabkWrs8SNdOEZLyFFcW9bUltV4G6OQS0s6H20PHg= github.com/aws/aws-sdk-go-v2/service/sso v1.25.5/go.mod h1:b7SiVprpU+iGazDUqvRSLf5XmCdn+JtT1on7uNL6Ipc= github.com/aws/aws-sdk-go-v2/service/sso v1.28.2/go.mod h1:n9bTZFZcBa9hGGqVz3i/a6+NG0zmZgtkB9qVVFDqPA8= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3/go.mod h1:vq/GQR1gOFLquZMSrxUK/cpvKCNVYibNyJ1m7JrU88E= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.2/go.mod h1:eknndR9rU8UpE/OmFpqU78V1EcXPKFTTm5l/buZYgvM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= github.com/aws/aws-sdk-go-v2/service/sts v1.34.0/go.mod h1:7ph2tGpfQvwzgistp2+zga9f+bCjlQJPkPUmMgDSD7w= github.com/aws/aws-sdk-go-v2/service/sts v1.38.0/go.mod h1:bEPcjW7IbolPfK67G1nilqWyoxYMSPrDiIQ3RdIdKgo= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/awslabs/aws-lambda-go-api-proxy v0.16.2 h1:CJyGEyO1CIwOnXTU40urf0mchf6t3voxpvUDikOU9LY= github.com/awslabs/aws-lambda-go-api-proxy v0.16.2/go.mod h1:vxxjwBHe/KbgFeNlAP/Tvp4SsVRL3WQamcWRxqVh0z0= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -558,6 +575,7 @@ github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnx github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible h1:C29Ae4G5GtYyYMm1aztcyj/J5ckgJm2zwdDajFbx1NY= github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= github.com/cncf/xds/go v0.0.0-20240723142845-024c85f92f20/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= @@ -739,6 +757,8 @@ github.com/felixge/fgprof v0.9.4/go.mod h1:yKl+ERSa++RYOs32d8K6WEXCB4uXdLls4ZaZP github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flosch/pongo2/v4 v4.0.2 h1:gv+5Pe3vaSVmiJvh/BZa82b7/00YUGm0PIyVVLop0Hw= github.com/flosch/pongo2/v4 v4.0.2/go.mod h1:B5ObFANs/36VwxxlgKpdchIJHMvHB562PW+BWPhwZD8= +github.com/flowstack/go-jsonschema v0.1.1 h1:dCrjGJRXIlbDsLAgTJZTjhwUJnnxVWl1OgNyYh5nyDc= +github.com/flowstack/go-jsonschema v0.1.1/go.mod h1:yL7fNggx1o8rm9RlgXv7hTBWxdBM0rVwpMwimd3F3N0= github.com/fluent/fluent-bit-go v0.0.0-20230731091245-a7a013e2473c h1:yKN46XJHYC/gvgH2UsisJ31+n4K3S7QYZSfU2uAWjuI= github.com/fluent/fluent-bit-go v0.0.0-20230731091245-a7a013e2473c/go.mod h1:L92h+dgwElEyUuShEwjbiHjseW410WIcNz+Bjutc8YQ= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= @@ -930,6 +950,7 @@ github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9 github.com/grafana/grafana-aws-sdk v1.0.2/go.mod h1:hO7q7yWV+t6dmiyJjMa3IbuYnYkBua+G/IAlOPVIYKE= github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= github.com/grafana/grafana-aws-sdk v1.2.0/go.mod h1:bBo7qOmM3f61vO+2JxTolNUph1l2TmtzmWcU9/Im+8A= +github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.1.6/go.mod h1:V7y2BmsWxS3A9Ohebwn4OiSfJJqi//4JQydQ8fHTduo= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= github.com/grafana/grafana-plugin-sdk-go v0.263.0/go.mod h1:U43Cnrj/9DNYyvFcNdeUWNjMXTKNB0jcTcQGpWKd2gw= @@ -977,6 +998,7 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36/go.mod h1:O/QP1BCm0HHIzbKvgMzqb5sSyH88rzkFk84F4TfJjBU= github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grafana/sqlds/v4 v4.2.4/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= +github.com/grafana/sqlds/v4 v4.2.7/go.mod h1:BQRjUG8rOqrBI4NAaeoWrIMuoNgfi8bdhCJ+5cgEfLU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0 h1:bjh0PVYSVVFxzINqPFYJmAmJNrWPgnVjuSdYJGHmtFU= github.com/grafana/tail v0.0.0-20230510142333-77b18831edf0/go.mod h1:7t5XR+2IA8P2qggOAHTj/GCZfoLBle3OvNSYh1VkRBU= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= @@ -1329,6 +1351,7 @@ github.com/open-telemetry/opentelemetry-collector-contrib/receiver/opencensusrec github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.121.0/go.mod h1:3axnebi8xUm9ifbs1myzehw2nODtIMrQlL566sJ4bYw= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.124.1 h1:XkxqUEoukMWXF+EpEWeM9itXKt62yKi13Lzd8ZEASP4= github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.124.1/go.mod h1:CuCZVPz+yn88b5vhZPAlxaMrVuhAVexUV6f8b07lpUc= +github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/runc v1.1.5/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg= github.com/opencontainers/runtime-spec v1.0.3-0.20220825212826-86290f6a00fb/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= @@ -1836,6 +1859,7 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.4 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.62.0/go.mod h1:ru6KHrNtNHxM4nD/vd6QrLVWgKhxPYgblq4VAtNawTQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0/go.mod h1:CosX/aS4eHnG9D7nESYpV753l4j9q5j3SL/PUYd2lR8= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0/go.mod h1:HfvuU0kW9HewH14VCOLImqKvUgONodURG7Alj/IrnGI= go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.62.0/go.mod h1:WfEApdZDMlLUAev/0QQpr8EJ/z0VWDKYZ5tF5RH5T1U= @@ -1887,7 +1911,6 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/exporters/prometheus v0.58.0/go.mod h1:7qo/4CLI+zYSNbv0GMNquzuss2FVZo3OYrGh96n4HNc= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.36.0/go.mod h1:PD57idA/AiFD5aqoxGxCvT/ILJPeHy3MjqU/NS7KogY= @@ -1931,10 +1954,12 @@ gocloud.dev/secrets/hashivault v0.42.0/go.mod h1:LXprr1XLEAT7BVZ+Y66dJEHQMzDsowI golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc= golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.11.1-0.20230711161743-2e82bdd1719d/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= @@ -1942,6 +1967,7 @@ golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632 golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= @@ -1955,6 +1981,7 @@ golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5N golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= +golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e h1:qyrTQ++p1afMkO4DPEeLGq/3oTsdlvdH4vqZUBWzUKM= golang.org/x/exp/typeparams v0.0.0-20220218215828-6cf2b201936e/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= @@ -2040,6 +2067,7 @@ golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= @@ -2048,6 +2076,7 @@ golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= @@ -2063,7 +2092,6 @@ golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= @@ -2213,7 +2241,6 @@ gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzE gopkg.in/vmihailenco/msgpack.v2 v2.9.2 h1:gjPqo9orRVlSAH/065qw3MsFCDpH7fa1KpiizXyllY4= gopkg.in/vmihailenco/msgpack.v2 v2.9.2/go.mod h1:/3Dn1Npt9+MYyLpYYXjInO/5jvMLamn+AEGwNEOatn8= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= -gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.3.2 h1:ytYb4rOqyp1TSa2EPvNVwtPQJctSELKaMyLfqNP4+34= honnef.co/go/tools v0.3.2/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index 08b7a1b2b6e..f17aa67f209 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -218,8 +218,10 @@ lineage: schemas: [{ // Optional field, if you want to extract part of a series name or metric node segment. // Named capture groups can be used to separate the display text and value. regex?: string - // Determine whether regex applies to variable value or display text - regexApplyTo?: #VariableRegexApplyTo + // Optional, indicates whether a custom type variable uses CSV or JSON to define its values + valuesFormat?: "csv" | "json" | *"csv" + // Determine whether regex applies to variable value or display text + regexApplyTo?: #VariableRegexApplyTo // Additional static options for query variable staticOptions?: [...#VariableOption] // Ordering of static options in relation to options returned from data source for query variable diff --git a/package.json b/package.json index d36b4bfc5f8..72a8f638cb6 100644 --- a/package.json +++ b/package.json @@ -295,8 +295,8 @@ "@grafana/plugin-ui": "^0.11.1", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "6.52.0", - "@grafana/scenes-react": "6.52.0", + "@grafana/scenes": "v6.52.1", + "@grafana/scenes-react": "v6.52.1", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -462,7 +462,7 @@ "js-yaml@npm:4.1.0": "^4.1.0", "js-yaml@npm:=4.1.0": "^4.1.0", "nodemailer": "7.0.11", - "@storybook/core@npm:8.6.2": "patch:@storybook/core@npm%3A8.6.2#~/.yarn/patches/@storybook-core-npm-8.6.2-8c752112c0.patch" + "@storybook/core@npm:8.6.15": "patch:@storybook/core@npm%3A8.6.15#~/.yarn/patches/@storybook-core-npm-8.6.15-a468a35170.patch" }, "workspaces": { "packages": [ diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index d64f1c01893..fa0ee6e5168 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -58,14 +58,12 @@ "bundle": "rollup -c rollup.config.ts --configPlugin esbuild", "clean": "rimraf ./dist ./compiled ./unstable ./testing ./package.tgz", "typecheck": "tsc --emitDeclarationOnly false --noEmit", - "codegen": "rtk-query-codegen-openapi ./scripts/codegen.ts", "prepack": "cp package.json package.json.bak && node ../../scripts/prepare-npm-package.js", "postpack": "mv package.json.bak package.json", "i18n-extract": "i18next-cli extract --sync-primary" }, "devDependencies": { "@grafana/test-utils": "workspace:*", - "@rtk-query/codegen-openapi": "^2.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", @@ -96,8 +94,9 @@ "dependencies": { "@emotion/css": "11.13.5", "@faker-js/faker": "^9.8.0", + "@grafana/api-clients": "12.4.0-pre", "@grafana/i18n": "12.4.0-pre", - "@reduxjs/toolkit": "^2.9.0", + "@reduxjs/toolkit": "2.10.1", "fishery": "^2.3.1", "lodash": "^4.17.21", "tinycolor2": "^1.6.0" diff --git a/packages/grafana-alerting/scripts/README.md b/packages/grafana-alerting/scripts/README.md deleted file mode 100644 index 32730a22a13..00000000000 --- a/packages/grafana-alerting/scripts/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Re-generate the clients - -⚠️ This guide assumes the Backend definitions have been updated in `apps/alerting`. - -## Re-create OpenAPI specification - -Start with re-generating the OpenAPI snapshots by running the test in `pkg/tests/apis/openapi_test.go`. - -This will output the OpenAPI JSON spec file(s) in `pkg/tests/apis/openapi_snapshots`. - -## Process OpenAPI specifications - -Next up run the post-processing of the snapshots with `yarn run process-specs`, this will copy processed specifications to `./data/openapi/`. - -## Generate RTKQ files - -These files are built using the `yarn run codegen` command, make sure to run that in the Grafana Alerting package working directory. - -`yarn --cwd ./packages/grafana-alerting run codegen`. - -API clients will be written to `src/grafana/api//api.gen.ts`. - -Make sure to create a versioned API client for each API version – see `src/grafana/api/v0alpha1/api.ts` as an example. diff --git a/packages/grafana-alerting/scripts/codegen.ts b/packages/grafana-alerting/scripts/codegen.ts deleted file mode 100644 index 59c9a7e5cb8..00000000000 --- a/packages/grafana-alerting/scripts/codegen.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * This script will generate TypeScript type definitions and a RTKQ clients for the alerting k8s APIs. - * - * Run `yarn run codegen` from the "grafana-alerting" package to invoke this script. - * - * API clients will be placed in "src/grafana/api//api.gen.ts" - */ -import type { ConfigFile } from '@rtk-query/codegen-openapi'; - -// ℹ️ append API groups and versions here to generate additional API clients -const SPECS = [ - ['notifications.alerting.grafana.app', ['v0alpha1']], - ['rules.alerting.grafana.app', ['v0alpha1']], - // keep this in Grafana Enterprise - // ['alertenrichment.grafana.app', ['v1beta1']], -] as const; - -type OutputFile = Omit; -type OutputFiles = Record; - -const outputFiles = SPECS.reduce((groupAcc, [group, versions]) => { - return versions.reduce((versionAcc, version) => { - // Create a unique export name based on the group - const groupName = group.split('.')[0]; // e.g., 'notifications', 'rules', 'alertenrichment' - const exportName = `${groupName}API`; - - // ℹ️ these snapshots are generated by running "go test pkg/tests/apis/openapi_test.go" and "scripts/process-specs.ts", - // see the README in the "openapi_snapshots" directory - const schemaFile = `../../../data/openapi/${group}-${version}.json`; - - // ℹ️ make sure there is a API file in each versioned directory - const apiFile = `../src/grafana/api/${groupName}/${version}/api.ts`; - - // output each api client into a versioned directory with group-specific naming - const outputPath = `../src/grafana/api/${groupName}/${version}/${groupName}.api.gen.ts`; - - versionAcc[outputPath] = { - exportName, - schemaFile, - apiFile, - tag: true, // generate tags for cache invalidation - } satisfies OutputFile; - - return versionAcc; - }, groupAcc); -}, {}); - -export default { - // these are intentionally empty but will be set for each versioned config file - exportName: '', - schemaFile: '', - apiFile: '', - - outputFiles, -} satisfies ConfigFile; diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/api.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/api.ts deleted file mode 100644 index 5b45157954f..00000000000 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; - -import { getAPIBaseURL, getAPIReducerPath } from '../../util'; - -import { GROUP, VERSION } from './const'; - -const baseUrl = getAPIBaseURL(GROUP, VERSION); -const reducerPath = getAPIReducerPath(GROUP, VERSION); - -export const api = createApi({ - reducerPath, - baseQuery: fetchBaseQuery({ - // Set URL correctly so MSW can intercept requests - // https://mswjs.io/docs/runbook#rtk-query-requests-are-not-intercepted - baseUrl: new URL(baseUrl, globalThis.location.origin).href, - }), - endpoints: () => ({}), -}); diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/const.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/const.ts deleted file mode 100644 index a196bb9516e..00000000000 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/const.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const VERSION = 'v0alpha1' as const; -export const GROUP = 'notifications.alerting.grafana.app' as const; diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes/Receivers.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes/Receivers.ts index fbd88a5972d..693e2a05f30 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes/Receivers.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/fakes/Receivers.ts @@ -1,8 +1,9 @@ import { faker } from '@faker-js/faker'; import { Factory } from 'fishery'; +import { API_GROUP, API_VERSION } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { DEFAULT_NAMESPACE, generateResourceVersion, generateTitle, generateUID } from '../../../../../mocks/util'; -import { GROUP, VERSION } from '../../const'; import { ContactPoint, ContactPointMetadataAnnotations, @@ -14,7 +15,7 @@ import { AlertingEntityMetadataAnnotationsFactory } from './common'; export const ListReceiverApiResponseFactory = Factory.define(() => ({ kind: 'ReceiverList', - apiVersion: `${GROUP}/${VERSION}`, + apiVersion: `${API_GROUP}/${API_VERSION}`, metadata: { resourceVersion: generateResourceVersion(), }, @@ -26,7 +27,7 @@ export const ContactPointFactory = Factory.define(() => { return { kind: 'Receiver', - apiVersion: `${GROUP}/${VERSION}`, + apiVersion: `${API_GROUP}/${API_VERSION}`, metadata: { name: btoa(title), namespace: DEFAULT_NAMESPACE, diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts index 2c21893b6d3..7502d4da000 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + CreateReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { CreateReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function createReceiverHandler( data: CreateReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.post(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + return http.post(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts index d3ff5c660f8..fc859d30043 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + DeleteReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { DeleteReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function deleteReceiverHandler( data: DeleteReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.delete(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + return http.delete(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers/:name'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts index f3871112e66..27f68e67834 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + DeletecollectionReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { DeletecollectionReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function deletecollectionReceiverHandler( data: DeletecollectionReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.delete(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + return http.delete(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts index d90d1fbecba..c7e533d47dc 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + GetReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { GetReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function getReceiverHandler( data: GetReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.get(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + return http.get(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers/:name'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts index fde49565388..b6f577bcca5 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts @@ -1,13 +1,14 @@ import { HttpResponse, http } from 'msw'; +import { API_GROUP, API_VERSION } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { GROUP, VERSION } from '../../../const'; import { EnhancedListReceiverApiResponse } from '../../../types'; export function listReceiverHandler( data: EnhancedListReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.get(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + return http.get(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts index c054de71882..1ed5bb7b50b 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + ReplaceReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { ReplaceReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function replaceReceiverHandler( data: ReplaceReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.put(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + return http.put(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers/:name'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts index ce8498331cd..3b2c7536ec2 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts @@ -1,13 +1,17 @@ import { HttpResponse, http } from 'msw'; +import { + API_GROUP, + API_VERSION, + UpdateReceiverApiResponse, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { getAPIBaseURLForMocks } from '../../../../../../mocks/util'; -import { UpdateReceiverApiResponse } from '../../../../v0alpha1/notifications.api.gen'; -import { GROUP, VERSION } from '../../../const'; export function updateReceiverHandler( data: UpdateReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) ) { - return http.patch(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + return http.patch(getAPIBaseURLForMocks(API_GROUP, API_VERSION, '/receivers/:name'), function handler(info) { if (typeof data === 'function') { return data(info); } diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/types.ts b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/types.ts index 1212fc74b92..b5913c3ab38 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/types.ts +++ b/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/types.ts @@ -3,7 +3,11 @@ */ import { MergeDeep, MergeExclusive, OverrideProperties } from 'type-fest'; -import type { ListReceiverApiResponse, Receiver, ReceiverIntegration } from './notifications.api.gen'; +import type { + ListReceiverApiResponse, + Receiver, + ReceiverIntegration, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; type GenericIntegration = OverrideProperties< ReceiverIntegration, diff --git a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/api.ts b/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/api.ts deleted file mode 100644 index 5b45157954f..00000000000 --- a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/api.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; - -import { getAPIBaseURL, getAPIReducerPath } from '../../util'; - -import { GROUP, VERSION } from './const'; - -const baseUrl = getAPIBaseURL(GROUP, VERSION); -const reducerPath = getAPIReducerPath(GROUP, VERSION); - -export const api = createApi({ - reducerPath, - baseQuery: fetchBaseQuery({ - // Set URL correctly so MSW can intercept requests - // https://mswjs.io/docs/runbook#rtk-query-requests-are-not-intercepted - baseUrl: new URL(baseUrl, globalThis.location.origin).href, - }), - endpoints: () => ({}), -}); diff --git a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/const.ts b/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/const.ts deleted file mode 100644 index 823560db3fb..00000000000 --- a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/const.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const VERSION = 'v0alpha1' as const; -export const GROUP = 'rules.alerting.grafana.app' as const; diff --git a/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx b/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx index 7b6ec11f2b2..60a661ac749 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx +++ b/packages/grafana-alerting/src/grafana/contactPoints/hooks/v0alpha1/useContactPoints.tsx @@ -7,9 +7,10 @@ import { OverrideProperties } from 'type-fest'; import { CreateReceiverApiArg, - type ListReceiverApiArg, - notificationsAPI, -} from '../../../api/notifications/v0alpha1/notifications.api.gen'; + ListReceiverApiArg, + generatedAPI as notificationsAPIv0alpha1, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import type { ContactPoint, EnhancedListReceiverApiResponse } from '../../../api/notifications/v0alpha1/types'; // this is a workaround for the fact that the generated types are not narrow enough @@ -22,17 +23,17 @@ type ListContactPointsHookResult = TypedUseQueryHookResult< // Type for the options that can be passed to the hook // Based on the pattern used for mutation options in this file type ListContactPointsQueryArgs = Parameters< - typeof notificationsAPI.endpoints.listReceiver.useQuery + typeof notificationsAPIv0alpha1.endpoints.listReceiver.useQuery >[0]; type ListContactPointsQueryOptions = Parameters< - typeof notificationsAPI.endpoints.listReceiver.useQuery + typeof notificationsAPIv0alpha1.endpoints.listReceiver.useQuery >[1]; /** * useListContactPoints is a hook that fetches a list of contact points * - * This function wraps the notificationsAPI.useListReceiverQuery with proper typing + * This function wraps the notificationsAPIv0alpha1.useListReceiverQuery with proper typing * to ensure that the returned ContactPoints are correctly typed in the data.items array. * * It automatically uses the configured namespace for the query. @@ -43,8 +44,8 @@ type ListContactPointsQueryOptions = Parameters< export function useListContactPoints( queryArgs: ListContactPointsQueryArgs = {}, queryOptions: ListContactPointsQueryOptions = {} -) { - return notificationsAPI.useListReceiverQuery(queryArgs, queryOptions); +): ListContactPointsHookResult { + return notificationsAPIv0alpha1.useListReceiverQuery(queryArgs, queryOptions); } // type narrowing mutations requires us to define a few helper types @@ -60,7 +61,7 @@ type CreateContactPointMutation = TypedUseMutationResult< >; type UseCreateContactPointOptions = Parameters< - typeof notificationsAPI.endpoints.createReceiver.useMutation + typeof notificationsAPIv0alpha1.endpoints.createReceiver.useMutation >[0]; /** @@ -69,8 +70,16 @@ type UseCreateContactPointOptions = Parameters< * This function wraps the notificationsAPI.useCreateReceiverMutation with proper typing * to ensure that the payload supports type narrowing. */ -export function useCreateContactPoint(options?: UseCreateContactPointOptions) { - const [updateFn, result] = notificationsAPI.endpoints.createReceiver.useMutation(options); +export function useCreateContactPoint( + options?: UseCreateContactPointOptions +): readonly [ + ( + args: CreateContactPointArgs + ) => ReturnType[0]>, + ReturnType>[1], +] { + const [updateFn, result] = + notificationsAPIv0alpha1.endpoints.createReceiver.useMutation(options); const typedUpdateFn = (args: CreateContactPointArgs) => { // @ts-expect-error this one is just impossible for me to figure out diff --git a/packages/grafana-alerting/src/grafana/contactPoints/utils.ts b/packages/grafana-alerting/src/grafana/contactPoints/utils.ts index ac4242bd1b1..e6748c657ff 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/utils.ts +++ b/packages/grafana-alerting/src/grafana/contactPoints/utils.ts @@ -1,6 +1,7 @@ import { countBy, isEmpty } from 'lodash'; -import { Receiver } from '../api/notifications/v0alpha1/notifications.api.gen'; +import { Receiver } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { ContactPoint } from '../api/notifications/v0alpha1/types'; /** diff --git a/packages/grafana-alerting/src/grafana/matchers/types.ts b/packages/grafana-alerting/src/grafana/matchers/types.ts index 3cf3103fd1c..f2d20ace7e9 100644 --- a/packages/grafana-alerting/src/grafana/matchers/types.ts +++ b/packages/grafana-alerting/src/grafana/matchers/types.ts @@ -1,4 +1,4 @@ -import { RoutingTreeMatcher } from '../api/notifications/v0alpha1/notifications.api.gen'; +import { RoutingTreeMatcher } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; export type Label = [string, string]; diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts index dc650db546b..655b4571309 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.test.ts @@ -1,6 +1,6 @@ -import { VERSION } from '../../api/notifications/v0alpha1/const'; +import { API_VERSION, RoutingTree } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { LabelMatcherFactory, RouteFactory } from '../../api/notifications/v0alpha1/mocks/fakes/Routes'; -import { RoutingTree } from '../../api/notifications/v0alpha1/notifications.api.gen'; import { Label } from '../../matchers/types'; import { matchInstancesToRouteTrees } from './useMatchPolicies'; @@ -16,7 +16,7 @@ describe('matchInstancesToRouteTrees', () => { const trees: RoutingTree[] = [ { kind: 'RoutingTree', - apiVersion: VERSION, + apiVersion: API_VERSION, metadata: { name: treeName }, spec: { defaults: { @@ -24,7 +24,6 @@ describe('matchInstancesToRouteTrees', () => { }, routes: [route], }, - status: {}, }, ]; @@ -51,7 +50,7 @@ describe('matchInstancesToRouteTrees', () => { const trees: RoutingTree[] = [ { kind: 'RoutingTree', - apiVersion: VERSION, + apiVersion: API_VERSION, metadata: { name: treeName }, spec: { defaults: { @@ -59,7 +58,6 @@ describe('matchInstancesToRouteTrees', () => { }, routes: [route], }, - status: {}, }, ]; diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts index d23efaae967..305671601f7 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/hooks/useMatchPolicies.ts @@ -1,6 +1,10 @@ import { useCallback } from 'react'; -import { RoutingTree, notificationsAPI } from '../../api/notifications/v0alpha1/notifications.api.gen'; +import { + RoutingTree, + generatedAPI as notificationsAPIv0alpha1, +} from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { Label } from '../../matchers/types'; import { USER_DEFINED_TREE_NAME } from '../consts'; import { Route, RouteWithID } from '../types'; @@ -24,6 +28,11 @@ export type InstanceMatchResult = { matchedRoutes: RouteMatch[]; }; +interface UseMatchInstancesToRouteTreesReturnType + extends ReturnType { + matchInstancesToRouteTrees: (instances: Label[][]) => InstanceMatchResult[]; +} + /** * React hook that finds notification policy routes in all routing trees that match the provided set of alert instances. * @@ -35,8 +44,8 @@ export type InstanceMatchResult = { * @returns An object containing a `matchInstancesToRoutingTrees` function that takes alert instances * and returns an array of InstanceMatchResult objects, each containing the matched routes and matching details */ -export function useMatchInstancesToRouteTrees() { - const { data, ...rest } = notificationsAPI.endpoints.listRoutingTree.useQuery( +export function useMatchInstancesToRouteTrees(): UseMatchInstancesToRouteTreesReturnType { + const { data, ...rest } = notificationsAPIv0alpha1.endpoints.listRoutingTree.useQuery( {}, { refetchOnFocus: true, diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts index 56465daf613..84f0e07d3b7 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/types.ts @@ -1,6 +1,7 @@ import { OverrideProperties } from 'type-fest'; -import { RoutingTreeRoute } from '../api/notifications/v0alpha1/notifications.api.gen'; +import { RoutingTreeRoute } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { LabelMatcher } from '../matchers/types'; // type-narrow the route tree diff --git a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts index 1fc7608a76f..6f7fb7d2ade 100644 --- a/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts +++ b/packages/grafana-alerting/src/grafana/notificationPolicies/utils.ts @@ -1,6 +1,7 @@ import { groupBy, isArray, pick, reduce, uniqueId } from 'lodash'; -import { RoutingTree, RoutingTreeRoute } from '../api/notifications/v0alpha1/notifications.api.gen'; +import { RoutingTree, RoutingTreeRoute } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; + import { Label } from '../matchers/types'; import { LabelMatchDetails, matchLabels } from '../matchers/utils'; diff --git a/packages/grafana-alerting/src/unstable.ts b/packages/grafana-alerting/src/unstable.ts index e033b55437c..15bce11bebf 100644 --- a/packages/grafana-alerting/src/unstable.ts +++ b/packages/grafana-alerting/src/unstable.ts @@ -19,5 +19,5 @@ export { type LabelMatcher, type Label } from './grafana/matchers/types'; export { matchLabelsSet, matchLabels, isLabelMatch, type LabelMatchDetails } from './grafana/matchers/utils'; // API endpoints -export { notificationsAPI as notificationsAPIv0alpha1 } from './grafana/api/notifications/v0alpha1/notifications.api.gen'; -export { rulesAPI as rulesAPIv0alpha1 } from './grafana/api/rules/v0alpha1/rules.api.gen'; +export { generatedAPI as notificationsAPIv0alpha1 } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; +export { generatedAPI as rulesAPIv0alpha1 } from '@grafana/api-clients/rtkq/rules.alerting/v0alpha1'; diff --git a/packages/grafana-alerting/tests/provider.tsx b/packages/grafana-alerting/tests/provider.tsx index b3caa8679b2..7a3ac8bc241 100644 --- a/packages/grafana-alerting/tests/provider.tsx +++ b/packages/grafana-alerting/tests/provider.tsx @@ -2,13 +2,25 @@ import { configureStore } from '@reduxjs/toolkit'; import { useEffect } from 'react'; import { Provider } from 'react-redux'; -import { notificationsAPIv0alpha1 } from '../src/unstable'; +import { MockBackendSrv } from '@grafana/api-clients'; +import { generatedAPI as notificationsAPIv0alpha1 } from '@grafana/api-clients/rtkq/notifications.alerting/v0alpha1'; +import { generatedAPI as rulesAPIv0alpha1 } from '@grafana/api-clients/rtkq/rules.alerting/v0alpha1'; +import { setBackendSrv } from '@grafana/runtime'; + +// Initialize BackendSrv for tests - this allows RTKQ to make HTTP requests +// The actual HTTP requests will be intercepted by MSW (setupMockServer) +// We only need to implement fetch() which is what RTKQ uses +// we could remove this once @grafana/api-client no longer uses the BackendSrv +// @ts-ignore +setBackendSrv(new MockBackendSrv()); // create an empty store -export const store = configureStore({ - middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(notificationsAPIv0alpha1.middleware), +export const store: ReturnType = configureStore({ + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(notificationsAPIv0alpha1.middleware).concat(rulesAPIv0alpha1.middleware), reducer: { [notificationsAPIv0alpha1.reducerPath]: notificationsAPIv0alpha1.reducer, + [rulesAPIv0alpha1.reducerPath]: rulesAPIv0alpha1.reducer, }, }); diff --git a/packages/grafana-alerting/tests/test-utils.tsx b/packages/grafana-alerting/tests/test-utils.tsx index e0f0e12ac9c..5c7de34c9c3 100644 --- a/packages/grafana-alerting/tests/test-utils.tsx +++ b/packages/grafana-alerting/tests/test-utils.tsx @@ -13,7 +13,14 @@ import '@testing-library/jest-dom'; * method which wraps the passed element in all of the necessary Providers, * so it can render correctly in the context of the application */ -const customRender = (ui: React.ReactNode, renderOptions: RenderOptions = {}) => { +const customRender = ( + ui: React.ReactNode, + renderOptions: RenderOptions = {} +): { + renderResult: ReturnType; + user: ReturnType; + store: typeof store; +} => { const user = userEvent.setup(); const Providers = renderOptions.wrapper || getDefaultWrapper(); diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 39accb04535..f72eab55212 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -116,6 +116,18 @@ "import": "./dist/esm/clients/rtkq/shorturl/v1beta1/index.mjs", "require": "./dist/cjs/clients/rtkq/shorturl/v1beta1/index.cjs" }, + "./rtkq/notifications.alerting/v0alpha1": { + "@grafana-app/source": "./src/clients/rtkq/notifications.alerting/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/notifications.alerting/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/notifications.alerting/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/notifications.alerting/v0alpha1/index.cjs" + }, + "./rtkq/rules.alerting/v0alpha1": { + "@grafana-app/source": "./src/clients/rtkq/rules.alerting/v0alpha1/index.ts", + "types": "./dist/types/clients/rtkq/rules.alerting/v0alpha1/index.d.ts", + "import": "./dist/esm/clients/rtkq/rules.alerting/v0alpha1/index.mjs", + "require": "./dist/cjs/clients/rtkq/rules.alerting/v0alpha1/index.cjs" + }, "./rtkq/historian.alerting/v0alpha1": { "@grafana-app/source": "./src/clients/rtkq/historian.alerting/v0alpha1/index.ts", "types": "./dist/types/clients/rtkq/historian.alerting/v0alpha1/index.d.ts", @@ -127,6 +139,12 @@ "types": "./dist/types/clients/rtkq/logsdrilldown/v1alpha1/index.d.ts", "import": "./dist/esm/clients/rtkq/logsdrilldown/v1alpha1/index.mjs", "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1alpha1/index.cjs" + }, + "./rtkq/logsdrilldown/v1beta1": { + "@grafana-app/source": "./src/clients/rtkq/logsdrilldown/v1beta1/index.ts", + "types": "./dist/types/clients/rtkq/logsdrilldown/v1beta1/index.d.ts", + "import": "./dist/esm/clients/rtkq/logsdrilldown/v1beta1/index.mjs", + "require": "./dist/cjs/clients/rtkq/logsdrilldown/v1beta1/index.cjs" } }, "publishConfig": { @@ -158,7 +176,7 @@ }, "peerDependencies": { "@grafana/runtime": ">=11.6 <= 12.x", - "@reduxjs/toolkit": "^2.8.0", + "@reduxjs/toolkit": "^2.10.0", "rxjs": "7.8.2" } } diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 326b53ccedd..9605b8e9355 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -246,6 +246,8 @@ const injectedRtkApi = api facetLimit: queryArg.facetLimit, tags: queryArg.tags, libraryPanel: queryArg.libraryPanel, + panelType: queryArg.panelType, + dataSourceType: queryArg.dataSourceType, permission: queryArg.permission, sort: queryArg.sort, limit: queryArg.limit, @@ -674,6 +676,10 @@ export type SearchDashboardsAndFoldersApiArg = { tags?: string[]; /** find dashboards that reference a given libraryPanel */ libraryPanel?: string; + /** find dashboards using panels of a given plugin type */ + panelType?: string; + /** find dashboards using datasources of a given plugin type */ + dataSourceType?: string; /** permission needed for the resource (view, edit, admin) */ permission?: 'view' | 'edit' | 'admin'; /** sortable field */ diff --git a/packages/grafana-api-clients/src/clients/rtkq/index.ts b/packages/grafana-api-clients/src/clients/rtkq/index.ts index 17a471862f8..708a7b04d5b 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/index.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/index.ts @@ -9,11 +9,14 @@ import { generatedAPI as folderAPIv1beta1 } from './folder/v1beta1'; import { generatedAPI as historianAlertingAPIv0alpha1 } from './historian.alerting/v0alpha1'; import { generatedAPI as iamAPIv0alpha1 } from './iam/v0alpha1'; import { generatedAPI as logsdrilldownAPIv1alpha1 } from './logsdrilldown/v1alpha1'; +import { generatedAPI as logsdrilldownAPIv1beta1 } from './logsdrilldown/v1beta1'; import { generatedAPI as migrateToCloudAPI } from './migrate-to-cloud'; +import { generatedAPI as notificationsAlertingAPIv0alpha1 } from './notifications.alerting/v0alpha1'; import { generatedAPI as playlistAPIv0alpha1 } from './playlist/v0alpha1'; import { generatedAPI as preferencesUserAPI } from './preferences/user'; import { generatedAPI as preferencesAPIv1alpha1 } from './preferences/v1alpha1'; import { generatedAPI as provisioningAPIv0alpha1 } from './provisioning/v0alpha1'; +import { generatedAPI as rulesAlertingAPIv0alpha1 } from './rules.alerting/v0alpha1'; import { generatedAPI as shortURLAPIv1beta1 } from './shorturl/v1beta1'; import { generatedAPI as legacyUserAPI } from './user'; // PLOP_INJECT_IMPORT @@ -33,7 +36,10 @@ export const allMiddleware = [ shortURLAPIv1beta1.middleware, correlationsAPIv0alpha1.middleware, legacyUserAPI.middleware, + notificationsAlertingAPIv0alpha1.middleware, + rulesAlertingAPIv0alpha1.middleware, historianAlertingAPIv0alpha1.middleware, + logsdrilldownAPIv1beta1.middleware, logsdrilldownAPIv1alpha1.middleware, // PLOP_INJECT_MIDDLEWARE ] as const; @@ -53,8 +59,11 @@ export const allReducers = { [shortURLAPIv1beta1.reducerPath]: shortURLAPIv1beta1.reducer, [correlationsAPIv0alpha1.reducerPath]: correlationsAPIv0alpha1.reducer, [legacyUserAPI.reducerPath]: legacyUserAPI.reducer, + [notificationsAlertingAPIv0alpha1.reducerPath]: notificationsAlertingAPIv0alpha1.reducer, + [rulesAlertingAPIv0alpha1.reducerPath]: rulesAlertingAPIv0alpha1.reducer, [historianAlertingAPIv0alpha1.reducerPath]: historianAlertingAPIv0alpha1.reducer, [logsdrilldownAPIv1alpha1.reducerPath]: logsdrilldownAPIv1alpha1.reducer, + [logsdrilldownAPIv1beta1.reducerPath]: logsdrilldownAPIv1beta1.reducer, // PLOP_INJECT_REDUCER }; diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index 4f17bb46ecf..4ceed793cad 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -1021,6 +1021,7 @@ const injectedRtkApi = api typeFilter: queryArg.typeFilter, excludeUid: queryArg.excludeUid, folderFilter: queryArg.folderFilter, + folderFilterUIDs: queryArg.folderFilterUiDs, perPage: queryArg.perPage, page: queryArg.page, }, @@ -2915,8 +2916,11 @@ export type GetLibraryElementsApiArg = { typeFilter?: string; /** Element UID to exclude from search results. */ excludeUid?: string; - /** A comma separated list of folder ID(s) to filter the elements by. */ + /** A comma separated list of folder ID(s) to filter the elements by. + Deprecated: Use FolderFilterUIDs instead. */ folderFilter?: string; + /** A comma separated list of folder UID(s) to filter the elements by. */ + folderFilterUiDs?: string; /** The number of results per page. */ perPage?: number; /** The page for a set of records, given that only perPage records are returned at a time. Numbering starts at 1. */ @@ -5312,7 +5316,8 @@ export type PatchPrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type UpdatePrefsCmd = { @@ -5325,7 +5330,8 @@ export type UpdatePrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark' | 'system'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type OrgUserDto = { @@ -5334,6 +5340,7 @@ export type OrgUserDto = { }; authLabels?: string[]; avatarUrl?: string; + created?: string; email?: string; isDisabled?: boolean; isExternallySynced?: boolean; @@ -5554,6 +5561,7 @@ export type ReportDashboard = { }; export type Type = string; export type ReportOptions = { + csvEncoding?: string; layout?: string; orientation?: string; pdfCombineOneFile?: boolean; @@ -6111,6 +6119,7 @@ export type ChangeUserPasswordCommand = { export type UserSearchHitDto = { authLabels?: string[]; avatarUrl?: string; + created?: string; email?: string; id?: number; isAdmin?: boolean; diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts new file mode 100644 index 00000000000..484d5084e89 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'logsdrilldown.grafana.app' as const; +export const API_VERSION = 'v1beta1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'logsdrilldownAPIv1beta1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts new file mode 100644 index 00000000000..65395d2279d --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/endpoints.gen.ts @@ -0,0 +1,652 @@ +import { api } from './baseAPI'; +export const addTagTypes = ['API Discovery', 'LogsDrilldownDefaultColumns'] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getApiResources: build.query({ + query: () => ({ url: `/` }), + providesTags: ['API Discovery'], + }), + listLogsDrilldownDefaultColumns: build.query< + ListLogsDrilldownDefaultColumnsApiResponse, + ListLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + createLogsDrilldownDefaultColumns: build.mutation< + CreateLogsDrilldownDefaultColumnsApiResponse, + CreateLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + method: 'POST', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + deletecollectionLogsDrilldownDefaultColumns: build.mutation< + DeletecollectionLogsDrilldownDefaultColumnsApiResponse, + DeletecollectionLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + getLogsDrilldownDefaultColumns: build.query< + GetLogsDrilldownDefaultColumnsApiResponse, + GetLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + replaceLogsDrilldownDefaultColumns: build.mutation< + ReplaceLogsDrilldownDefaultColumnsApiResponse, + ReplaceLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'PUT', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + deleteLogsDrilldownDefaultColumns: build.mutation< + DeleteLogsDrilldownDefaultColumnsApiResponse, + DeleteLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + updateLogsDrilldownDefaultColumns: build.mutation< + UpdateLogsDrilldownDefaultColumnsApiResponse, + UpdateLogsDrilldownDefaultColumnsApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + getLogsDrilldownDefaultColumnsStatus: build.query< + GetLogsDrilldownDefaultColumnsStatusApiResponse, + GetLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['LogsDrilldownDefaultColumns'], + }), + replaceLogsDrilldownDefaultColumnsStatus: build.mutation< + ReplaceLogsDrilldownDefaultColumnsStatusApiResponse, + ReplaceLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + method: 'PUT', + body: queryArg.logsDrilldownDefaultColumns, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + updateLogsDrilldownDefaultColumnsStatus: build.mutation< + UpdateLogsDrilldownDefaultColumnsStatusApiResponse, + UpdateLogsDrilldownDefaultColumnsStatusApiArg + >({ + query: (queryArg) => ({ + url: `/logsdrilldowndefaultcolumns/${queryArg.name}/status`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['LogsDrilldownDefaultColumns'], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as generatedAPI }; +export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; +export type GetApiResourcesApiArg = void; +export type ListLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumnsList; +export type ListLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns + | /** status 202 Accepted */ LogsDrilldownDefaultColumns; +export type CreateLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type DeletecollectionLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ Status; +export type DeletecollectionLogsDrilldownDefaultColumnsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumns; +export type GetLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type ReplaceLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type DeleteLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | Status + | /** status 202 Accepted */ Status; +export type DeleteLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateLogsDrilldownDefaultColumnsApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type UpdateLogsDrilldownDefaultColumnsApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type GetLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ LogsDrilldownDefaultColumns; +export type GetLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type ReplaceLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + logsDrilldownDefaultColumns: LogsDrilldownDefaultColumns; +}; +export type UpdateLogsDrilldownDefaultColumnsStatusApiResponse = /** status 200 OK */ + | LogsDrilldownDefaultColumns + | /** status 201 Created */ LogsDrilldownDefaultColumns; +export type UpdateLogsDrilldownDefaultColumnsStatusApiArg = { + /** name of the LogsDrilldownDefaultColumns */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type ApiResource = { + /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ + categories?: string[]; + /** group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale". */ + group?: string; + /** kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo') */ + kind: string; + /** name is the plural name of the resource. */ + name: string; + /** namespaced indicates if a resource is namespaced or not. */ + namespaced: boolean; + /** shortNames is a list of suggested short names of the resource. */ + shortNames?: string[]; + /** singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface. */ + singularName: string; + /** The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates. */ + storageVersionHash?: string; + /** verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy) */ + verbs: string[]; + /** version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)". */ + version?: string; +}; +export type ApiResourceList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** groupVersion is the group and version this APIResourceList is for. */ + groupVersion: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** resources contains the name of the resources and if they are namespaced. */ + resources: ApiResource[]; +}; +export type Time = string; +export type FieldsV1 = object; +export type ManagedFieldsEntry = { + /** APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. */ + apiVersion?: string; + /** FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1" */ + fieldsType?: string; + /** FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. */ + fieldsV1?: FieldsV1; + /** Manager is an identifier of the workflow managing these fields. */ + manager?: string; + /** Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. */ + operation?: string; + /** Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. */ + subresource?: string; + /** Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. */ + time?: Time; +}; +export type OwnerReference = { + /** API version of the referent. */ + apiVersion: string; + /** If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. */ + blockOwnerDeletion?: boolean; + /** If true, this reference points to the managing controller. */ + controller?: boolean; + /** Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name: string; + /** UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid: string; +}; +export type ObjectMeta = { + /** Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations */ + annotations?: { + [key: string]: string; + }; + /** CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + creationTimestamp?: Time; + /** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */ + deletionGracePeriodSeconds?: number; + /** DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. + + Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + deletionTimestamp?: Time; + /** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */ + finalizers?: string[]; + /** GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. + + If this field is specified and the generated name exists, the server will return a 409. + + Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */ + generateName?: string; + /** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */ + generation?: number; + /** Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels */ + labels?: { + [key: string]: string; + }; + /** ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. */ + managedFields?: ManagedFieldsEntry[]; + /** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name?: string; + /** Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. + + Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces */ + namespace?: string; + /** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */ + ownerReferences?: OwnerReference[]; + /** An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. + + Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; + /** UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. + + Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel = { + key: string; + value: string; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels = LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel[]; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord = { + columns: string[]; + labels: LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels; +}; +export type LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords = + LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord[]; +export type LogsDrilldownDefaultColumnsSpec = { + records: LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords; +}; +export type LogsDrilldownDefaultColumnsOperatorState = { + /** descriptiveState is an optional more descriptive state field which has no requirements on format */ + descriptiveState?: string; + /** details contains any extra information that is operator-specific */ + details?: { + [key: string]: { + [key: string]: any; + }; + }; + /** lastEvaluation is the ResourceVersion last evaluated */ + lastEvaluation: string; + /** state describes the state of the lastEvaluation. + It is limited to three possible states for machine evaluation. */ + state: 'success' | 'in_progress' | 'failed'; +}; +export type LogsDrilldownDefaultColumnsStatus = { + /** additionalFields is reserved for future use */ + additionalFields?: { + [key: string]: { + [key: string]: any; + }; + }; + /** operatorStates is a map of operator ID to operator state evaluations. + Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ + operatorStates?: { + [key: string]: LogsDrilldownDefaultColumnsOperatorState; + }; +}; +export type LogsDrilldownDefaultColumns = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + metadata: ObjectMeta; + spec: LogsDrilldownDefaultColumnsSpec; + status?: LogsDrilldownDefaultColumnsStatus; +}; +export type ListMeta = { + /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ + continue?: string; + /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ + remainingItemCount?: number; + /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; +}; +export type LogsDrilldownDefaultColumnsList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: LogsDrilldownDefaultColumns[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ListMeta; +}; +export type StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; +export type Patch = object; +export const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListLogsDrilldownDefaultColumnsQuery, + useLazyListLogsDrilldownDefaultColumnsQuery, + useCreateLogsDrilldownDefaultColumnsMutation, + useDeletecollectionLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsQuery, + useLazyGetLogsDrilldownDefaultColumnsQuery, + useReplaceLogsDrilldownDefaultColumnsMutation, + useDeleteLogsDrilldownDefaultColumnsMutation, + useUpdateLogsDrilldownDefaultColumnsMutation, + useGetLogsDrilldownDefaultColumnsStatusQuery, + useLazyGetLogsDrilldownDefaultColumnsStatusQuery, + useReplaceLogsDrilldownDefaultColumnsStatusMutation, + useUpdateLogsDrilldownDefaultColumnsStatusMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/logsdrilldown/v1beta1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/baseAPI.ts new file mode 100644 index 00000000000..f302218e275 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'notifications.alerting.grafana.app' as const; +export const API_VERSION = 'v0alpha1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'notificationsAlertingAPIv0alpha1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/notifications.api.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/endpoints.gen.ts similarity index 82% rename from packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/notifications.api.gen.ts rename to packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/endpoints.gen.ts index 66b145753a6..35301a132fc 100644 --- a/packages/grafana-alerting/src/grafana/api/notifications/v0alpha1/notifications.api.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/endpoints.gen.ts @@ -1,4 +1,4 @@ -import { api } from './api'; +import { api } from './baseAPI'; export const addTagTypes = ['API Discovery', 'Receiver', 'RoutingTree', 'TemplateGroup', 'TimeInterval'] as const; const injectedRtkApi = api .enhanceEndpoints({ @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/notifications.alerting.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listReceiver: build.query({ @@ -119,44 +119,6 @@ const injectedRtkApi = api }), invalidatesTags: ['Receiver'], }), - getReceiverStatus: build.query({ - query: (queryArg) => ({ - url: `/receivers/${queryArg.name}/status`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['Receiver'], - }), - replaceReceiverStatus: build.mutation({ - query: (queryArg) => ({ - url: `/receivers/${queryArg.name}/status`, - method: 'PUT', - body: queryArg.receiver, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['Receiver'], - }), - updateReceiverStatus: build.mutation({ - query: (queryArg) => ({ - url: `/receivers/${queryArg.name}/status`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['Receiver'], - }), listRoutingTree: build.query({ query: (queryArg) => ({ url: `/routingtrees`, @@ -269,44 +231,6 @@ const injectedRtkApi = api }), invalidatesTags: ['RoutingTree'], }), - getRoutingTreeStatus: build.query({ - query: (queryArg) => ({ - url: `/routingtrees/${queryArg.name}/status`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['RoutingTree'], - }), - replaceRoutingTreeStatus: build.mutation({ - query: (queryArg) => ({ - url: `/routingtrees/${queryArg.name}/status`, - method: 'PUT', - body: queryArg.routingTree, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['RoutingTree'], - }), - updateRoutingTreeStatus: build.mutation({ - query: (queryArg) => ({ - url: `/routingtrees/${queryArg.name}/status`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['RoutingTree'], - }), listTemplateGroup: build.query({ query: (queryArg) => ({ url: `/templategroups`, @@ -419,47 +343,6 @@ const injectedRtkApi = api }), invalidatesTags: ['TemplateGroup'], }), - getTemplateGroupStatus: build.query({ - query: (queryArg) => ({ - url: `/templategroups/${queryArg.name}/status`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['TemplateGroup'], - }), - replaceTemplateGroupStatus: build.mutation< - ReplaceTemplateGroupStatusApiResponse, - ReplaceTemplateGroupStatusApiArg - >({ - query: (queryArg) => ({ - url: `/templategroups/${queryArg.name}/status`, - method: 'PUT', - body: queryArg.templateGroup, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['TemplateGroup'], - }), - updateTemplateGroupStatus: build.mutation({ - query: (queryArg) => ({ - url: `/templategroups/${queryArg.name}/status`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['TemplateGroup'], - }), listTimeInterval: build.query({ query: (queryArg) => ({ url: `/timeintervals`, @@ -572,48 +455,10 @@ const injectedRtkApi = api }), invalidatesTags: ['TimeInterval'], }), - getTimeIntervalStatus: build.query({ - query: (queryArg) => ({ - url: `/timeintervals/${queryArg.name}/status`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['TimeInterval'], - }), - replaceTimeIntervalStatus: build.mutation({ - query: (queryArg) => ({ - url: `/timeintervals/${queryArg.name}/status`, - method: 'PUT', - body: queryArg.timeInterval, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['TimeInterval'], - }), - updateTimeIntervalStatus: build.mutation({ - query: (queryArg) => ({ - url: `/timeintervals/${queryArg.name}/status`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['TimeInterval'], - }), }), overrideExisting: false, }); -export { injectedRtkApi as notificationsAPI }; +export { injectedRtkApi as generatedAPI }; export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; export type GetApiResourcesApiArg = void; export type ListReceiverApiResponse = /** status 200 OK */ ReceiverList; @@ -781,43 +626,6 @@ export type UpdateReceiverApiArg = { force?: boolean; patch: Patch; }; -export type GetReceiverStatusApiResponse = /** status 200 OK */ Receiver; -export type GetReceiverStatusApiArg = { - /** name of the Receiver */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceReceiverStatusApiResponse = /** status 200 OK */ Receiver | /** status 201 Created */ Receiver; -export type ReplaceReceiverStatusApiArg = { - /** name of the Receiver */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - receiver: Receiver; -}; -export type UpdateReceiverStatusApiResponse = /** status 200 OK */ Receiver | /** status 201 Created */ Receiver; -export type UpdateReceiverStatusApiArg = { - /** name of the Receiver */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; export type ListRoutingTreeApiResponse = /** status 200 OK */ RoutingTreeList; export type ListRoutingTreeApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -983,47 +791,6 @@ export type UpdateRoutingTreeApiArg = { force?: boolean; patch: Patch; }; -export type GetRoutingTreeStatusApiResponse = /** status 200 OK */ RoutingTree; -export type GetRoutingTreeStatusApiArg = { - /** name of the RoutingTree */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceRoutingTreeStatusApiResponse = /** status 200 OK */ - | RoutingTree - | /** status 201 Created */ RoutingTree; -export type ReplaceRoutingTreeStatusApiArg = { - /** name of the RoutingTree */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - routingTree: RoutingTree; -}; -export type UpdateRoutingTreeStatusApiResponse = /** status 200 OK */ - | RoutingTree - | /** status 201 Created */ RoutingTree; -export type UpdateRoutingTreeStatusApiArg = { - /** name of the RoutingTree */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; export type ListTemplateGroupApiResponse = /** status 200 OK */ TemplateGroupList; export type ListTemplateGroupApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -1193,47 +960,6 @@ export type UpdateTemplateGroupApiArg = { force?: boolean; patch: Patch; }; -export type GetTemplateGroupStatusApiResponse = /** status 200 OK */ TemplateGroup; -export type GetTemplateGroupStatusApiArg = { - /** name of the TemplateGroup */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceTemplateGroupStatusApiResponse = /** status 200 OK */ - | TemplateGroup - | /** status 201 Created */ TemplateGroup; -export type ReplaceTemplateGroupStatusApiArg = { - /** name of the TemplateGroup */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - templateGroup: TemplateGroup; -}; -export type UpdateTemplateGroupStatusApiResponse = /** status 200 OK */ - | TemplateGroup - | /** status 201 Created */ TemplateGroup; -export type UpdateTemplateGroupStatusApiArg = { - /** name of the TemplateGroup */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; export type ListTimeIntervalApiResponse = /** status 200 OK */ TimeIntervalList; export type ListTimeIntervalApiArg = { /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ @@ -1399,47 +1125,6 @@ export type UpdateTimeIntervalApiArg = { force?: boolean; patch: Patch; }; -export type GetTimeIntervalStatusApiResponse = /** status 200 OK */ TimeInterval; -export type GetTimeIntervalStatusApiArg = { - /** name of the TimeInterval */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceTimeIntervalStatusApiResponse = /** status 200 OK */ - | TimeInterval - | /** status 201 Created */ TimeInterval; -export type ReplaceTimeIntervalStatusApiArg = { - /** name of the TimeInterval */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - timeInterval: TimeInterval; -}; -export type UpdateTimeIntervalStatusApiResponse = /** status 200 OK */ - | TimeInterval - | /** status 201 Created */ TimeInterval; -export type UpdateTimeIntervalStatusApiArg = { - /** name of the TimeInterval */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; export type ApiResource = { /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ categories?: string[]; @@ -1572,34 +1257,6 @@ export type ReceiverSpec = { integrations: ReceiverIntegration[]; title: string; }; -export type ReceiverOperatorState = { - /** descriptiveState is an optional more descriptive state field which has no requirements on format */ - descriptiveState?: string; - /** details contains any extra information that is operator-specific */ - details?: { - [key: string]: { - [key: string]: any; - }; - }; - /** lastEvaluation is the ResourceVersion last evaluated */ - lastEvaluation: string; - /** state describes the state of the lastEvaluation. - It is limited to three possible states for machine evaluation. */ - state: 'success' | 'in_progress' | 'failed'; -}; -export type ReceiverStatus = { - /** additionalFields is reserved for future use */ - additionalFields?: { - [key: string]: { - [key: string]: any; - }; - }; - /** operatorStates is a map of operator ID to operator state evaluations. - Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ - operatorStates?: { - [key: string]: ReceiverOperatorState; - }; -}; export type Receiver = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion: string; @@ -1607,7 +1264,6 @@ export type Receiver = { kind: string; metadata: ObjectMeta; spec: ReceiverSpec; - status?: ReceiverStatus; }; export type ListMeta = { /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ @@ -1700,34 +1356,6 @@ export type RoutingTreeSpec = { defaults: RoutingTreeRouteDefaults; routes: RoutingTreeRoute[]; }; -export type RoutingTreeOperatorState = { - /** descriptiveState is an optional more descriptive state field which has no requirements on format */ - descriptiveState?: string; - /** details contains any extra information that is operator-specific */ - details?: { - [key: string]: { - [key: string]: any; - }; - }; - /** lastEvaluation is the ResourceVersion last evaluated */ - lastEvaluation: string; - /** state describes the state of the lastEvaluation. - It is limited to three possible states for machine evaluation. */ - state: 'success' | 'in_progress' | 'failed'; -}; -export type RoutingTreeStatus = { - /** additionalFields is reserved for future use */ - additionalFields?: { - [key: string]: { - [key: string]: any; - }; - }; - /** operatorStates is a map of operator ID to operator state evaluations. - Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ - operatorStates?: { - [key: string]: RoutingTreeOperatorState; - }; -}; export type RoutingTree = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion: string; @@ -1735,7 +1363,6 @@ export type RoutingTree = { kind: string; metadata: ObjectMeta; spec: RoutingTreeSpec; - status?: RoutingTreeStatus; }; export type RoutingTreeList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1745,38 +1372,12 @@ export type RoutingTreeList = { kind?: string; metadata: ListMeta; }; +export type TemplateGroupTemplateKind = 'grafana' | 'mimir'; export type TemplateGroupSpec = { content: string; + kind: TemplateGroupTemplateKind; title: string; }; -export type TemplateGroupOperatorState = { - /** descriptiveState is an optional more descriptive state field which has no requirements on format */ - descriptiveState?: string; - /** details contains any extra information that is operator-specific */ - details?: { - [key: string]: { - [key: string]: any; - }; - }; - /** lastEvaluation is the ResourceVersion last evaluated */ - lastEvaluation: string; - /** state describes the state of the lastEvaluation. - It is limited to three possible states for machine evaluation. */ - state: 'success' | 'in_progress' | 'failed'; -}; -export type TemplateGroupStatus = { - /** additionalFields is reserved for future use */ - additionalFields?: { - [key: string]: { - [key: string]: any; - }; - }; - /** operatorStates is a map of operator ID to operator state evaluations. - Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ - operatorStates?: { - [key: string]: TemplateGroupOperatorState; - }; -}; export type TemplateGroup = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion: string; @@ -1784,7 +1385,6 @@ export type TemplateGroup = { kind: string; metadata: ObjectMeta; spec: TemplateGroupSpec; - status?: TemplateGroupStatus; }; export type TemplateGroupList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1810,34 +1410,6 @@ export type TimeIntervalSpec = { name: string; time_intervals: TimeIntervalInterval[]; }; -export type TimeIntervalOperatorState = { - /** descriptiveState is an optional more descriptive state field which has no requirements on format */ - descriptiveState?: string; - /** details contains any extra information that is operator-specific */ - details?: { - [key: string]: { - [key: string]: any; - }; - }; - /** lastEvaluation is the ResourceVersion last evaluated */ - lastEvaluation: string; - /** state describes the state of the lastEvaluation. - It is limited to three possible states for machine evaluation. */ - state: 'success' | 'in_progress' | 'failed'; -}; -export type TimeIntervalStatus = { - /** additionalFields is reserved for future use */ - additionalFields?: { - [key: string]: { - [key: string]: any; - }; - }; - /** operatorStates is a map of operator ID to operator state evaluations. - Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ - operatorStates?: { - [key: string]: TimeIntervalOperatorState; - }; -}; export type TimeInterval = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion: string; @@ -1845,7 +1417,6 @@ export type TimeInterval = { kind: string; metadata: ObjectMeta; spec: TimeIntervalSpec; - status?: TimeIntervalStatus; }; export type TimeIntervalList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1855,3 +1426,43 @@ export type TimeIntervalList = { kind?: string; metadata: ListMeta; }; +export const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListReceiverQuery, + useLazyListReceiverQuery, + useCreateReceiverMutation, + useDeletecollectionReceiverMutation, + useGetReceiverQuery, + useLazyGetReceiverQuery, + useReplaceReceiverMutation, + useDeleteReceiverMutation, + useUpdateReceiverMutation, + useListRoutingTreeQuery, + useLazyListRoutingTreeQuery, + useCreateRoutingTreeMutation, + useDeletecollectionRoutingTreeMutation, + useGetRoutingTreeQuery, + useLazyGetRoutingTreeQuery, + useReplaceRoutingTreeMutation, + useDeleteRoutingTreeMutation, + useUpdateRoutingTreeMutation, + useListTemplateGroupQuery, + useLazyListTemplateGroupQuery, + useCreateTemplateGroupMutation, + useDeletecollectionTemplateGroupMutation, + useGetTemplateGroupQuery, + useLazyGetTemplateGroupQuery, + useReplaceTemplateGroupMutation, + useDeleteTemplateGroupMutation, + useUpdateTemplateGroupMutation, + useListTimeIntervalQuery, + useLazyListTimeIntervalQuery, + useCreateTimeIntervalMutation, + useDeletecollectionTimeIntervalMutation, + useGetTimeIntervalQuery, + useLazyGetTimeIntervalQuery, + useReplaceTimeIntervalMutation, + useDeleteTimeIntervalMutation, + useUpdateTimeIntervalMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/notifications.alerting/v0alpha1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts index 71107ce1072..93b5259f144 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/preferences/user/endpoints.gen.ts @@ -86,7 +86,8 @@ export type PatchPrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export type UpdatePrefsCmd = { @@ -99,7 +100,8 @@ export type UpdatePrefsCmd = { queryHistory?: QueryHistoryPreference; regionalFormat?: string; theme?: 'light' | 'dark' | 'system'; - timezone?: 'utc' | 'browser'; + /** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */ + timezone?: string; weekStart?: string; }; export const { diff --git a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts index be22503f3bf..40d4299a1a0 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/provisioning/v0alpha1/endpoints.gen.ts @@ -122,6 +122,10 @@ const injectedRtkApi = api }), invalidatesTags: ['Connection'], }), + getConnectionRepositories: build.query({ + query: (queryArg) => ({ url: `/connections/${queryArg.name}/repositories` }), + providesTags: ['Connection'], + }), getConnectionStatus: build.query({ query: (queryArg) => ({ url: `/connections/${queryArg.name}/status`, @@ -726,6 +730,18 @@ export type UpdateConnectionApiArg = { force?: boolean; patch: Patch; }; +export type GetConnectionRepositoriesApiResponse = /** status 200 OK */ { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: any[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata?: any; +}; +export type GetConnectionRepositoriesApiArg = { + /** name of the ExternalRepositoryList */ + name: string; +}; export type GetConnectionStatusApiResponse = /** status 200 OK */ Connection; export type GetConnectionStatusApiArg = { /** name of the Connection */ @@ -1436,7 +1452,7 @@ export type ConnectionSecure = { /** PrivateKey is the reference to the private key used for GitHub App authentication. This value is stored securely and cannot be read back */ privateKey?: InlineSecureValue; /** Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back */ - webhook?: InlineSecureValue; + token?: InlineSecureValue; }; export type BitbucketConnectionConfig = { /** App client ID */ @@ -2079,6 +2095,8 @@ export const { useReplaceConnectionMutation, useDeleteConnectionMutation, useUpdateConnectionMutation, + useGetConnectionRepositoriesQuery, + useLazyGetConnectionRepositoriesQuery, useGetConnectionStatusQuery, useLazyGetConnectionStatusQuery, useReplaceConnectionStatusMutation, diff --git a/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/baseAPI.ts new file mode 100644 index 00000000000..34762fa1b86 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'rules.alerting.grafana.app' as const; +export const API_VERSION = 'v0alpha1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'rulesAlertingAPIv0alpha1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/rules.api.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/endpoints.gen.ts similarity index 98% rename from packages/grafana-alerting/src/grafana/api/rules/v0alpha1/rules.api.gen.ts rename to packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/endpoints.gen.ts index 361362d5699..cfd62d68def 100644 --- a/packages/grafana-alerting/src/grafana/api/rules/v0alpha1/rules.api.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/endpoints.gen.ts @@ -1,4 +1,4 @@ -import { api } from './api'; +import { api } from './baseAPI'; export const addTagTypes = ['API Discovery', 'AlertRule', 'RecordingRule'] as const; const injectedRtkApi = api .enhanceEndpoints({ @@ -7,7 +7,7 @@ const injectedRtkApi = api .injectEndpoints({ endpoints: (build) => ({ getApiResources: build.query({ - query: () => ({ url: `/apis/rules.alerting.grafana.app/v0alpha1/` }), + query: () => ({ url: `/` }), providesTags: ['API Discovery'], }), listAlertRule: build.query({ @@ -313,7 +313,7 @@ const injectedRtkApi = api }), overrideExisting: false, }); -export { injectedRtkApi as rulesAPI }; +export { injectedRtkApi as generatedAPI }; export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; export type GetApiResourcesApiArg = void; export type ListAlertRuleApiResponse = /** status 200 OK */ AlertRuleList; @@ -1085,3 +1085,33 @@ export type RecordingRuleList = { kind?: string; metadata: ListMeta; }; +export const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListAlertRuleQuery, + useLazyListAlertRuleQuery, + useCreateAlertRuleMutation, + useDeletecollectionAlertRuleMutation, + useGetAlertRuleQuery, + useLazyGetAlertRuleQuery, + useReplaceAlertRuleMutation, + useDeleteAlertRuleMutation, + useUpdateAlertRuleMutation, + useGetAlertRuleStatusQuery, + useLazyGetAlertRuleStatusQuery, + useReplaceAlertRuleStatusMutation, + useUpdateAlertRuleStatusMutation, + useListRecordingRuleQuery, + useLazyListRecordingRuleQuery, + useCreateRecordingRuleMutation, + useDeletecollectionRecordingRuleMutation, + useGetRecordingRuleQuery, + useLazyGetRecordingRuleQuery, + useReplaceRecordingRuleMutation, + useDeleteRecordingRuleMutation, + useUpdateRecordingRuleMutation, + useGetRecordingRuleStatusQuery, + useLazyGetRecordingRuleStatusQuery, + useReplaceRecordingRuleStatusMutation, + useUpdateRecordingRuleStatusMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/rules.alerting/v0alpha1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/index.ts b/packages/grafana-api-clients/src/index.ts index 96e34be4f87..3fb772884a1 100644 --- a/packages/grafana-api-clients/src/index.ts +++ b/packages/grafana-api-clients/src/index.ts @@ -1 +1,4 @@ export { getAPINamespace, getAPIBaseURL, normalizeError, handleRequestError } from './utils/utils'; + +/* @TODO figure out how to automatically set the MockBackendSrv when consumers of this package write tests using the exported clients */ +export { MockBackendSrv } from './utils/backendSrv.mock'; diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index a353b4e311f..8610da06920 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -108,7 +108,10 @@ const config: ConfigFile = { ...createAPIConfig('preferences', 'v1alpha1'), ...createAPIConfig('provisioning', 'v0alpha1'), ...createAPIConfig('shorturl', 'v1beta1'), + ...createAPIConfig('notifications.alerting', 'v0alpha1'), + ...createAPIConfig('rules.alerting', 'v0alpha1'), ...createAPIConfig('historian.alerting', 'v0alpha1'), + ...createAPIConfig('logsdrilldown', 'v1beta1'), ...createAPIConfig('logsdrilldown', 'v1alpha1'), // PLOP_INJECT_API_CLIENT - Used by the API client generator }, diff --git a/packages/grafana-api-clients/src/utils/backendSrv.mock.ts b/packages/grafana-api-clients/src/utils/backendSrv.mock.ts new file mode 100644 index 00000000000..ae7f1129913 --- /dev/null +++ b/packages/grafana-api-clients/src/utils/backendSrv.mock.ts @@ -0,0 +1,46 @@ +import { Observable } from 'rxjs'; +import { fromFetch } from 'rxjs/fetch'; + +import { BackendSrv, BackendSrvRequest, FetchResponse } from '@grafana/runtime'; + +/** + * Minimal mock implementation of BackendSrv for testing. + * Only implements the fetch() method which is used by RTKQ. + * HTTP requests are intercepted by MSW in tests. + */ +export class MockBackendSrv implements Partial { + fetch(options: BackendSrvRequest): Observable> { + const init: RequestInit = { + method: options.method || 'GET', + headers: options.headers, + body: options.data ? JSON.stringify(options.data) : undefined, + credentials: options.credentials, + signal: options.abortSignal, + }; + + return new Observable((observer) => { + fromFetch(options.url, init).subscribe({ + next: async (response) => { + try { + const data = await response.json(); + observer.next({ + data, + status: response.status, + statusText: response.statusText, + ok: response.ok, + headers: response.headers, + redirected: response.redirected, + type: response.type, + url: response.url, + config: options, + }); + observer.complete(); + } catch (error) { + observer.error(error); + } + }, + error: (error) => observer.error(error), + }); + }); + } +} diff --git a/packages/grafana-data/src/datetime/datemath.test.ts b/packages/grafana-data/src/datetime/datemath.test.ts index f53bbe8e6e5..3abf59ea43f 100644 --- a/packages/grafana-data/src/datetime/datemath.test.ts +++ b/packages/grafana-data/src/datetime/datemath.test.ts @@ -165,9 +165,17 @@ describe('DateMath', () => { expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf()); }); - it('should handle multiple math expressions', () => { - const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5])); - expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf()); + it.each([ + ['-2d-6h', [2014, 1, 5], [2014, 1, 2, 18]], + ['-30m-2d', [2014, 1, 5], [2014, 1, 2, 23, 30]], + ['-2d-1d', [2014, 1, 5], [2014, 1, 2]], + ['-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 5, 10, 30]], + ['-1d-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 4, 10, 30]], + ['+1d-6h', [2014, 1, 5], [2014, 1, 5, 18]], + ['-1w-1d', [2014, 1, 14], [2014, 1, 6]], + ])('should handle multiple math expressions: %s', (expression, inputDate, expectedDate) => { + const date = dateMath.parseDateMath(expression, dateTime(inputDate)); + expect(date!.valueOf()).toEqual(dateTime(expectedDate).valueOf()); }); it('should return false when invalid expression', () => { diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index 767da439543..b0aa271be3f 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -9,6 +9,7 @@ import { FieldColorModeId } from '../types/fieldColor'; import { FieldConfigPropertyItem, FieldConfigSource } from '../types/fieldOverrides'; import { InterpolateFunction } from '../types/panel'; import { ThresholdsMode } from '../types/thresholds'; +import { MappingType } from '../types/valueMapping'; import { Registry } from '../utils/Registry'; import { locationUtil } from '../utils/location'; import { mockStandardProperties } from '../utils/tests/mockStandardProperties'; @@ -999,6 +1000,45 @@ describe('setDynamicConfigValue', () => { expect(config.custom.property3).toEqual({}); expect(config.displayName).toBeUndefined(); }); + + it('works correctly with multiple value mappings in the same override', () => { + const config: FieldConfig = { + mappings: [{ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }], + }; + + setDynamicConfigValue( + config, + { + id: 'mappings', + value: [{ type: MappingType.ValueToText, options: { first: { text: 'first' } } }], + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [], + field: { type: FieldType.number } as Field, + dataFrameIndex: 0, + } + ); + + setDynamicConfigValue( + config, + { + id: 'mappings', + value: [{ type: MappingType.ValueToText, options: { second: { text: 'second' } } }], + }, + { + fieldConfigRegistry: customFieldRegistry, + data: [], + field: { type: FieldType.number } as Field, + dataFrameIndex: 0, + } + ); + + expect(config.mappings).toHaveLength(3); + expect(config.mappings![0]).toEqual({ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }); + expect(config.mappings![1]).toEqual({ type: MappingType.ValueToText, options: { first: { text: 'first' } } }); + expect(config.mappings![2]).toEqual({ type: MappingType.ValueToText, options: { second: { text: 'second' } } }); + }); }); describe('getLinksSupplier', () => { diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 8b345036c64..bf56f811106 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -341,7 +341,7 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV return; } - const val = item.process(value.value, context, item.settings); + let val = item.process(value.value, context, item.settings); const remove = val === undefined || val === null; @@ -352,6 +352,15 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV unset(config, item.path); } } else { + // Merge arrays (e.g. mappings) when multiple overrides target the same field + if (Array.isArray(val)) { + const existingValue = item.isCustom ? get(config.custom, item.path) : get(config, item.path); + + if (Array.isArray(existingValue)) { + val = [...existingValue, ...val]; + } + } + if (item.isCustom) { if (!config.custom) { config.custom = {}; diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 06aa45d2275..f1b1ce4154d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -207,6 +207,10 @@ export interface FeatureToggles { */ reportingRetries?: boolean; /** + * Enables CSV encoding options in the reporting feature + */ + reportingCsvEncodingOptions?: boolean; + /** * Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. */ sseGroupByDatasource?: boolean; @@ -400,10 +404,6 @@ export interface FeatureToggles { */ tableSharedCrosshair?: boolean; /** - * Use the kubernetes API for feature toggle management in the frontend - */ - kubernetesFeatureToggles?: boolean; - /** * Enabled grafana cloud specific RBAC roles */ cloudRBACRoles?: boolean; @@ -527,10 +527,6 @@ export interface FeatureToggles { */ dashboardTemplates?: boolean; /** - * Sets the logs table as default visualisation in logs explore - */ - logsExploreTableDefaultVisualization?: boolean; - /** * Enables the new alert list view design */ alertingListViewV2?: boolean; @@ -626,10 +622,6 @@ export interface FeatureToggles { */ exploreLogsAggregatedMetrics?: boolean; /** - * Used in Logs Drilldown to limit the time range - */ - exploreLogsLimitedTimeRange?: boolean; - /** * Enables the gRPC client to authenticate with the App Platform by using ID & access tokens */ appPlatformGrpcClientAuth?: boolean; @@ -657,10 +649,6 @@ export interface FeatureToggles { */ rolePickerDrawer?: boolean; /** - * Enable unified storage search - */ - unifiedStorageSearch?: boolean; - /** * Enable sprinkles on unified storage search */ unifiedStorageSearchSprinkles?: boolean; @@ -703,10 +691,6 @@ export interface FeatureToggles { */ passwordlessMagicLinkAuthentication?: boolean; /** - * Display Related Logs in Grafana Metrics Drilldown - */ - exploreMetricsRelatedLogs?: boolean; - /** * Adds support for quotes and special characters in label values for Prometheus queries */ prometheusSpecialCharsInLabelValues?: boolean; @@ -782,20 +766,11 @@ export interface FeatureToggles { */ elasticsearchCrossClusterSearch?: boolean; /** - * Displays the navigation history so the user can navigate back to previous pages - */ - unifiedHistory?: boolean; - /** * Defaults to using the Loki `/labels` API instead of `/series` * @default true */ lokiLabelNamesQueryApi?: boolean; /** - * Enable the investigations backend API - * @default false - */ - investigationsBackend?: boolean; - /** * Enable folder's api server counts * @default false */ @@ -966,7 +941,8 @@ export interface FeatureToggles { */ alertingBulkActionsInUI?: boolean; /** - * Registers AuthZ /apis endpoint + * Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead + * @deprecated */ kubernetesAuthzApis?: boolean; /** @@ -982,6 +958,18 @@ export interface FeatureToggles { */ kubernetesAuthzZanzanaSync?: boolean; /** + * Registers AuthZ Core Roles /apis endpoint + */ + kubernetesAuthzCoreRolesApi?: boolean; + /** + * Registers AuthZ Roles /apis endpoint + */ + kubernetesAuthzRolesApi?: boolean; + /** + * Registers AuthZ Role Bindings /apis endpoint + */ + kubernetesAuthzRoleBindingsApi?: boolean; + /** * Enables create, delete, and update mutations for resources owned by IAM identity */ kubernetesAuthnMutation?: boolean; @@ -1133,11 +1121,6 @@ export interface FeatureToggles { */ pluginContainers?: boolean; /** - * Run search queries through the tempo backend - * @default false - */ - tempoSearchBackendMigration?: boolean; - /** * Prioritize loading plugins from the CDN before other sources * @default false */ @@ -1259,4 +1242,12 @@ export interface FeatureToggles { * Enables the ASAP smoothing transformation for time series data */ smoothingTransformation?: boolean; + /** + * Enables the creation of keepers that manage secrets stored on AWS secrets manager + */ + secretsManagementAppPlatformAwsKeeper?: boolean; + /** + * Enables profiles exemplars support in profiles drilldown + */ + profilesExemplars?: boolean; } diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 34e672b66f5..3dc7215a2cf 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -52,6 +52,7 @@ export const availableIconsIndex = { bookmark: true, 'book-open': true, 'brackets-curly': true, + brain: true, 'browser-alt': true, bug: true, building: true, diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 8b6ed69463b..62ce2862af2 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -103,6 +103,7 @@ export interface IntervalVariableModel extends VariableWithOptions { export interface CustomVariableModel extends VariableWithMultiSupport { type: 'custom'; + valuesFormat?: 'csv' | 'json'; } export interface DataSourceVariableModel extends VariableWithMultiSupport { diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index b88dac9099c..35bc4f7c975 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -266,6 +266,9 @@ export const versionedPages = { Controls: { '11.1.0': 'data-testid dashboard controls', }, + ControlsButton: { + '12.3.0': 'data-testid dashboard controls button', + }, SubMenu: { submenu: { [MIN_GRAFANA_VERSION]: 'Dashboard submenu', diff --git a/packages/grafana-i18n/package.json b/packages/grafana-i18n/package.json index 36a9faa541b..93dd9837897 100644 --- a/packages/grafana-i18n/package.json +++ b/packages/grafana-i18n/package.json @@ -29,7 +29,6 @@ "@grafana-app/source": "./src/internal/index.ts" }, "./eslint-plugin": { - "@grafana-app/source": "./src/eslint/index.cjs", "types": "./src/eslint/index.d.ts", "default": "./src/eslint/index.cjs" } diff --git a/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts index f323c53b037..c297dc86114 100644 --- a/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/grafanapyroscope/dataquery/x/GrafanaPyroscopeDataQuery_types.gen.ts @@ -25,6 +25,10 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { * Allows to group the results. */ groupBy: Array; + /** + * If set to true, exemplars will be requested + */ + includeExemplars: boolean; /** * Specifies the query label selectors. */ @@ -49,6 +53,7 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery { export const defaultGrafanaPyroscopeDataQuery: Partial = { groupBy: [], + includeExemplars: false, labelSelector: '{}', spanSelector: [], }; diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index f2423566ff5..bc1c1cbcbf4 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -211,6 +211,10 @@ export interface VariableModel { * Type of variable */ type: VariableType; + /** + * Optional, indicates whether a custom type variable uses CSV or JSON to define its values + */ + valuesFormat?: ('csv' | 'json'); } export const defaultVariableModel: Partial = { @@ -220,6 +224,7 @@ export const defaultVariableModel: Partial = { options: [], skipUrlSync: false, staticOptions: [], + valuesFormat: 'csv', }; /** diff --git a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts index e0f10d0f770..294e0262f95 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2_examples.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2_examples.ts @@ -317,6 +317,7 @@ export const handyTestingSchema: Spec = { query: 'option1, option2', skipUrlSync: false, allowCustomValue: true, + valuesFormat: 'csv', }, }, { diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index 78068b9412d..aa1d1e19539 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -300,7 +300,7 @@ export interface FieldConfig { description?: string; // An explicit path to the field in the datasource. When the frame meta includes a path, // This will default to `${frame.meta.path}/${field.name} - // + // // When defined, this value can be used as an identifier within the datasource scope, and // may be used to update the results path?: string; @@ -1353,6 +1353,7 @@ export interface CustomVariableSpec { skipUrlSync: boolean; description?: string; allowCustomValue: boolean; + valuesFormat?: "csv" | "json"; } export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ @@ -1365,6 +1366,7 @@ export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ hide: "dontHide", skipUrlSync: false, allowCustomValue: true, + valuesFormat: undefined, }); // Group variable kind @@ -1549,4 +1551,3 @@ export const defaultSpec = (): Spec => ({ title: "", variables: [], }); - diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index 6a05bed3f1c..1749a57c459 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -1359,6 +1359,7 @@ export interface CustomVariableSpec { skipUrlSync: boolean; description?: string; allowCustomValue: boolean; + valuesFormat?: "csv" | "json"; } export const defaultCustomVariableSpec = (): CustomVariableSpec => ({ diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 478c44704ed..e7b908fc08c 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -137,23 +137,23 @@ "@babel/core": "7.28.0", "@faker-js/faker": "^9.0.0", "@rollup/plugin-node-resolve": "16.0.1", - "@storybook/addon-a11y": "^8.6.2", - "@storybook/addon-actions": "^8.6.2", - "@storybook/addon-docs": "^8.6.2", - "@storybook/addon-essentials": "^8.6.2", - "@storybook/addon-storysource": "^8.6.2", + "@storybook/addon-a11y": "^8.6.15", + "@storybook/addon-actions": "^8.6.15", + "@storybook/addon-docs": "^8.6.15", + "@storybook/addon-essentials": "^8.6.15", + "@storybook/addon-storysource": "^8.6.15", "@storybook/addon-webpack5-compiler-swc": "^2.1.0", - "@storybook/blocks": "^8.6.2", - "@storybook/components": "^8.6.2", - "@storybook/core-events": "^8.6.2", - "@storybook/manager-api": "^8.6.2", + "@storybook/blocks": "^8.6.15", + "@storybook/components": "^8.6.15", + "@storybook/core-events": "^8.6.15", + "@storybook/manager-api": "^8.6.15", "@storybook/mdx2-csf": "1.1.0", "@storybook/preset-scss": "1.0.3", - "@storybook/preview-api": "^8.6.2", - "@storybook/react": "^8.6.2", - "@storybook/react-webpack5": "^8.6.2", + "@storybook/preview-api": "^8.6.15", + "@storybook/react": "^8.6.15", + "@storybook/react-webpack5": "^8.6.15", "@storybook/test-runner": "^0.23.0", - "@storybook/theming": "^8.6.2", + "@storybook/theming": "^8.6.15", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.6.4", "@testing-library/react": "16.3.0", @@ -200,7 +200,7 @@ "rollup-plugin-node-externals": "^8.0.0", "rollup-plugin-svg-import": "3.0.0", "sass-loader": "16.0.5", - "storybook": "^8.6.2", + "storybook": "^8.6.15", "style-loader": "4.0.0", "typescript": "5.9.2", "webpack": "5.101.0" diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 00039adfc3f..7d115e3f9d0 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -1,9 +1,7 @@ import { css, cx } from '@emotion/css'; +import { FloatingFocusManager, useFloating } from '@floating-ui/react'; import RcDrawer from '@rc-component/drawer'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { useOverlay } from '@react-aria/overlays'; -import { ReactNode, useCallback, useEffect, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useId, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -81,17 +79,16 @@ export function Drawer({ const styles = useStyles2(getStyles); const wrapperStyles = useStyles2(getWrapperStyles, size); const dragStyles = useStyles2(getDragStyles); + const titleId = useId(); - const overlayRef = React.useRef(null); - const { dialogProps, titleProps } = useDialog({}, overlayRef); - const { overlayProps } = useOverlay( - { - isDismissable: false, - isOpen: true, - onClose, + const { context, refs } = useFloating({ + open: true, + onOpenChange: (open) => { + if (!open) { + onClose?.(); + } }, - overlayRef - ); + }); // Adds body class while open so the toolbar nav can hide some actions while drawer is open useBodyClassWhileOpen(); @@ -117,6 +114,8 @@ export function Drawer({ minWidth, }, }} + aria-label={typeof title === 'string' ? selectors.components.Drawer.General.title(title) : undefined} + aria-labelledby={typeof title !== 'string' ? titleId : undefined} width={''} motion={{ motionAppear: true, @@ -129,18 +128,8 @@ export function Drawer({ motionName: styles.maskMotion, }} > - -
+ +
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
{typeof title === 'string' ? ( - + {title} {subtitle && ( @@ -169,13 +158,13 @@ export function Drawer({ )} ) : ( - title +
{title}
)} {tabs &&
{tabs}
}
{!scrollableContent ? content : {content}}
- +
); } diff --git a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx index d5a25e2e480..1b06c7daa87 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx +++ b/packages/grafana-ui/src/components/InteractiveTable/InteractiveTable.tsx @@ -153,6 +153,10 @@ interface BaseProps { * Optional way to set how the table is sorted from the beginning. Must be memoized. */ initialSortBy?: Array>; + /** + * Disable the ability to remove sorting on columns (none -> asc -> desc -> asc) + */ + disableSortRemove?: boolean; } interface WithExpandableRow extends BaseProps { @@ -191,6 +195,7 @@ export function InteractiveTable({ showExpandAll = false, fetchData, initialSortBy = [], + disableSortRemove, }: Props) { const styles = useStyles2(getStyles); const tableColumns = useMemo(() => { @@ -222,6 +227,7 @@ export function InteractiveTable({ disableMultiSort: true, // If fetchData is provided, we disable client-side sorting manualSortBy: Boolean(fetchData), + disableSortRemove, getRowId, initialState: { hiddenColumns: [ diff --git a/packages/grafana-ui/src/components/InteractiveTable/types.ts b/packages/grafana-ui/src/components/InteractiveTable/types.ts index 47263d4730e..5b84f4c568b 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/types.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/types.ts @@ -26,4 +26,8 @@ export interface Column { * If the provided function returns `false` the column will be hidden. */ visible?: (data: TableData[]) => boolean; + /** + * Determines starting sort direction when the column header is clicked. + */ + sortDescFirst?: boolean; } diff --git a/packages/grafana-ui/src/components/InteractiveTable/utils.ts b/packages/grafana-ui/src/components/InteractiveTable/utils.ts index 2b664b16f6d..050419fe1d1 100644 --- a/packages/grafana-ui/src/components/InteractiveTable/utils.ts +++ b/packages/grafana-ui/src/components/InteractiveTable/utils.ts @@ -33,6 +33,7 @@ export function getColumns( disableSortBy: !Boolean(column.sortType), width: column.disableGrow ? 0 : undefined, visible: column.visible, + ...(column.sortDescFirst !== undefined && { sortDescFirst: column.sortDescFirst }), ...(column.cell && { Cell: column.cell }), })), ]; diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index aaeb2c3e426..f6ea73c3d31 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -1,9 +1,7 @@ import { cx } from '@emotion/css'; -import { useDialog } from '@react-aria/dialog'; -import { FocusScope } from '@react-aria/focus'; -import { OverlayContainer, useOverlay } from '@react-aria/overlays'; -import { PropsWithChildren, useRef, type JSX } from 'react'; -import * as React from 'react'; +import { FloatingFocusManager, useDismiss, useFloating, useInteractions, useRole } from '@floating-ui/react'; +import { OverlayContainer } from '@react-aria/overlays'; +import { PropsWithChildren, ReactNode, useId, type JSX } from 'react'; import { t } from '@grafana/i18n'; @@ -66,23 +64,26 @@ export function Modal(props: PropsWithChildren) { trapFocus = true, } = props; const styles = useStyles2(getModalStyles); + const titleId = useId(); - const ref = useRef(null); - - // Handle interacting outside the dialog and pressing - // the Escape key to close the modal. - const { overlayProps, underlayProps } = useOverlay( - { isKeyboardDismissDisabled: !closeOnEscape, isOpen, onClose: onDismiss }, - ref - ); - - // Get props for the dialog and its title - const { dialogProps, titleProps } = useDialog( - { - 'aria-label': ariaLabel, + const { context, refs } = useFloating({ + open: isOpen, + onOpenChange: (open) => { + if (!open) { + onDismiss?.(); + } }, - ref - ); + }); + + const dismiss = useDismiss(context, { + enabled: closeOnEscape, + }); + + const role = useRole(context, { + role: 'dialog', + }); + + const { getFloatingProps } = useInteractions([dismiss, role]); if (!isOpen) { return null; @@ -96,12 +97,17 @@ export function Modal(props: PropsWithChildren) { role="presentation" className={styles.modalBackdrop} onClick={onClickBackdrop || (closeOnBackdropClick ? onDismiss : undefined)} - {...underlayProps} /> - -
+ +
- {typeof title === 'string' && } + {typeof title === 'string' && } { // FIXME: custom title components won't get an accessible title. // Do we really want to support them or shall we just limit this ModalTabsHeader? @@ -118,12 +124,12 @@ export function Modal(props: PropsWithChildren) {
{children}
- +
); } -function ModalButtonRow({ leftItems, children }: { leftItems?: React.ReactNode; children: React.ReactNode }) { +function ModalButtonRow({ leftItems, children }: { leftItems?: ReactNode; children: ReactNode }) { const styles = useStyles2(getModalStyles); if (leftItems) { diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index f59614acd53..f60fafe36f3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,8 +1,9 @@ -import { useId, memo, HTMLAttributes, ReactNode } from 'react'; +import { useId, memo, HTMLAttributes, SVGProps } from 'react'; import { FieldDisplay } from '@grafana/data'; -import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors'; +import { RadialArcPathEndpointMarks } from './RadialArcPathEndpointMarks'; +import { getBarEndcapColors, getGradientCss } from './colors'; import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; import { drawRadialArcPath, toRad } from './utils'; @@ -29,11 +30,6 @@ interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase { type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient; -const ENDPOINT_MARKER_MIN_ANGLE = 10; -const DOT_OPACITY = 0.5; -const DOT_RADIUS_FACTOR = 0.4; -const MAX_DOT_RADIUS = 8; - export const RadialArcPath = memo( ({ arcLengthDeg, @@ -50,14 +46,13 @@ export const RadialArcPath = memo( }: RadialArcPathProps) => { const id = useId(); - const bgDivStyle: HTMLAttributes['style'] = { width: '100%', height: '100%' }; - if ('color' in rest) { - bgDivStyle.backgroundColor = rest.color; - } else { - bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); - } + const isGradient = 'gradient' in rest; - const { radius, centerX, centerY, barWidth } = dimensions; + const { vizWidth, vizHeight, radius, centerX, centerY, barWidth } = dimensions; + const pad = Math.ceil(Math.max(2, barWidth / 2)); // pad to cover stroke caps and glow in Safari + const boxX = Math.round(centerX - radius - barWidth - pad); + const boxY = Math.round(centerY - radius - barWidth - pad); + const boxSize = Math.round((radius + barWidth) * 2 + pad * 2); const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars); @@ -69,78 +64,71 @@ export const RadialArcPath = memo( const xEnd = centerX + radius * Math.cos(endRadians); const yEnd = centerY + radius * Math.sin(endRadians); - const dotRadius = - endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; + const bgDivStyle: HTMLAttributes['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX }; + const pathProps: SVGProps = {}; + if (isGradient) { + bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); + pathProps.fill = 'none'; + pathProps.stroke = 'white'; + } else { + bgDivStyle.backgroundColor = rest.color; + pathProps.fill = 'none'; + pathProps.stroke = rest.color; + } let barEndcapColors: [string, string] | undefined; - let endpointMarks: ReactNode = null; - if ('gradient' in rest) { - if (endpointMarker && (rest.gradient?.length ?? 0) > 0) { - switch (endpointMarker) { - case 'point': - const [pointColorStart, pointColorEnd] = getEndpointMarkerColors( - rest.gradient!, - fieldDisplay.display.percent - ); - endpointMarks = ( - <> - {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( - - )} - - - ); - break; - case 'glow': - const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); - const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); - const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); - endpointMarks = - arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? ( - - ) : null; - break; - default: - break; - } - } - - if (barEndcaps) { - barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent); - } + if (barEndcaps) { + barEndcapColors = isGradient + ? getBarEndcapColors(rest.gradient, fieldDisplay.display.percent) + : [rest.color, rest.color]; } + const pathEl = ( + + ); + return ( <> - {/* FIXME: optimize this by only using clippath + foreign obj for gradients */} - - - + {isGradient && ( + + + + {pathEl} + + + )} - -
- + {isGradient ? ( + +
+ + ) : ( + pathEl + )} {barEndcapColors?.[0] && } {barEndcapColors?.[1] && ( )} - {endpointMarks} + {endpointMarker && ( + + )} ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx new file mode 100644 index 00000000000..af601e65c21 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.test.tsx @@ -0,0 +1,143 @@ +import { render, RenderResult } from '@testing-library/react'; + +import { FieldDisplay } from '@grafana/data'; + +import { RadialArcPathEndpointMarks, RadialArcPathEndpointMarksProps } from './RadialArcPathEndpointMarks'; +import { RadialGaugeDimensions } from './types'; + +const ser = new XMLSerializer(); + +const expectHTML = (result: RenderResult, expected: string) => { + let actual = ser.serializeToString(result.asFragment()).replace(/xmlns=".*?" /g, ''); + expect(actual).toEqual(expected.replace(/^\s*|\n/gm, '')); +}; + +describe('RadialArcPathEndpointMarks', () => { + const defaultDimensions = Object.freeze({ + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + vizWidth: 200, + vizHeight: 200, + margin: 10, + barIndex: 0, + thresholdsBarRadius: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }) satisfies RadialGaugeDimensions; + + const defaultFieldDisplay = Object.freeze({ + name: 'Test', + field: {}, + display: { text: '50', numeric: 50, color: '#FF0000' }, + hasLinks: false, + }) satisfies FieldDisplay; + + const defaultProps = Object.freeze({ + arcLengthDeg: 90, + dimensions: defaultDimensions, + fieldDisplay: defaultFieldDisplay, + startAngle: 0, + xStart: 100, + xEnd: 150, + yStart: 100, + yEnd: 50, + }) satisfies Omit; + + it('renders the expected marks when endpointMarker is "point" w/ a static color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "point" w/ a gradient color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "glow" w/ a static color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('renders the expected marks when endpointMarker is "glow" w/ a gradient color', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render the start mark when arcLengthDeg is less than the minimum angle for "point" endpointMarker', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render anything when arcLengthDeg is less than the minimum angle for "glow" endpointMarker', () => { + expectHTML( + render( + + + + ), + '' + ); + }); + + it('does not render anything if endpointMarker is some other value', () => { + expectHTML( + render( + + {/* @ts-ignore: confirming the component doesn't throw */} + + + ), + '' + ); + }); +}); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx new file mode 100644 index 00000000000..7bd9a8c435f --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPathEndpointMarks.tsx @@ -0,0 +1,98 @@ +import { FieldDisplay } from '@grafana/data'; + +import { getEndpointMarkerColors, getGuideDotColor } from './colors'; +import { GradientStop, RadialGaugeDimensions } from './types'; +import { toRad } from './utils'; + +interface RadialArcPathEndpointMarksPropsBase { + arcLengthDeg: number; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; + endpointMarker: 'point' | 'glow'; + roundedBars?: boolean; + startAngle: number; + glowFilter?: string; + endpointMarkerGlowFilter?: string; + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; +} + +interface RadialArcPathEndpointMarksPropsWithColor extends RadialArcPathEndpointMarksPropsBase { + color: string; +} + +interface RadialArcPathEndpointMarksPropsWithGradient extends RadialArcPathEndpointMarksPropsBase { + gradient: GradientStop[]; +} + +export type RadialArcPathEndpointMarksProps = + | RadialArcPathEndpointMarksPropsWithColor + | RadialArcPathEndpointMarksPropsWithGradient; + +const ENDPOINT_MARKER_MIN_ANGLE = 10; +const DOT_OPACITY = 0.5; +const DOT_RADIUS_FACTOR = 0.4; +const MAX_DOT_RADIUS = 8; + +export function RadialArcPathEndpointMarks({ + startAngle: angle, + arcLengthDeg, + dimensions, + endpointMarker, + fieldDisplay, + xStart, + xEnd, + yStart, + yEnd, + roundedBars, + endpointMarkerGlowFilter, + glowFilter, + ...rest +}: RadialArcPathEndpointMarksProps) { + const isGradient = 'gradient' in rest; + const { radius, centerX, centerY, barWidth } = dimensions; + const endRadians = toRad(angle + arcLengthDeg); + + switch (endpointMarker) { + case 'point': { + const [pointColorStart, pointColorEnd] = isGradient + ? getEndpointMarkerColors(rest.gradient, fieldDisplay.display.percent) + : [getGuideDotColor(rest.color), getGuideDotColor(rest.color)]; + + const dotRadius = + endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; + + return ( + <> + {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( + + )} + + + ); + } + case 'glow': + const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); + const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); + const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); + if (arcLengthDeg <= ENDPOINT_MARKER_MIN_ANGLE) { + break; + } + return ( + + ); + default: + break; + } + + return null; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index 452dd394ab1..e60a3dfde31 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { useId } from 'react'; +import { useId, ReactNode } from 'react'; import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -107,14 +107,14 @@ export function RadialGauge(props: RadialGaugeProps) { const startAngle = shape === 'gauge' ? 250 : 0; const endAngle = shape === 'gauge' ? 110 : 360; - const defs: React.ReactNode[] = []; - const graphics: React.ReactNode[] = []; - let sparklineElement: React.ReactNode | null = null; + const defs: ReactNode[] = []; + const graphics: ReactNode[] = []; + let sparklineElement: ReactNode | null = null; for (let barIndex = 0; barIndex < values.length; barIndex++) { const displayValue = values[barIndex]; const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle); - const gradientStops = buildGradientColors(gradient, theme, displayValue); + const gradientStops = gradient ? buildGradientColors(theme, displayValue) : undefined; const color = displayValue.display.color ?? FALLBACK_COLOR; const dimensions = calculateDimensions( width, @@ -131,7 +131,9 @@ export function RadialGauge(props: RadialGaugeProps) { // FIXME: I want to move the ids for these filters into a context which the children // can reference via a hook, rather than passing them down as props const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`; + const spotlightGradientRef = endpointMarker === 'glow' ? `url(#${spotlightGradientId})` : undefined; const glowFilterId = `glow-${gaugeId}`; + const glowFilterRef = glowBar ? `url(#${glowFilterId})` : undefined; if (endpointMarker === 'glow') { defs.push( @@ -154,7 +156,7 @@ export function RadialGauge(props: RadialGaugeProps) { fieldDisplay={displayValue} angleRange={angleRange} startAngle={startAngle} - glowFilter={`url(#${glowFilterId})`} + glowFilter={glowFilterRef} segmentCount={segmentCount} segmentSpacing={segmentSpacing} shape={shape} @@ -170,8 +172,8 @@ export function RadialGauge(props: RadialGaugeProps) { angleRange={angleRange} startAngle={startAngle} roundedBars={roundedBars} - glowFilter={`url(#${glowFilterId})`} - endpointMarkerGlowFilter={`url(#${spotlightGradientId})`} + glowFilter={glowFilterRef} + endpointMarkerGlowFilter={spotlightGradientRef} shape={shape} gradient={gradientStops} fieldDisplay={displayValue} @@ -183,7 +185,7 @@ export function RadialGauge(props: RadialGaugeProps) { // These elements are only added for first value / bar if (barIndex === 0) { if (glowBar) { - defs.push(); + defs.push(); } if (glowCenter) { @@ -234,7 +236,7 @@ export function RadialGauge(props: RadialGaugeProps) { endAngle={endAngle} angleRange={angleRange} roundedBars={roundedBars} - glowFilter={`url(#${glowFilterId})`} + glowFilter={glowFilterRef} shape={shape} gradient={gradientStops} /> @@ -260,7 +262,7 @@ export function RadialGauge(props: RadialGaugeProps) { const body = ( <> - {defs} + {defs.length > 0 && {defs}} {graphics} {sparklineElement} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx index 69ab16e450e..dc094b7261c 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx @@ -1,4 +1,3 @@ -import { css } from '@emotion/css'; import { memo } from 'react'; import { @@ -9,7 +8,6 @@ import { GrafanaTheme2, } from '@grafana/data'; -import { useStyles2 } from '../../themes/ThemeContext'; import { calculateFontSize } from '../../utils/measureText'; import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; @@ -50,7 +48,6 @@ export const RadialText = memo( valueManualFontSize, nameManualFontSize, }: RadialTextProps) => { - const styles = useStyles2(getStyles); const { centerX, centerY, radius, barWidth } = dimensions; if (textMode === 'none') { @@ -106,10 +103,9 @@ export const RadialText = memo( const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY; const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY; const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; - const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2; // adjust the text up on gauges and when sparklines are present - let yOffset = 0; + let yOffset = valueFontSize / 4; if (shape === 'gauge') { // we render from the center of the gauge, so move up by half of half of the total height yOffset -= (valueHeight + nameHeight) / 4; @@ -126,15 +122,12 @@ export const RadialText = memo( y={valueY} fontSize={valueFontSize} fill={theme.colors.text.primary} - className={styles.text} textAnchor="middle" - dominantBaseline="middle" + dominantBaseline="text-bottom" > {displayValue.prefix ?? ''} {displayValue.text} - - {displayValue.suffix ?? ''} - + {displayValue.suffix ?? ''} )} {showName && ( @@ -143,7 +136,7 @@ export const RadialText = memo( x={centerX} y={nameY} textAnchor="middle" - dominantBaseline="middle" + dominantBaseline="text-bottom" fill={nameColor} > {displayValue.title} @@ -155,9 +148,3 @@ export const RadialText = memo( ); RadialText.displayName = 'RadialText'; - -const getStyles = (_theme: GrafanaTheme2) => ({ - text: css({ - verticalAlign: 'bottom', - }), -}); diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap index db4c1c40882..9d12b97a6c3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap @@ -1,17 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 120 A 80 80 0 1 1 149.98603736605492 120.00000121846968"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 50 A 50 50 0 0 1 100 150"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 20 A 80 80 0 0 1 180 100"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 20 A 80 80 0 1 1 20 100.00000000000001"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 20 A 80 80 0 1 1 20 100.00000000000001"`; -exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`; +exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`; diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.test.ts b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts index 321e95bb921..36a4c70d619 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.test.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts @@ -1,6 +1,6 @@ import { defaultsDeep } from 'lodash'; -import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data'; +import { createTheme, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data'; import { FieldColorModeId } from '@grafana/schema'; import { @@ -50,35 +50,9 @@ describe('RadialGauge color utils', () => { }, }); - it('should return the baseColor if gradient is false-y', () => { - expect( - buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') - ).toEqual([ - { color: '#FF0000', percent: 0 }, - { color: '#FF0000', percent: 1 }, - ]); - - expect( - buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') - ).toEqual([ - { color: '#FF0000', percent: 0 }, - { color: '#FF0000', percent: 1 }, - ]); - }); - - it('uses the fallback color if no baseColor is set', () => { - expect(buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))).toEqual( - [ - { color: FALLBACK_COLOR, percent: 0 }, - { color: FALLBACK_COLOR, percent: 1 }, - ] - ); - }); - it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { expect( buildGradientColors( - true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds), { view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) }, @@ -89,14 +63,13 @@ describe('RadialGauge color utils', () => { it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { expect( - buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000') + buildGradientColors(createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000') ).toMatchSnapshot(); }); it('should return gradient colors for continuous color modes', () => { expect( buildGradientColors( - true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)), '#00FF00' @@ -107,7 +80,6 @@ describe('RadialGauge color utils', () => { it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => { expect( buildGradientColors( - true, createTheme({ colors: { mode } }), buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues)) ) @@ -117,7 +89,6 @@ describe('RadialGauge color utils', () => { it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => { expect( buildGradientColors( - true, createTheme({ colors: { mode } }), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#442299' diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts index 61160a9f826..a05817a7a54 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -7,18 +7,10 @@ import { GradientStop, RadialShape } from './types'; import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils'; export function buildGradientColors( - gradient = false, theme: GrafanaTheme2, fieldDisplay: FieldDisplay, baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR ): GradientStop[] { - if (!gradient) { - return [ - { color: baseColor, percent: 0 }, - { color: baseColor, percent: 1 }, - ]; - } - const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode); // thresholds get special handling @@ -183,7 +175,7 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape const GRAY_05 = '#111217'; const GRAY_90 = '#fbfbfb'; const CONTRAST_THRESHOLD_MAX = 4.5; -const getGuideDotColor = (color: string): string => { +export const getGuideDotColor = (color: string): string => { const darkColor = GRAY_05; const lightColor = GRAY_90; return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; diff --git a/packages/grafana-ui/src/components/RadialGauge/effects.tsx b/packages/grafana-ui/src/components/RadialGauge/effects.tsx index 53a255d4a45..2d3ae3daf21 100644 --- a/packages/grafana-ui/src/components/RadialGauge/effects.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/effects.tsx @@ -2,14 +2,20 @@ import { colorManipulator, GrafanaTheme2 } from '@grafana/data'; import { RadialGaugeDimensions } from './types'; +// some utility transparent white colors for gradients +const TRANSPARENT_WHITE = '#ffffff00'; +const MOSTLY_TRANSPARENT_WHITE = '#ffffff88'; +const MOSTLY_OPAQUE_WHITE = '#ffffffbb'; +const OPAQUE_WHITE = '#ffffff'; + +const MIN_GLOW_SIZE = 0.75; +const GLOW_FACTOR = 0.08; + export interface GlowGradientProps { id: string; barWidth: number; } -const MIN_GLOW_SIZE = 0.75; -const GLOW_FACTOR = 0.08; - export function GlowGradient({ id, barWidth }: GlowGradientProps) { // 0.75 is the minimum glow size, and it scales with bar width const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR; @@ -27,16 +33,6 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) { const CENTER_GLOW_OPACITY = 0.25; -export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) { - const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY); - return ( - - - - - ); -} - export interface CenterGlowProps { dimensions: RadialGaugeDimensions; gaugeId: string; @@ -52,7 +48,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps - + @@ -62,19 +58,15 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps ); } -export function SpotlightGradient({ - id, - dimensions, - roundedBars, - angle, - theme, -}: { +interface SpotlightGradientProps { id: string; dimensions: RadialGaugeDimensions; angle: number; roundedBars: boolean; theme: GrafanaTheme2; -}) { +} + +export function SpotlightGradient({ id, dimensions, roundedBars, angle, theme }: SpotlightGradientProps) { if (theme.isLight) { return null; } @@ -88,9 +80,9 @@ export function SpotlightGradient({ return ( - - - {roundedBars && } + + + {roundedBars && } ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/types.ts b/packages/grafana-ui/src/components/RadialGauge/types.ts index cc233dd524c..111c7aef7d6 100644 --- a/packages/grafana-ui/src/components/RadialGauge/types.ts +++ b/packages/grafana-ui/src/components/RadialGauge/types.ts @@ -2,6 +2,8 @@ export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'non export type RadialShape = 'circle' | 'gauge'; export interface RadialGaugeDimensions { + vizHeight: number; + vizWidth: number; margin: number; radius: number; centerX: number; diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts index b9b2e4ad8f3..70ee54a6337 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts @@ -283,7 +283,9 @@ describe('RadialGauge utils', () => { }); describe('drawRadialArcPath', () => { - const defaultDims: RadialGaugeDimensions = Object.freeze({ + const defaultDims = Object.freeze({ + vizHeight: 220, + vizWidth: 220, centerX: 100, centerY: 100, radius: 80, @@ -297,7 +299,7 @@ describe('RadialGauge utils', () => { scaleLabelsSpacing: 0, scaleLabelsRadius: 0, gaugeBottomY: 0, - }); + }) satisfies RadialGaugeDimensions; it.each([ { description: 'quarter arc', startAngle: 0, endAngle: 90 }, @@ -324,11 +326,6 @@ describe('RadialGauge utils', () => { expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims)); expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims)); }); - - it('should return empty string if inner radius collapses to zero or below', () => { - const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 }; - expect(drawRadialArcPath(0, 180, smallRadiusDims)).toBe(''); - }); }); }); @@ -341,7 +338,9 @@ describe('RadialGauge utils', () => { describe('getOptimalSegmentCount', () => { it('should adjust segment count based on dimensions and spacing', () => { - const dimensions: RadialGaugeDimensions = { + const dimensions = { + vizHeight: 220, + vizWidth: 220, centerX: 100, centerY: 100, radius: 80, @@ -355,7 +354,7 @@ describe('RadialGauge utils', () => { scaleLabelsSpacing: 0, scaleLabelsRadius: 0, gaugeBottomY: 0, - }; + } satisfies RadialGaugeDimensions; expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8); expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5); diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index e26cf5eed2a..a18efc42699 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -155,6 +155,8 @@ export function calculateDimensions( } return { + vizWidth: width, + vizHeight: height, margin, gaugeBottomY: centerY + belowCenterY, radius: innerRadius, @@ -185,7 +187,7 @@ export function drawRadialArcPath( dimensions: RadialGaugeDimensions, roundedBars?: boolean ): string { - const { radius, centerX, centerY, barWidth } = dimensions; + const { radius, centerX, centerY } = dimensions; // For some reason a 100% full arc cannot be rendered if (endAngle >= 360) { @@ -197,66 +199,12 @@ export function drawRadialArcPath( const largeArc = endAngle > 180 ? 1 : 0; - const outerR = radius + barWidth / 2; - const innerR = Math.max(0, radius - barWidth / 2); - if (innerR <= 0) { - return ''; // cannot draw arc with 0 inner radius - } + let x1 = centerX + radius * Math.cos(startRadians); + let y1 = centerY + radius * Math.sin(startRadians); + let x2 = centerX + radius * Math.cos(endRadians); + let y2 = centerY + radius * Math.sin(endRadians); - // get points for both an inner and outer arc. we draw - // the arc entirely with a path's fill instead of using stroke - // so that it can be used as a clip-path. - const ox1 = centerX + outerR * Math.cos(startRadians); - const oy1 = centerY + outerR * Math.sin(startRadians); - const ox2 = centerX + outerR * Math.cos(endRadians); - const oy2 = centerY + outerR * Math.sin(endRadians); - - const ix1 = centerX + innerR * Math.cos(startRadians); - const iy1 = centerY + innerR * Math.sin(startRadians); - const ix2 = centerX + innerR * Math.cos(endRadians); - const iy2 = centerY + innerR * Math.sin(endRadians); - - // calculate the cap width in case we're drawing rounded bars - const capR = barWidth / 2; - - const pathParts = [ - // start at outer start - 'M', - ox1, - oy1, - // outer arc from start to end (clockwise) - 'A', - outerR, - outerR, - 0, - largeArc, - 1, - ox2, - oy2, - ]; - - if (roundedBars) { - // rounded end cap: small arc connecting outer end to inner end - pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2); - } else { - // straight line to inner end (square butt) - pathParts.push('L', ix2, iy2); - } - - // inner arc from end back to start (counter-clockwise) - pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1); - - if (roundedBars) { - // rounded start cap: small arc connecting inner start back to outer start - pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1); - } else { - // straight line back to outer start (square butt) - pathParts.push('L', ox1, oy1); - } - - pathParts.push('Z'); - - return pathParts.join(' '); + return ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' '); } export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { diff --git a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx index a10ad157120..5b919c05ec2 100644 --- a/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx +++ b/packages/grafana-ui/src/components/SecretTextArea/SecretTextArea.tsx @@ -14,6 +14,8 @@ export type Props = React.ComponentProps & { isConfigured: boolean; /** Called when the user clicks on the "Reset" button in order to clear the secret */ onReset: () => void; + /** If true, the text area will grow to fill available width. */ + grow?: boolean; }; export const CONFIGURED_TEXT = 'configured'; @@ -35,11 +37,11 @@ const getStyles = (theme: GrafanaTheme2) => { * * https://developers.grafana.com/ui/latest/index.html?path=/docs/inputs-secrettextarea--docs */ -export const SecretTextArea = ({ isConfigured, onReset, ...props }: Props) => { +export const SecretTextArea = ({ isConfigured, onReset, grow, ...props }: Props) => { const styles = useStyles2(getStyles); return ( - + {!isConfigured && } {isConfigured && (
+

This demonstrates using Toggletip inside a Drawer.

+ + + + + +
+ } + footer="Focus should work correctly within this Toggletip" + placement="bottom-start" + > + + +
+ + )} + + ); +}; + +InsideDrawer.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'], + }, +}; + +export const InsideModal: StoryFn = () => { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + + setIsModalOpen(false)}> +
+

This demonstrates using Toggletip inside a Modal.

+ + + + + + +
+ } + footer="Focus should work correctly within this Toggletip" + placement="bottom-start" + > + + + +
+ + + ); +}; + +InsideDrawer.parameters = { + controls: { + hideNoControlsWarning: true, + exclude: ['title', 'content', 'footer', 'children', 'placement', 'theme', 'closeButton', 'portalRoot'], + }, +}; + export default meta; diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx index d9537dfdc1e..77c189e6e79 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx +++ b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.mdx @@ -66,6 +66,6 @@ export interface UserView { avatarUrl?: string; }; /** Datetime string when the user was last active */ - lastActiveAt: DateTimeInput; + lastActiveAt?: DateTimeInput; } ``` diff --git a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx index ca5ff1f665c..945d46b2f79 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UserIcon.tsx @@ -10,7 +10,7 @@ import { Tooltip } from '../Tooltip/Tooltip'; import { UserView } from './types'; export interface UserIconProps { - /** An object that contains the user's details and 'lastActiveAt' status */ + /** An object that contains the user's details and an optional 'lastActiveAt' status */ userView: UserView; /** A boolean value that determines whether the tooltip should be shown or not */ showTooltip?: boolean; @@ -64,7 +64,8 @@ export const UserIcon = ({ showTooltip = true, }: PropsWithChildren) => { const { user, lastActiveAt } = userView; - const isActive = dateTime(lastActiveAt).diff(dateTime(), 'minutes', true) >= -15; + const hasActive = lastActiveAt !== undefined && lastActiveAt !== null; + const isActive = hasActive && dateTime(lastActiveAt).diff(dateTime(), 'minutes', true) >= -15; const theme = useTheme2(); const styles = useMemo(() => getStyles(theme, isActive), [theme, isActive]); const content = ( @@ -88,18 +89,20 @@ export const UserIcon = ({ const tooltip = (
{user.name}
-
- {isActive ? ( -
- - Active last 15m - - -
- ) : ( - formatViewed(lastActiveAt) - )} -
+ {hasActive && ( +
+ {isActive ? ( +
+ + Active last 15m + + +
+ ) : ( + formatViewed(lastActiveAt) + )} +
+ )}
); diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx index b5d689af8ea..ade04a32c79 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.mdx @@ -60,6 +60,6 @@ export interface UserView { avatarUrl?: string; }; /** Datetime string when the user was last active */ - lastActiveAt: DateTimeInput; + lastActiveAt?: DateTimeInput; } ``` diff --git a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx index 66c9a0bddca..04718af9493 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx +++ b/packages/grafana-ui/src/components/UsersIndicator/UsersIndicator.tsx @@ -9,7 +9,7 @@ import { UserIcon } from './UserIcon'; import { UserView } from './types'; export interface UsersIndicatorProps { - /** An object that contains the user's details and 'lastActiveAt' status */ + /** An object that contains the user's details and an optional 'lastActiveAt' status */ users: UserView[]; /** A limit of how many user icons to show before collapsing them and showing a number of users instead */ limit?: number; @@ -23,7 +23,7 @@ export interface UsersIndicatorProps { * https://developers.grafana.com/ui/latest/index.html?path=/docs/iconography-usersindicator--docs */ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProps) => { - const styles = useStyles2(getStyles); + const styles = useStyles2(getStyles, limit); if (!users.length) { return null; } @@ -39,34 +39,41 @@ export const UsersIndicator = ({ users, onClick, limit = 4 }: UsersIndicatorProp className={styles.container} aria-label={t('grafana-ui.users-indicator.container-label', 'Users indicator container')} > + {users.slice(0, limitReached ? limit : limit + 1).map((userView, idx, arr) => ( + + ))} {limitReached && ( - + {tooManyUsers ? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings '...' : `+${extraUsers}`} )} - {users - .slice(0, limitReached ? limit : limit + 1) - .reverse() - .map((userView) => ( - - ))}
); }; -const getStyles = (theme: GrafanaTheme2) => { +const getStyles = (theme: GrafanaTheme2, limit: number) => { return { container: css({ display: 'flex', justifyContent: 'center', - flexDirection: 'row-reverse', marginLeft: theme.spacing(1), + isolation: 'isolate', '& > button': { marginLeft: theme.spacing(-1), // Overlay the elements a bit on top of each other + + // Ensure overlaying user icons are stacked correctly with z-index on each element + ...Object.fromEntries( + Array.from({ length: limit }).map((_, idx) => [ + `&:nth-of-type(${idx + 1})`, + { + zIndex: limit - idx, + }, + ]) + ), }, }), dots: css({ diff --git a/packages/grafana-ui/src/components/UsersIndicator/types.ts b/packages/grafana-ui/src/components/UsersIndicator/types.ts index 1ba9f2d4e5b..641afbf1914 100644 --- a/packages/grafana-ui/src/components/UsersIndicator/types.ts +++ b/packages/grafana-ui/src/components/UsersIndicator/types.ts @@ -8,5 +8,5 @@ export interface UserView { avatarUrl?: string; }; /** Datetime string when the user was last active */ - lastActiveAt: DateTimeInput; + lastActiveAt?: DateTimeInput; } diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index 0c2859453eb..b654a2d3ac6 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -119,6 +119,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ table: css({ width: '100%', 'th:first-child': { + width: '100%', borderBottom: `1px solid ${theme.colors.border.weak}`, }, }), diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 56ec6cb733e..6de77fd660b 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -134,6 +134,7 @@ const getStyles = (theme: GrafanaTheme2) => { label: 'LegendLabelCell', maxWidth: 0, width: '100%', + minWidth: theme.spacing(16), }), labelCellInner: css({ label: 'LegendLabelCellInner', diff --git a/pkg/api/datasources.go b/pkg/api/datasources.go index d7dc89d248f..0fe733ea1b5 100644 --- a/pkg/api/datasources.go +++ b/pkg/api/datasources.go @@ -204,7 +204,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response. func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds()) }() ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID()) @@ -240,7 +240,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds()) }() uid := web.Params(c.Req)[":uid"] @@ -375,7 +375,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error { func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("AddDataSource"), time.Since(start).Seconds()) }() cmd := datasources.AddDataSourceCommand{} @@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response. func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("UpdateDataSourceByUID"), time.Since(start).Seconds()) }() cmd := datasources.UpdateDataSourceCommand{} if err := web.Bind(c.Req, &cmd); err != nil { diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index dd0b39e13a7..8a0e27639d4 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -91,7 +91,7 @@ func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.Histogram Namespace: "grafana", Name: "ds_config_handler_requests_duration_seconds", Help: "Duration of requests handled by datasource configuration handlers", - }, []string{"code_path", "handler"}) + }, []string{"handler"}) promRegister.MustRegister(dsConfigHandlerRequestsDuration) return promRegister, dsConfigHandlerRequestsDuration } diff --git a/pkg/api/dtos/prefs.go b/pkg/api/dtos/prefs.go index cc7da29b550..252810d8bd4 100644 --- a/pkg/api/dtos/prefs.go +++ b/pkg/api/dtos/prefs.go @@ -13,7 +13,7 @@ type UpdatePrefsCmd struct { // Deprecated: Use HomeDashboardUID instead HomeDashboardID int64 `json:"homeDashboardId"` HomeDashboardUID *string `json:"homeDashboardUID,omitempty"` - // Enum: utc,browser + // Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string Timezone string `json:"timezone"` WeekStart string `json:"weekStart"` QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"` @@ -31,7 +31,7 @@ type PatchPrefsCmd struct { // Default:0 // Deprecated: Use HomeDashboardUID instead HomeDashboardID *int64 `json:"homeDashboardId,omitempty"` - // Enum: utc,browser + // Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string Timezone *string `json:"timezone,omitempty"` WeekStart *string `json:"weekStart,omitempty"` Language *string `json:"language,omitempty"` diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 0898a5ecb66..591941d8bf5 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -387,7 +387,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi Namespace: "grafana", Name: "ds_config_handler_requests_duration_seconds", Help: "Duration of requests handled by datasource configuration handlers", - }, []string{"code_path", "handler"}), + }, []string{"handler"}), } promRegister.MustRegister(hs.htmlHandlerRequestsDuration) diff --git a/pkg/api/plugin_resource_test.go b/pkg/api/plugin_resource_test.go index 0ee413447da..68611319354 100644 --- a/pkg/api/plugin_resource_test.go +++ b/pkg/api/plugin_resource_test.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/caching" @@ -28,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/oauthtoken/oauthtokentest" "github.com/grafana/grafana/pkg/services/pluginsintegration" + "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" diff --git a/pkg/api/pluginproxy/ds_proxy_test.go b/pkg/api/pluginproxy/ds_proxy_test.go index 99561233366..002965a1606 100644 --- a/pkg/api/pluginproxy/ds_proxy_test.go +++ b/pkg/api/pluginproxy/ds_proxy_test.go @@ -928,9 +928,10 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *contextmodel.ReqContext, cfg secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) features := featuremgmt.WithFeatures() quotaService := quotatest.New(false, nil) + dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features) dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), &actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, - plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features) require.NoError(t, err) @@ -1050,9 +1051,11 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets var routes []*plugins.Route features := featuremgmt.WithFeatures() quotaService := quotatest.New(false, nil) - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), + var sqlStore db.DB = nil + dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features) + dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), &actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, - plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features) require.NoError(t, err) @@ -1106,9 +1109,11 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger()) features := featuremgmt.WithFeatures() - dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), + var sqlStore db.DB = nil + dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features) + dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features), &actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, - plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) tracer := tracing.InitializeTracerForTest() diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index 785b69f2b7c..9650b2d92c2 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -134,6 +134,10 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te return response.Error(http.StatusBadRequest, "Invalid theme", nil) } + if dtoCmd.Timezone != nil && !pref.IsValidTimezone(*dtoCmd.Timezone) { + return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil) + } + // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query // nolint:staticcheck dashboardID := dtoCmd.HomeDashboardID diff --git a/pkg/apiserver/registry/generic/storage.go b/pkg/apiserver/registry/generic/storage.go index 98e2f1fe9df..adbe54f5a1f 100644 --- a/pkg/apiserver/registry/generic/storage.go +++ b/pkg/apiserver/registry/generic/storage.go @@ -1,26 +1,55 @@ package generic import ( + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/generic" "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/storage" "github.com/grafana/grafana/pkg/apimachinery/utils" ) +// SelectableFieldsOptions allows customizing field selector behavior for a resource. +type SelectableFieldsOptions struct { + // GetAttrs returns labels and fields for the object. + // If nil, the default GetAttrs is used which only exposes metadata.name. + GetAttrs func(obj runtime.Object) (labels.Set, fields.Set, error) +} + func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) { + return NewRegistryStoreWithSelectableFields(scheme, resourceInfo, optsGetter, SelectableFieldsOptions{}) +} + +// NewRegistryStoreWithSelectableFields creates a registry store with custom selectable fields support. +// Use this when you need to filter resources by custom fields like spec.connection.name. +func NewRegistryStoreWithSelectableFields(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter, fieldOpts SelectableFieldsOptions) (*registry.Store, error) { gv := resourceInfo.GroupVersion() gv.Version = runtime.APIVersionInternal strategy := NewStrategy(scheme, gv) if resourceInfo.IsClusterScoped() { strategy = strategy.WithClusterScope() } + + // Use custom GetAttrs if provided, otherwise use default + var attrFunc storage.AttrFunc + var predicateFunc func(label labels.Selector, field fields.Selector) storage.SelectionPredicate + if fieldOpts.GetAttrs != nil { + attrFunc = fieldOpts.GetAttrs + // Pass nil predicateFunc to use default behavior with custom attrFunc + predicateFunc = nil + } else { + attrFunc = GetAttrs + predicateFunc = Matcher + } + store := ®istry.Store{ NewFunc: resourceInfo.NewFunc, NewListFunc: resourceInfo.NewListFunc, KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()), KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: Matcher, + PredicateFunc: predicateFunc, DefaultQualifiedResource: resourceInfo.GroupResource(), SingularQualifiedResource: resourceInfo.SingularGroupResource(), TableConvertor: resourceInfo.TableConverter(), @@ -28,7 +57,7 @@ func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, o UpdateStrategy: strategy, DeleteStrategy: strategy, } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrFunc} if err := store.CompleteWithOptions(options); err != nil { return nil, err } diff --git a/pkg/build/e2e/README.md b/pkg/build/e2e/README.md new file mode 100644 index 00000000000..3edc6946727 --- /dev/null +++ b/pkg/build/e2e/README.md @@ -0,0 +1,20 @@ +## Build artifacts + +Put the resulting tar in your `grafana` OSS path: +```sh +go -C grafana run ./pkg/build/cmd artifacts -a targz:enterprise:linux/amd64 --alpine-base=alpine:3.22 --tag-format='{{ .version }}-{{ .buildID }}-{{ .arch }}' --grafana-dir="${PWD}/grafana" --enterprise-dir="${PWD}/grafana-enterprise" +``` + +Also build the e2e test runner: +```sh +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ./e2e-runner ./e2e/ +``` + +And then `chmod +x ./e2e-runner`. + +## Running tests + +Reporting tests with Image Renderer: +```sh +go run ./pkg/build/e2e --suite=e2e/extensions/enterprise/smtp-suite --license=e2e/extensions/enterprise/license.jwt --image-renderer +``` diff --git a/pkg/build/e2e/main.go b/pkg/build/e2e/main.go index 976a7883bdc..7cd85ad2825 100644 --- a/pkg/build/e2e/main.go +++ b/pkg/build/e2e/main.go @@ -138,6 +138,10 @@ func run(ctx context.Context, cmd *cli.Command) error { } if code != 0 { + if stdout, _ := c.Stdout(ctx); len(stdout) > 0 { + log.Printf("e2e test suite stdout:\n%s", stdout) + } + return fmt.Errorf("e2e tests failed with exit code %d", code) } diff --git a/pkg/build/e2e/run.go b/pkg/build/e2e/run.go index e5d36d34b7b..c9bf85df3c8 100644 --- a/pkg/build/e2e/run.go +++ b/pkg/build/e2e/run.go @@ -8,10 +8,10 @@ import ( func RunSuite(d *dagger.Client, svc *dagger.Service, src *dagger.Directory, cache *dagger.CacheVolume, suite, runnerFlags string) *dagger.Container { command := fmt.Sprintf( - "./e2e-runner cypress --start-grafana=false --cypress-video"+ + "./e2e-runner cypress --browser=electron --start-grafana=false --cypress-video"+ " --grafana-base-url http://grafana:3001 --suite %s %s", suite, runnerFlags) - return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:13.1.0"), src), cache). + return WithYarnCache(WithGrafanaFrontend(d.Container().From("cypress/included:14.3.2"), src), cache). WithWorkdir("/src"). WithServiceBinding("grafana", svc). WithExec([]string{"yarn", "install", "--immutable"}). diff --git a/pkg/build/e2e/service.go b/pkg/build/e2e/service.go index 31463f63783..f55bd765f3f 100644 --- a/pkg/build/e2e/service.go +++ b/pkg/build/e2e/service.go @@ -99,13 +99,15 @@ func GrafanaService(ctx context.Context, d *dagger.Client, opts GrafanaServiceOp } if opts.StartImageRenderer { - container = container.WithEnvVariable("START_IMAGE_RENDERER", "true"). - WithExec([]string{"apt-get", "update"}). - WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}) + imageRendererSvc := d.Container().From("grafana/grafana-image-renderer:" + opts.ImageRendererVersion). + WithExposedPort(8081). + AsService() - if opts.ImageRendererVersion != "" { - container = container.WithEnvVariable("IMAGE_RENDERER_VERSION", opts.ImageRendererVersion) - } + container = container.WithServiceBinding("image-renderer", imageRendererSvc). + WithExec([]string{"apt-get", "update"}). + WithExec([]string{"apt-get", "install", "-y", "ca-certificates"}). + WithEnvVariable("GF_RENDERING_CALLBACK_URL", "http://grafana:3001/"). + WithEnvVariable("GF_RENDERING_SERVER_URL", "http://image-renderer:8081/render") } // We add all GF_ environment variables to allow for overriding Grafana configuration. diff --git a/pkg/infra/tracing/tracing.go b/pkg/infra/tracing/tracing.go index f8b340d12df..276014c7d6b 100644 --- a/pkg/infra/tracing/tracing.go +++ b/pkg/infra/tracing/tracing.go @@ -209,7 +209,7 @@ func (ots *TracingService) initSampler() (tracesdk.Sampler, error) { case "rateLimiting": return newRateLimiter(ots.cfg.SamplerParam), nil case "remote": - return jaegerremote.New("grafana", + return jaegerremote.New(ots.cfg.ServiceName, jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL), jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)), ), nil diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 7d2a0ba5439..ad0a1c88f24 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -837,6 +837,8 @@ type VariableModel struct { // Optional field, if you want to extract part of a series name or metric node segment. // Named capture groups can be used to separate the display text and value. Regex *string `json:"regex,omitempty"` + // Optional, indicates whether a custom type variable uses CSV or JSON to define its values + ValuesFormat *VariableModelValuesFormat `json:"valuesFormat,omitempty"` // Determine whether regex applies to variable value or display text RegexApplyTo *VariableRegexApplyTo `json:"regexApplyTo,omitempty"` // Additional static options for query variable @@ -852,6 +854,7 @@ func NewVariableModel() *VariableModel { Multi: (func(input bool) *bool { return &input })(false), AllowCustomValue: (func(input bool) *bool { return &input })(true), IncludeAll: (func(input bool) *bool { return &input })(false), + ValuesFormat: (func(input VariableModelValuesFormat) *VariableModelValuesFormat { return &input })(VariableModelValuesFormatCsv), } } @@ -1191,6 +1194,13 @@ const ( DataTransformerConfigTopicAlertStates DataTransformerConfigTopic = "alertStates" ) +type VariableModelValuesFormat string + +const ( + VariableModelValuesFormatCsv VariableModelValuesFormat = "csv" + VariableModelValuesFormatJson VariableModelValuesFormat = "json" +) + type VariableModelStaticOptionsOrder string const ( diff --git a/pkg/operators/provisioning/config.go b/pkg/operators/provisioning/config.go index 05552e56095..868d2a8d717 100644 --- a/pkg/operators/provisioning/config.go +++ b/pkg/operators/provisioning/config.go @@ -36,7 +36,6 @@ import ( type provisioningControllerConfig struct { provisioningClient *client.Clientset resyncInterval time.Duration - repoFactory repository.Factory unified resources.ResourceStore clients resources.ClientFactory tokenExchangeClient *authn.TokenExchangeClient @@ -129,16 +128,6 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll return nil, fmt.Errorf("failed to create provisioning client: %w", err) } - decrypter, err := setupDecrypter(cfg, tracer, tokenExchangeClient) - if err != nil { - return nil, fmt.Errorf("failed to setup decrypter: %w", err) - } - - repoFactory, err := setupRepoFactory(cfg, decrypter, provisioningClient, registry) - if err != nil { - return nil, fmt.Errorf("failed to setup repository getter: %w", err) - } - // HACK: This logic directly connects to unified storage. We are doing this for now as there is no global // search endpoint. But controllers, in general, should not connect directly to unified storage and instead // go through the api server. Once there is a global search endpoint, we will switch to that here as well. @@ -195,7 +184,6 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll return &provisioningControllerConfig{ provisioningClient: provisioningClient, - repoFactory: repoFactory, unified: unified, clients: clients, resyncInterval: operatorSec.Key("resync_interval").MustDuration(60 * time.Second), diff --git a/pkg/operators/provisioning/connection_operator.go b/pkg/operators/provisioning/connection_operator.go new file mode 100644 index 00000000000..34624f4fe47 --- /dev/null +++ b/pkg/operators/provisioning/connection_operator.go @@ -0,0 +1,86 @@ +package provisioning + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/grafana/grafana-app-sdk/logging" + "github.com/prometheus/client_golang/prometheus" + "k8s.io/client-go/tools/cache" + + appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/controller" + "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/setting" +) + +// RunConnectionController starts the connection controller operator. +func RunConnectionController(deps server.OperatorDependencies) error { + logger := logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })).With("logger", "provisioning-connection-controller") + logger.Info("Starting provisioning connection controller") + + controllerCfg, err := getConnectionControllerConfig(deps.Config, deps.Registerer) + if err != nil { + return fmt.Errorf("failed to setup operator: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("Received shutdown signal, stopping controllers") + cancel() + }() + + informerFactory := informer.NewSharedInformerFactoryWithOptions( + controllerCfg.provisioningClient, + controllerCfg.resyncInterval, + ) + + statusPatcher := appcontroller.NewConnectionStatusPatcher(controllerCfg.provisioningClient.ProvisioningV0alpha1()) + connInformer := informerFactory.Provisioning().V0alpha1().Connections() + + connController, err := controller.NewConnectionController( + controllerCfg.provisioningClient.ProvisioningV0alpha1(), + connInformer, + statusPatcher, + ) + if err != nil { + return fmt.Errorf("failed to create connection controller: %w", err) + } + + informerFactory.Start(ctx.Done()) + if !cache.WaitForCacheSync(ctx.Done(), connInformer.Informer().HasSynced) { + return fmt.Errorf("failed to sync informer cache") + } + + connController.Run(ctx, controllerCfg.workerCount) + return nil +} + +type connectionControllerConfig struct { + provisioningControllerConfig + workerCount int +} + +func getConnectionControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) (*connectionControllerConfig, error) { + controllerCfg, err := setupFromConfig(cfg, registry) + if err != nil { + return nil, err + } + + return &connectionControllerConfig{ + provisioningControllerConfig: *controllerCfg, + workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1), + }, nil +} diff --git a/pkg/operators/provisioning/repo_operator.go b/pkg/operators/provisioning/repo_operator.go index 416eb5c0e3f..c3a038b9378 100644 --- a/pkg/operators/provisioning/repo_operator.go +++ b/pkg/operators/provisioning/repo_operator.go @@ -106,6 +106,7 @@ func RunRepoController(deps server.OperatorDependencies) error { type repoControllerConfig struct { provisioningControllerConfig + repoFactory repository.Factory workerCount int parallelOperations int allowedTargets []string @@ -119,6 +120,17 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) ( return nil, err } + // Setup repository factory for repo controller + decrypter, err := setupDecrypter(cfg, tracing.NewNoopTracerService(), controllerCfg.tokenExchangeClient) + if err != nil { + return nil, fmt.Errorf("failed to setup decrypter: %w", err) + } + + repoFactory, err := setupRepoFactory(cfg, decrypter, controllerCfg.provisioningClient, registry) + if err != nil { + return nil, fmt.Errorf("failed to setup repository factory: %w", err) + } + allowedTargets := []string{} cfg.SectionWithEnvOverrides("provisioning").Key("allowed_targets").Strings("|") if len(allowedTargets) == 0 { @@ -127,6 +139,7 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) ( return &repoControllerConfig{ provisioningControllerConfig: *controllerCfg, + repoFactory: repoFactory, allowedTargets: allowedTargets, workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1), parallelOperations: cfg.SectionWithEnvOverrides("operator").Key("parallel_operations").MustInt(10), diff --git a/pkg/operators/register.go b/pkg/operators/register.go index b31b9837fb0..4d42591ca7b 100644 --- a/pkg/operators/register.go +++ b/pkg/operators/register.go @@ -13,6 +13,12 @@ func init() { RunFunc: provisioning.RunRepoController, }) + server.RegisterOperator(server.Operator{ + Name: "provisioning-connection", + Description: "Watch provisioning connections", + RunFunc: provisioning.RunConnectionController, + }) + server.RegisterOperator(server.Operator{ Name: "iam-folder-reconciler", Description: "Reconcile folder resources into Zanzana", diff --git a/pkg/plugins/backendplugin/provider/provider.go b/pkg/plugins/backendplugin/provider/provider.go index db9e3309a34..9b16329dc59 100644 --- a/pkg/plugins/backendplugin/provider/provider.go +++ b/pkg/plugins/backendplugin/provider/provider.go @@ -5,7 +5,6 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/grpcplugin" "github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2" "github.com/grafana/grafana/pkg/plugins/log" @@ -27,10 +26,6 @@ func New(providers ...PluginBackendProvider) *Service { } } -func ProvideService(coreRegistry *coreplugin.Registry) *Service { - return New(coreRegistry.BackendFactoryProvider(), DefaultProvider) -} - func (s *Service) BackendFactory(ctx context.Context, p *plugins.Plugin) backendplugin.PluginFactoryFunc { for _, provider := range s.providerChain { if factory := provider(ctx, p); factory != nil { diff --git a/pkg/plugins/go.mod b/pkg/plugins/go.mod new file mode 100644 index 00000000000..9bcc74e80f6 --- /dev/null +++ b/pkg/plugins/go.mod @@ -0,0 +1,130 @@ +module github.com/grafana/grafana/pkg/plugins + +go 1.25.5 + +require ( + github.com/Machiel/slugify v1.0.1 + github.com/ProtonMail/go-crypto v1.3.0 + github.com/gobwas/glob v0.2.3 + github.com/google/go-cmp v0.7.0 + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 + github.com/grafana/grafana-plugin-sdk-go v0.284.0 + github.com/grafana/grafana/pkg/apimachinery v0.0.0 + github.com/grafana/grafana/pkg/semconv v0.0.0 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/go-plugin v1.7.0 + github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 + google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/apache/arrow-go/v18 v18.4.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cheekybits/genny v1.0.0 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/gogo/googleapis v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect + github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect + github.com/grafana/otel-profiling-go v0.5.1 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/hashicorp/yamux v0.1.2 // indirect + github.com/jaegertracing/jaeger-idl v0.5.0 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mattetti/filebuffer v1.0.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect + go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.40.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect + k8s.io/apimachinery v0.34.3 // indirect + k8s.io/apiserver v0.34.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect +) + +replace ( + github.com/grafana/grafana/pkg/apimachinery => ../apimachinery + github.com/grafana/grafana/pkg/semconv => ../semconv +) diff --git a/pkg/plugins/go.sum b/pkg/plugins/go.sum new file mode 100644 index 00000000000..2bd8adfbad2 --- /dev/null +++ b/pkg/plugins/go.sum @@ -0,0 +1,347 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4= +github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E= +github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= +github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= +github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE= +github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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 v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= +github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= +github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= +github.com/grafana/grafana-plugin-sdk-go v0.284.0 h1:1bK7eWsnPBLUWDcWJWe218Ik5ad0a5JpEL4mH9ry7Ws= +github.com/grafana/grafana-plugin-sdk-go v0.284.0/go.mod h1:lHPniaSxq3SL5MxDIPy04TYB1jnTp/ivkYO+xn5Rz3E= +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.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +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.7.0 h1:YghfQH/0QmPNc/AZMTFE3ac8fipZyZECHdDPshfk+mA= +github.com/hashicorp/go-plugin v1.7.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2 h1:gCNiM4T5xEc4IpT8vM50CIO+AtElr5kO9l2Rxbq+Sz8= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.2/go.mod h1:6ZM4ZdwClyAsiU2uDBmRHCvq0If/03BMbF9U+U7G5pA= +github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= +github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jaegertracing/jaeger-idl v0.5.0 h1:zFXR5NL3Utu7MhPg8ZorxtCBjHrL3ReM1VoB65FOFGE= +github.com/jaegertracing/jaeger-idl v0.5.0/go.mod h1:ON90zFo9eoyXrt9F/KN8YeF3zxcnujaisMweFY/rg5k= +github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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/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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= +github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= +github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +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/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 h1:2pn7OzMewmYRiNtv1doZnLo3gONcnMHlFnmOR8Vgt+8= +go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0/go.mod h1:rjbQTDEPQymPE0YnRQp9/NuPwwtL0sesz/fnqRW/v84= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 h1:nXGeLvT1QtCAhkASkP/ksjkTKZALIaQBIW+JSIw1KIc= +go.opentelemetry.io/contrib/propagators/jaeger v1.38.0/go.mod h1:oMvOXk78ZR3KEuPMBgp/ThAMDy9ku/eyUVztr+3G6Wo= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 h1:oPW/SRFyHgIgxrvNhSBzqvZER2N5kRlci3/rGTOuyWo= +go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0/go.mod h1:B9Oka5QVD0bnmZNO6gBbBta6nohD/1Z+f9waH2oXyBs= +go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU= +go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= +golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +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-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= +google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.3 h1:uGH1qpDvSiYG4HVFqc6A3L4CKiX+aBWDrrsxHYK0Bdo= +k8s.io/apiserver v0.34.3/go.mod h1:QPnnahMO5C2m3lm6fPW3+JmyQbvHZQ8uudAu/493P2w= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E= +sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/plugins/log/logger.go b/pkg/plugins/log/logger.go index f0eed4f3e07..ff21b2a4a7c 100644 --- a/pkg/plugins/log/logger.go +++ b/pkg/plugins/log/logger.go @@ -2,54 +2,84 @@ package log import ( "context" - - "github.com/grafana/grafana/pkg/infra/log" + "log/slog" + "sync" ) +// loggerFactory is a function that creates a Logger given a name. +// It can be set by calling SetLoggerFactory to use a custom logger implementation. +var loggerFactory func(name string) Logger + +// SetLoggerFactory sets the factory function used to create loggers. +// This should be called during initialization to register a custom logger implementation. +// If not set, a default slog-based logger will be used. +func SetLoggerFactory(factory func(name string) Logger) { + loggerFactory = factory +} + +var slogLogManager = &slogLoggerManager{ + cache: sync.Map{}, +} + func New(name string) Logger { - return &grafanaInfraLogWrapper{ - l: log.New(name), + if loggerFactory != nil { + return loggerFactory(name) } + // add a caching layer since slog doesn't perform any caching itself + return slogLogManager.getOrCreate(name) } -type grafanaInfraLogWrapper struct { - l *log.ConcreteLogger +type slogLoggerManager struct { + cache sync.Map } -func (d *grafanaInfraLogWrapper) New(ctx ...any) Logger { +func (m *slogLoggerManager) getOrCreate(name string) Logger { + if cached, ok := m.cache.Load(name); ok { + return cached.(*slogLogger) + } + + logger := &slogLogger{ + logger: slog.Default().With("logger", name), + name: name, + } + actual, _ := m.cache.LoadOrStore(name, logger) + return actual.(*slogLogger) +} + +type slogLogger struct { + logger *slog.Logger + name string +} + +func (l *slogLogger) New(ctx ...any) Logger { if len(ctx) == 0 { - return &grafanaInfraLogWrapper{ - l: d.l.New(), + return &slogLogger{ + logger: l.logger, + name: l.name, } } - - return &grafanaInfraLogWrapper{ - l: d.l.New(ctx...), + return &slogLogger{ + logger: l.logger.With(ctx...), + name: l.name, } } -func (d *grafanaInfraLogWrapper) Debug(msg string, ctx ...any) { - d.l.Debug(msg, ctx...) +func (l *slogLogger) Debug(msg string, ctx ...any) { + l.logger.Debug(msg, ctx...) } -func (d *grafanaInfraLogWrapper) Info(msg string, ctx ...any) { - d.l.Info(msg, ctx...) +func (l *slogLogger) Info(msg string, ctx ...any) { + l.logger.Info(msg, ctx...) } -func (d *grafanaInfraLogWrapper) Warn(msg string, ctx ...any) { - d.l.Warn(msg, ctx...) +func (l *slogLogger) Warn(msg string, ctx ...any) { + l.logger.Warn(msg, ctx...) } -func (d *grafanaInfraLogWrapper) Error(msg string, ctx ...any) { - d.l.Error(msg, ctx...) +func (l *slogLogger) Error(msg string, ctx ...any) { + l.logger.Error(msg, ctx...) } -func (d *grafanaInfraLogWrapper) FromContext(ctx context.Context) Logger { - concreteInfraLogger, ok := d.l.FromContext(ctx).(*log.ConcreteLogger) - if !ok { - return d.New() - } - return &grafanaInfraLogWrapper{ - l: concreteInfraLogger, - } +func (l *slogLogger) FromContext(_ context.Context) Logger { + return l } diff --git a/pkg/registry/apis/collections/legacy/stars.go b/pkg/registry/apis/collections/legacy/stars.go index 39f7a9c088f..3ce900d6e30 100644 --- a/pkg/registry/apis/collections/legacy/stars.go +++ b/pkg/registry/apis/collections/legacy/stars.go @@ -170,6 +170,13 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *collections.Star return nil, err } + // Send an error if we try to save a non-dashboard star + for _, res := range obj.Spec.Resource { + if res.Group != "dashboard.grafana.app" || res.Kind != "Dashboard" { + return nil, fmt.Errorf("only dashboard stars are supported until the migration to unified storage is complete") + } + } + user, err := s.users.GetByUID(ctx, &user.GetUserByUIDQuery{ UID: owner.Identifier, }) diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 0b7ffa04ea4..0fa8b7d7115 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -224,7 +224,7 @@ func (a *dashboardSqlAccess) CountResources(ctx context.Context, opts MigrateOpt case "folder.grafana.app/folders": summary := &resourcepb.BulkResponse_Summary{} summary.Group = folders.GROUP - summary.Group = folders.RESOURCE + summary.Resource = folders.RESOURCE _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ " WHERE is_folder=TRUE AND org_id=?", orgId).Get(&summary.Count) rsp.Summary = append(rsp.Summary, summary) diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index eed79dd6f0d..6973a443b12 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -389,6 +389,11 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A return apierrors.NewBadRequest(err.Error()) } + // Validate tags + if err := validateDashboardTags(dashObj); err != nil { + return apierrors.NewBadRequest(err.Error()) + } + id, err := identity.GetRequester(ctx) if err != nil { return fmt.Errorf("error getting requester: %w", err) @@ -459,6 +464,11 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A return apierrors.NewBadRequest(err.Error()) } + // Validate tags + if err := validateDashboardTags(newDashObj); err != nil { + return apierrors.NewBadRequest(err.Error()) + } + // Validate folder existence if specified and changed if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" { id, err := identity.GetRequester(ctx) @@ -556,6 +566,32 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) { return title, refresh, nil } +// validateDashboardTags validates that all dashboard tags are within the maximum length +func validateDashboardTags(obj runtime.Object) error { + var tags []string + + switch d := obj.(type) { + case *dashv0.Dashboard: + tags = d.Spec.GetNestedStringSlice("tags") + case *dashv1.Dashboard: + tags = d.Spec.GetNestedStringSlice("tags") + case *dashv2alpha1.Dashboard: + tags = d.Spec.Tags + case *dashv2beta1.Dashboard: + tags = d.Spec.Tags + default: + return fmt.Errorf("unsupported dashboard version: %T", obj) + } + + for _, tag := range tags { + if len(tag) > 50 { + return dashboards.ErrDashboardTagTooLong + } + } + + return nil +} + func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storageOpts := apistore.StorageOptions{ EnableFolderSupport: true, diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index 08a943b8da6..bbc032e7382 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -142,6 +142,24 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.StringProperty(), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "panelType", + In: "query", + Description: "find dashboards using panels of a given plugin type", + Required: false, + Schema: spec.StringProperty(), + }, + }, + { + ParameterProps: spec3.ParameterProps{ + Name: "dataSourceType", + In: "query", + Description: "find dashboards using datasources of a given plugin type", + Required: false, + Schema: spec.StringProperty(), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "permission", @@ -430,14 +448,11 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use } } - // The facet term fields + // Apply facet terms if facets, ok := queryParams["facet"]; ok { if queryParams.Has("facetLimit") { if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 { - facetLimit = parsed - if facetLimit > 1000 { - facetLimit = 1000 - } + facetLimit = min(parsed, 1000) } } searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet) @@ -449,21 +464,35 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use } } - // The tags filter - if tags, ok := queryParams["tag"]; ok { + if v, ok := queryParams["tag"]; ok { searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: "tags", Operator: "=", - Values: tags, + Values: v, }) } - // The libraryPanel filter - if libraryPanel, ok := queryParams["libraryPanel"]; ok { + if v, ok := queryParams["panelType"]; ok { + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ + Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_PANEL_TYPES, + Operator: "=", + Values: v, + }) + } + + if v, ok := queryParams["dataSourceType"]; ok { + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ + Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_DS_TYPES, + Operator: "=", + Values: v, + }) + } + + if v, ok := queryParams["libraryPanel"]; ok { searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: builders.DASHBOARD_LIBRARY_PANEL_REFERENCE, Operator: "=", - Values: libraryPanel, + Values: v, }) } @@ -523,6 +552,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use // gets dashboards that the user was granted read access to permissions := user.GetPermissions() dashboardPermissions := permissions[dashboards.ActionDashboardsRead] + folderPermissions := permissions[dashboards.ActionFoldersRead] dashboardUids := make([]string, 0) sharedDashboards := make([]string, 0) @@ -533,6 +563,13 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use } } } + for _, folderPermission := range folderPermissions { + if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found { + if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID { + dashboardUids = append(dashboardUids, folderUid) + } + } + } if len(dashboardUids) == 0 { return sharedDashboards, nil @@ -543,9 +580,15 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use return sharedDashboards, err } + folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE) + if err != nil { + return sharedDashboards, err + } + dashboardSearchRequest := &resourcepb.ResourceSearchRequest{ - Fields: []string{"folder"}, - Limit: int64(len(dashboardUids)), + Federated: []*resourcepb.ResourceKey{folderKey}, + Fields: []string{"folder"}, + Limit: int64(len(dashboardUids)), Options: &resourcepb.ListOptions{ Key: key, Fields: []*resourcepb.Requirement{{ @@ -581,12 +624,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use } } - // only folders the user has access to will be returned here - folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE) - if err != nil { - return sharedDashboards, err - } - folderSearchRequest := &resourcepb.ResourceSearchRequest{ Fields: []string{"folder"}, Limit: int64(len(allFolders)), @@ -599,6 +636,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use }}, }, } + // only folders the user has access to will be returned here foldersResult, err := s.client.Search(ctx, folderSearchRequest) if err != nil { return sharedDashboards, err diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 3b9935f8247..c7defba3fea 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -507,6 +507,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { []byte("publicfolder"), // folder uid }, }, + { + Key: &resourcepb.ResourceKey{ + Name: "sharedfolder", + Resource: "folder", + }, + Cells: [][]byte{ + []byte("privatefolder"), // folder uid + }, + }, }, }, } @@ -550,6 +559,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { []byte("privatefolder"), // folder uid }, }, + { + Key: &resourcepb.ResourceKey{ + Name: "sharedfolder", + Resource: "folder", + }, + Cells: [][]byte{ + []byte("privatefolder"), // folder uid + }, + }, }, }, } @@ -571,6 +589,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { allPermissions := make(map[int64]map[string][]string) permissions := make(map[string][]string) permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"} + permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"} allPermissions[1] = permissions // "Permissions" is where we store the uid of dashboards shared with the user req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions})) @@ -581,14 +600,19 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { // first call gets all dashboards user has permission for firstCall := mockClient.MockCalls[0] - assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"}) + assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"}) + // verify federated field is set to include folders + assert.NotNil(t, firstCall.Federated) + assert.Equal(t, 1, len(firstCall.Federated)) + assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group) + assert.Equal(t, "folders", firstCall.Federated[0].Resource) // second call gets folders associated with the previous dashboards secondCall := mockClient.MockCalls[1] assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"}) - // lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have + // lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have // permission to read thirdCall := mockClient.MockCalls[2] - assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"}) + assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"}) resp := rr.Result() defer func() { diff --git a/pkg/registry/apis/datasource/legacy_store.go b/pkg/registry/apis/datasource/legacy_store.go index e0526dd2409..c263129a157 100644 --- a/pkg/registry/apis/datasource/legacy_store.go +++ b/pkg/registry/apis/datasource/legacy_store.go @@ -57,6 +57,12 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec } func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { + if s.dsConfigHandlerRequestsDuration != nil { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.List"), time.Since(start).Seconds()) + }() + } return s.datasources.ListDataSources(ctx) } @@ -64,7 +70,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge if s.dsConfigHandlerRequestsDuration != nil { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Get"), time.Since(start).Seconds()) }() } @@ -76,7 +82,7 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa if s.dsConfigHandlerRequestsDuration != nil { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Create"), time.Since(start).Seconds()) }() } @@ -92,7 +98,7 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up if s.dsConfigHandlerRequestsDuration != nil { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Update"), time.Since(start).Seconds()) }() } @@ -135,7 +141,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio if s.dsConfigHandlerRequestsDuration != nil { start := time.Now() defer func() { - metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds()) + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Delete"), time.Since(start).Seconds()) }() } @@ -145,6 +151,13 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio // DeleteCollection implements rest.CollectionDeleter. func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + if s.dsConfigHandlerRequestsDuration != nil { + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.DeleteCollection"), time.Since(start).Seconds()) + }() + } + dss, err := s.datasources.ListDataSources(ctx) if err != nil { return nil, err diff --git a/pkg/registry/apis/datasource/plugincontext.go b/pkg/registry/apis/datasource/plugincontext.go index a0525030dcb..27479f60675 100644 --- a/pkg/registry/apis/datasource/plugincontext.go +++ b/pkg/registry/apis/datasource/plugincontext.go @@ -71,7 +71,6 @@ type cachingDatasourceProvider struct { } func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSONData) PluginDatasourceProvider { - group, _ := plugins.GetDatasourceGroupNameFromPluginID(pluginJson.ID) return &scopedDatasourceProvider{ plugin: pluginJson, dsService: q.dsService, @@ -81,7 +80,7 @@ func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSO mapper: q.converter.mapper, plugin: pluginJson.ID, alias: pluginJson.AliasIDs, - group: group, + group: pluginJson.ID, }, } } diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index 4b6f27da5c0..e23a3c1ab2a 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -21,6 +21,7 @@ import ( datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1" queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/sources" @@ -36,6 +37,11 @@ var ( _ builder.APIGroupBuilder = (*DataSourceAPIBuilder)(nil) ) +type DataSourceAPIBuilderConfig struct { + LoadQueryTypes bool + UseDualWriter bool +} + // DataSourceAPIBuilder is used just so wire has something unique to return type DataSourceAPIBuilder struct { datasourceResourceInfo utils.ResourceInfo @@ -45,7 +51,7 @@ type DataSourceAPIBuilder struct { contextProvider PluginContextWrapper accessControl accesscontrol.AccessControl queryTypes *queryV0.QueryTypeDefinitionList - configCrudUseNewApis bool + cfg DataSourceAPIBuilderConfig dataSourceCRUDMetric *prometheus.HistogramVec } @@ -69,10 +75,10 @@ func RegisterAPIService( dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{ Namespace: "grafana", - Name: "ds_config_handler_requests_duration_seconds", - Help: "Duration of requests handled by datasource configuration handlers", - }, []string{"code_path", "handler"}) - regErr := reg.Register(dataSourceCRUDMetric) + Name: "ds_config_handler_apis_requests_duration_seconds", + Help: "Duration of requests handled by new k8s style APIs datasource configuration handlers", + }, []string{"handler"}) + regErr := metrics.ProvideRegisterer().Register(dataSourceCRUDMetric) if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) { return nil, regErr } @@ -88,20 +94,24 @@ func RegisterAPIService( return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient) } + groupName := pluginJSON.ID + ".datasource.grafana.app" builder, err = NewDataSourceAPIBuilder( + groupName, pluginJSON, client, datasources.GetDatasourceProvider(pluginJSON), contextProvider, accessControl, - //nolint:staticcheck // not yet migrated to OpenFeature - features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes), - //nolint:staticcheck // not yet migrated to OpenFeature - features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections), + DataSourceAPIBuilderConfig{ + //nolint:staticcheck // not yet migrated to OpenFeature + LoadQueryTypes: features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes), + UseDualWriter: false, + }, ) if err != nil { return nil, err } + builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric) apiRegistrar.RegisterAPI(builder) @@ -119,31 +129,27 @@ type PluginClient interface { } func NewDataSourceAPIBuilder( + groupName string, plugin plugins.JSONData, client PluginClient, datasources PluginDatasourceProvider, contextProvider PluginContextWrapper, accessControl accesscontrol.AccessControl, - loadQueryTypes bool, - configCrudUseNewApis bool, + cfg DataSourceAPIBuilderConfig, ) (*DataSourceAPIBuilder, error) { - group, err := plugins.GetDatasourceGroupNameFromPluginID(plugin.ID) - if err != nil { - return nil, err - } - builder := &DataSourceAPIBuilder{ - datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(group, plugin.ID), + datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(groupName, plugin.ID), pluginJSON: plugin, client: client, datasources: datasources, contextProvider: contextProvider, accessControl: accessControl, - configCrudUseNewApis: configCrudUseNewApis, + cfg: cfg, } - if loadQueryTypes { + var err error + if cfg.LoadQueryTypes { // In the future, this will somehow come from the plugin - builder.queryTypes, err = getHardcodedQueryTypes(group) + builder.queryTypes, err = getHardcodedQueryTypes(groupName) } return builder, err } @@ -153,9 +159,9 @@ func getHardcodedQueryTypes(group string) (*queryV0.QueryTypeDefinitionList, err var err error var raw json.RawMessage switch group { - case "testdata.datasource.grafana.app": + case "testdata.datasource.grafana.app", "grafana-testdata-datasource": raw, err = kinds.QueryTypeDefinitionListJSON() - case "prometheus.datasource.grafana.app": + case "prometheus.datasource.grafana.app", "prometheus": raw, err = models.QueryTypeDefinitionListJSON() } if err != nil { @@ -232,7 +238,7 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver storage["connections"] = &noopREST{} // hidden from openapi storage["connections/query"] = storage[ds.StoragePath("query")] // deprecated in openapi - if b.configCrudUseNewApis { + if b.cfg.UseDualWriter { legacyStore := &legacyStorage{ datasources: b.datasources, resourceInfo: &ds, diff --git a/pkg/registry/apis/iam/authorizer.go b/pkg/registry/apis/iam/authorizer.go index 8abc67bf885..efcf6fa5b39 100644 --- a/pkg/registry/apis/iam/authorizer.go +++ b/pkg/registry/apis/iam/authorizer.go @@ -53,7 +53,7 @@ func newIAMAuthorizer( resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer - resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer + resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = allowAuthorizer resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer resourceAuthorizer["searchUsers"] = serviceAuthorizer resourceAuthorizer["searchTeams"] = serviceAuthorizer diff --git a/pkg/registry/apis/iam/authorizer/external_group_mapping.go b/pkg/registry/apis/iam/authorizer/external_group_mapping.go new file mode 100644 index 00000000000..0537acbe81d --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/external_group_mapping.go @@ -0,0 +1,150 @@ +package authorizer + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + "k8s.io/apimachinery/pkg/runtime" + + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer/storewrapper" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +type ExternalGroupMappingAuthorizer struct { + accessClient types.AccessClient +} + +var _ storewrapper.ResourceStorageAuthorizer = (*ExternalGroupMappingAuthorizer)(nil) + +func NewExternalGroupMappingAuthorizer( + accessClient types.AccessClient, +) *ExternalGroupMappingAuthorizer { + return &ExternalGroupMappingAuthorizer{ + accessClient: accessClient, + } +} + +// AfterGet implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) AfterGet(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + + concreteObj, ok := obj.(*iamv0.ExternalGroupMapping) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMapping, got %T: %w", obj, storewrapper.ErrUnexpectedType)) + } + + teamName := concreteObj.Spec.TeamRef.Name + checkReq := types.CheckRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbGetPermissions, + Name: teamName, + } + res, err := r.accessClient.Check(ctx, authInfo, checkReq, "") + if err != nil { + return apierrors.NewInternalError(err) + } + + if !res.Allowed { + return apierrors.NewForbidden( + iamv0.ExternalGroupMappingResourceInfo.GroupResource(), + concreteObj.Name, + fmt.Errorf("user cannot access team %s", teamName), + ) + } + return nil +} + +// BeforeCreate implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeCreate(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeDelete implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeDelete(ctx context.Context, obj runtime.Object) error { + return r.beforeWrite(ctx, obj) +} + +// BeforeUpdate implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) BeforeUpdate(ctx context.Context, obj runtime.Object) error { + // Update is not supported for ExternalGroupMapping resources and update attempts are blocked at a lower level, + // so this is just a safeguard. + return apierrors.NewMethodNotSupported(iamv0.ExternalGroupMappingResourceInfo.GroupResource(), "PUT/PATCH") +} + +func (r *ExternalGroupMappingAuthorizer) beforeWrite(ctx context.Context, obj runtime.Object) error { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return storewrapper.ErrUnauthenticated + } + + concreteObj, ok := obj.(*iamv0.ExternalGroupMapping) + if !ok { + return apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMapping, got %T: %w", obj, storewrapper.ErrUnexpectedType)) + } + + teamName := concreteObj.Spec.TeamRef.Name + checkReq := types.CheckRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbSetPermissions, + Name: teamName, + } + + res, err := r.accessClient.Check(ctx, authInfo, checkReq, "") + if err != nil { + return apierrors.NewInternalError(err) + } + + if !res.Allowed { + return apierrors.NewForbidden( + iamv0.ExternalGroupMappingResourceInfo.GroupResource(), + concreteObj.Name, + fmt.Errorf("user cannot write team %s", teamName), + ) + } + return nil +} + +// FilterList implements ResourceStorageAuthorizer. +func (r *ExternalGroupMappingAuthorizer) FilterList(ctx context.Context, list runtime.Object) (runtime.Object, error) { + authInfo, ok := types.AuthInfoFrom(ctx) + if !ok { + return nil, storewrapper.ErrUnauthenticated + } + + l, ok := list.(*iamv0.ExternalGroupMappingList) + if !ok { + return nil, apierrors.NewInternalError(fmt.Errorf("expected ExternalGroupMappingList, got %T: %w", list, storewrapper.ErrUnexpectedType)) + } + + var filteredItems []iamv0.ExternalGroupMapping + + listReq := types.ListRequest{ + Namespace: authInfo.GetNamespace(), + Group: iamv0.GROUP, + Resource: iamv0.TeamResourceInfo.GetName(), + Verb: utils.VerbGetPermissions, + } + canView, _, err := r.accessClient.Compile(ctx, authInfo, listReq) + if err != nil { + return nil, apierrors.NewInternalError(err) + } + + for _, item := range l.Items { + if canView(item.Spec.TeamRef.Name, "") { + filteredItems = append(filteredItems, item) + } + } + + l.Items = filteredItems + return l, nil +} diff --git a/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go b/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go new file mode 100644 index 00000000000..69ee7e5fed9 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/external_group_mapping_test.go @@ -0,0 +1,229 @@ +package authorizer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/authlib/types" + iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func newExternalGroupMapping(teamName, name string) *iamv0.ExternalGroupMapping { + return &iamv0.ExternalGroupMapping{ + ObjectMeta: metav1.ObjectMeta{Namespace: "org-2", Name: name}, + Spec: iamv0.ExternalGroupMappingSpec{ + TeamRef: iamv0.ExternalGroupMappingTeamRef{ + Name: teamName, + }, + }, + } +} + +func TestExternalGroupMapping_AfterGet(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow access", + shouldAllow: true, + }, + { + name: "deny access", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.AfterGet(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} + +func TestExternalGroupMapping_FilterList(t *testing.T) { + list := &iamv0.ExternalGroupMappingList{ + Items: []iamv0.ExternalGroupMapping{ + *newExternalGroupMapping("team-1", "mapping-1"), + *newExternalGroupMapping("team-2", "mapping-2"), + }, + ListMeta: metav1.ListMeta{ + SelfLink: "/apis/iam.grafana.app/v0alpha1/namespaces/org-2/externalgroupmappings", + }, + } + + compileFunc := func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, utils.VerbGetPermissions, req.Verb) + + return func(name, folder string) bool { + return name == "team-1" + }, &types.NoopZookie{}, nil + } + + accessClient := &fakeAccessClient{compileFunc: compileFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + obj, err := authz.FilterList(ctx, list) + require.NoError(t, err) + require.NotNil(t, list) + require.True(t, accessClient.compileCalled) + + filtered, ok := obj.(*iamv0.ExternalGroupMappingList) + require.True(t, ok) + require.Len(t, filtered.Items, 1) + require.Equal(t, "mapping-1", filtered.Items[0].Name) +} + +func TestExternalGroupMapping_BeforeCreate(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow create", + shouldAllow: true, + }, + { + name: "deny create", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeCreate(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} + +func TestExternalGroupMapping_BeforeUpdate(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + accessClient := &fakeAccessClient{ + checkFunc: func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.Fail(t, "check should not be called") + return types.CheckResponse{}, nil + }, + } + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeUpdate(ctx, mapping) + require.Error(t, err) + require.True(t, apierrors.IsMethodNotSupported(err)) + require.Contains(t, err.Error(), "PUT/PATCH") + require.False(t, accessClient.checkCalled) +} + +func TestExternalGroupMapping_BeforeDelete(t *testing.T) { + mapping := newExternalGroupMapping("team-1", "mapping-1") + + tests := []struct { + name string + shouldAllow bool + }{ + { + name: "allow delete", + shouldAllow: true, + }, + { + name: "deny delete", + shouldAllow: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkFunc := func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) { + require.NotNil(t, id) + require.Equal(t, "user:u001", id.GetUID()) + require.Equal(t, "org-2", id.GetNamespace()) + + require.Equal(t, "org-2", req.Namespace) + require.Equal(t, iamv0.GROUP, req.Group) + require.Equal(t, iamv0.TeamResourceInfo.GetName(), req.Resource) + require.Equal(t, "team-1", req.Name) + require.Equal(t, utils.VerbSetPermissions, req.Verb) + require.Equal(t, "", folder) + + return types.CheckResponse{Allowed: tt.shouldAllow}, nil + } + + accessClient := &fakeAccessClient{checkFunc: checkFunc} + authz := NewExternalGroupMappingAuthorizer(accessClient) + ctx := types.WithAuthInfo(context.Background(), user) + + err := authz.BeforeDelete(ctx, mapping) + if tt.shouldAllow { + require.NoError(t, err) + } else { + require.Error(t, err) + } + require.True(t, accessClient.checkCalled) + }) + } +} diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions.go b/pkg/registry/apis/iam/authorizer/resource_permissions.go index 0fbf413adac..098037c93b7 100644 --- a/pkg/registry/apis/iam/authorizer/resource_permissions.go +++ b/pkg/registry/apis/iam/authorizer/resource_permissions.go @@ -179,19 +179,17 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run canViewFuncs = map[schema.GroupResource]types.ItemChecker{} ) for _, item := range l.Items { - gr := schema.GroupResource{ - Group: item.Spec.Resource.ApiGroup, - Resource: item.Spec.Resource.Resource, - } + target := item.Spec.Resource + targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource} // Reuse the same canView for items with the same resource - canView, found := canViewFuncs[gr] + canView, found := canViewFuncs[targetGR] if !found { listReq := types.ListRequest{ Namespace: item.Namespace, - Group: item.Spec.Resource.ApiGroup, - Resource: item.Spec.Resource.Resource, + Group: target.ApiGroup, + Resource: target.Resource, Verb: utils.VerbGetPermissions, } @@ -200,12 +198,9 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run return nil, err } - canViewFuncs[gr] = canView + canViewFuncs[targetGR] = canView } - target := item.Spec.Resource - targetGR := schema.GroupResource{Group: target.ApiGroup, Resource: target.Resource} - parent := "" // Fetch the parent of the resource // It's not efficient to do for every item in the list, but it's a good starting point. diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions_test.go b/pkg/registry/apis/iam/authorizer/resource_permissions_test.go index df777ee31e9..9e12bd87ebf 100644 --- a/pkg/registry/apis/iam/authorizer/resource_permissions_test.go +++ b/pkg/registry/apis/iam/authorizer/resource_permissions_test.go @@ -4,35 +4,15 @@ import ( "context" "testing" - "github.com/go-jose/go-jose/v4/jwt" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" ) -var ( - user = authn.NewIDTokenAuthInfo( - authn.Claims[authn.AccessTokenClaims]{ - Claims: jwt.Claims{Issuer: "grafana", - Subject: types.NewTypeID(types.TypeAccessPolicy, "grafana"), Audience: []string{"iam.grafana.app"}}, - Rest: authn.AccessTokenClaims{ - Namespace: "*", - Permissions: identity.ServiceIdentityClaims.Rest.Permissions, - DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, - }, - }, &authn.Claims[authn.IDTokenClaims]{ - Claims: jwt.Claims{Subject: types.NewTypeID(types.TypeUser, "u001")}, - Rest: authn.IDTokenClaims{Namespace: "org-2", Identifier: "u001", Type: types.TypeUser}, - }, - ) -) - func newResourcePermission(apiGroup, resource, name string) *iamv0.ResourcePermission { return &iamv0.ResourcePermission{ ObjectMeta: metav1.ObjectMeta{Namespace: "org-2"}, @@ -222,26 +202,6 @@ func TestResourcePermissions_beforeWrite(t *testing.T) { } } -// fakeAccessClient is a mock implementation of claims.AccessClient -type fakeAccessClient struct { - checkCalled bool - checkFunc func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) - compileCalled bool - compileFunc func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) -} - -func (m *fakeAccessClient) Check(ctx context.Context, id types.AuthInfo, req types.CheckRequest, folder string) (types.CheckResponse, error) { - m.checkCalled = true - return m.checkFunc(id, &req, folder) -} - -func (m *fakeAccessClient) Compile(ctx context.Context, id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { - m.compileCalled = true - return m.compileFunc(id, req) -} - -var _ types.AccessClient = (*fakeAccessClient)(nil) - type fakeParentProvider struct { hasParent bool getParentCalled bool diff --git a/pkg/registry/apis/iam/authorizer/testutil.go b/pkg/registry/apis/iam/authorizer/testutil.go new file mode 100644 index 00000000000..37e40596583 --- /dev/null +++ b/pkg/registry/apis/iam/authorizer/testutil.go @@ -0,0 +1,48 @@ +package authorizer + +import ( + "context" + + "github.com/go-jose/go-jose/v4/jwt" + "github.com/grafana/authlib/authn" + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +var ( + // Shared test user identity + user = authn.NewIDTokenAuthInfo( + authn.Claims[authn.AccessTokenClaims]{ + Claims: jwt.Claims{Issuer: "grafana", + Subject: types.NewTypeID(types.TypeAccessPolicy, "grafana"), Audience: []string{"iam.grafana.app"}}, + Rest: authn.AccessTokenClaims{ + Namespace: "*", + Permissions: identity.ServiceIdentityClaims.Rest.Permissions, + DelegatedPermissions: identity.ServiceIdentityClaims.Rest.DelegatedPermissions, + }, + }, &authn.Claims[authn.IDTokenClaims]{ + Claims: jwt.Claims{Subject: types.NewTypeID(types.TypeUser, "u001")}, + Rest: authn.IDTokenClaims{Namespace: "org-2", Identifier: "u001", Type: types.TypeUser}, + }, + ) +) + +var _ types.AccessClient = (*fakeAccessClient)(nil) + +// fakeAccessClient is a mock implementation of claims.AccessClient +type fakeAccessClient struct { + checkCalled bool + checkFunc func(id types.AuthInfo, req *types.CheckRequest, folder string) (types.CheckResponse, error) + compileCalled bool + compileFunc func(id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) +} + +func (m *fakeAccessClient) Check(ctx context.Context, id types.AuthInfo, req types.CheckRequest, folder string) (types.CheckResponse, error) { + m.checkCalled = true + return m.checkFunc(id, &req, folder) +} + +func (m *fakeAccessClient) Compile(ctx context.Context, id types.AuthInfo, req types.ListRequest) (types.ItemChecker, types.Zookie, error) { + m.compileCalled = true + return m.compileFunc(id, req) +} diff --git a/pkg/registry/apis/iam/common/common.go b/pkg/registry/apis/iam/common/common.go index b508084bcae..a5409ce5ac4 100644 --- a/pkg/registry/apis/iam/common/common.go +++ b/pkg/registry/apis/iam/common/common.go @@ -12,6 +12,7 @@ import ( legacyiamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/team" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // OptonalFormatInt formats num as a string. If num is less or equal than 0 @@ -39,23 +40,17 @@ func MapUserTeamPermission(p team.PermissionType) legacyiamv0.TeamPermission { } } -// Resource is required to be implemented for list return types so we can -// perform authorization. -type Resource interface { - AuthID() string -} - -type ListResponse[T Resource] struct { +type ListResponse[T metav1.Object] struct { Items []T RV int64 Continue int64 } -type ListFunc[T Resource] func(ctx context.Context, ns authlib.NamespaceInfo, p Pagination) (*ListResponse[T], error) +type ListFunc[T metav1.Object] func(ctx context.Context, ns authlib.NamespaceInfo, p Pagination) (*ListResponse[T], error) // List is a helper function that will perform access check on resources if // prvovided with a authlib.AccessClient. -func List[T Resource]( +func List[T metav1.Object]( ctx context.Context, resource utils.ResourceInfo, ac authlib.AccessClient, @@ -78,7 +73,7 @@ func List[T Resource]( check, _, err = ac.Compile(ctx, ident, authlib.ListRequest{ Resource: resource.GroupResource().Resource, Group: resource.GroupResource().Group, - Verb: "list", + Verb: utils.VerbList, Namespace: ns.Value, }) @@ -95,7 +90,7 @@ func List[T Resource]( } for _, item := range first.Items { - if !check(item.AuthID(), "") { + if !check(item.GetName(), "") { continue } res.Items = append(res.Items, item) @@ -118,7 +113,7 @@ outer: break outer } - if !check(item.AuthID(), "") { + if !check(item.GetName(), "") { continue } diff --git a/pkg/registry/apis/iam/common/common_test.go b/pkg/registry/apis/iam/common/common_test.go index d835c238b8c..ea079043fd1 100644 --- a/pkg/registry/apis/iam/common/common_test.go +++ b/pkg/registry/apis/iam/common/common_test.go @@ -5,6 +5,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/endpoints/request" authlib "github.com/grafana/authlib/types" @@ -15,14 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" ) -type item struct { - id string -} - -func (i item) AuthID() string { - return i.id -} - func TestList(t *testing.T) { ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures()) @@ -83,8 +77,8 @@ func TestList(t *testing.T) { assert.NoError(t, err) assert.Len(t, res.Items, 2) - assert.Equal(t, "1", res.Items[0].AuthID()) - assert.Equal(t, "3", res.Items[1].AuthID()) + assert.Equal(t, "1", res.Items[0].GetName()) + assert.Equal(t, "3", res.Items[1].GetName()) }) } @@ -103,3 +97,158 @@ func newIdent(permissions ...accesscontrol.Permission) *identity.StaticRequester Permissions: map[int64]map[string][]string{1: pmap}, } } + +var _ metav1.Object = (*item)(nil) + +type item struct { + id string +} + +// GetAnnotations implements v1.Object. +func (i item) GetAnnotations() map[string]string { + panic("unimplemented") +} + +// GetCreationTimestamp implements v1.Object. +func (i item) GetCreationTimestamp() metav1.Time { + panic("unimplemented") +} + +// GetDeletionGracePeriodSeconds implements v1.Object. +func (i item) GetDeletionGracePeriodSeconds() *int64 { + panic("unimplemented") +} + +// GetDeletionTimestamp implements v1.Object. +func (i item) GetDeletionTimestamp() *metav1.Time { + panic("unimplemented") +} + +// GetFinalizers implements v1.Object. +func (i item) GetFinalizers() []string { + panic("unimplemented") +} + +// GetGenerateName implements v1.Object. +func (i item) GetGenerateName() string { + panic("unimplemented") +} + +// GetGeneration implements v1.Object. +func (i item) GetGeneration() int64 { + panic("unimplemented") +} + +// GetLabels implements v1.Object. +func (i item) GetLabels() map[string]string { + panic("unimplemented") +} + +// GetManagedFields implements v1.Object. +func (i item) GetManagedFields() []metav1.ManagedFieldsEntry { + panic("unimplemented") +} + +// GetNamespace implements v1.Object. +func (i item) GetNamespace() string { + panic("unimplemented") +} + +// GetOwnerReferences implements v1.Object. +func (i item) GetOwnerReferences() []metav1.OwnerReference { + panic("unimplemented") +} + +// GetResourceVersion implements v1.Object. +func (i item) GetResourceVersion() string { + panic("unimplemented") +} + +// GetSelfLink implements v1.Object. +func (i item) GetSelfLink() string { + panic("unimplemented") +} + +// GetUID implements v1.Object. +func (i item) GetUID() types.UID { + panic("unimplemented") +} + +// SetAnnotations implements v1.Object. +func (i item) SetAnnotations(annotations map[string]string) { + panic("unimplemented") +} + +// SetCreationTimestamp implements v1.Object. +func (i item) SetCreationTimestamp(timestamp metav1.Time) { + panic("unimplemented") +} + +// SetDeletionGracePeriodSeconds implements v1.Object. +func (i item) SetDeletionGracePeriodSeconds(*int64) { + panic("unimplemented") +} + +// SetDeletionTimestamp implements v1.Object. +func (i item) SetDeletionTimestamp(timestamp *metav1.Time) { + panic("unimplemented") +} + +// SetFinalizers implements v1.Object. +func (i item) SetFinalizers(finalizers []string) { + panic("unimplemented") +} + +// SetGenerateName implements v1.Object. +func (i item) SetGenerateName(name string) { + panic("unimplemented") +} + +// SetGeneration implements v1.Object. +func (i item) SetGeneration(generation int64) { + panic("unimplemented") +} + +// SetLabels implements v1.Object. +func (i item) SetLabels(labels map[string]string) { + panic("unimplemented") +} + +// SetManagedFields implements v1.Object. +func (i item) SetManagedFields(managedFields []metav1.ManagedFieldsEntry) { + panic("unimplemented") +} + +// SetName implements v1.Object. +func (i item) SetName(name string) { + panic("unimplemented") +} + +// SetNamespace implements v1.Object. +func (i item) SetNamespace(namespace string) { + panic("unimplemented") +} + +// SetOwnerReferences implements v1.Object. +func (i item) SetOwnerReferences([]metav1.OwnerReference) { + panic("unimplemented") +} + +// SetResourceVersion implements v1.Object. +func (i item) SetResourceVersion(version string) { + panic("unimplemented") +} + +// SetSelfLink implements v1.Object. +func (i item) SetSelfLink(selfLink string) { + panic("unimplemented") +} + +// SetUID implements v1.Object. +func (i item) SetUID(uid types.UID) { + panic("unimplemented") +} + +func (i item) GetName() string { + return i.id +} diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 8a3dcb9586b..7f42d620987 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -5,7 +5,9 @@ import ( "fmt" "maps" "strings" + "time" + "github.com/open-feature/go-sdk/openfeature" "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -102,7 +104,7 @@ func RegisterAPIService( store: store, userLegacyStore: user.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), saLegacyStore: serviceaccount.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), - legacyTeamStore: team.NewLegacyStore(store, legacyAccessClient, enableAuthnMutation, tracing), + legacyTeamStore: team.NewLegacyStore(store, accessClient, enableAuthnMutation, tracing), teamBindingLegacyStore: teambinding.NewLegacyBindingStore(store, enableAuthnMutation, tracing), ssoLegacyStore: sso.NewLegacyStore(ssoService, tracing), coreRolesStorage: coreRolesStorage, @@ -209,8 +211,16 @@ func (b *IdentityAccessManagementAPIBuilder) GetGroupVersion() schema.GroupVersi } func (b *IdentityAccessManagementAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) { + client := openfeature.NewDefaultClient() + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + + // Check if any of the AuthZ APIs are enabled + enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx)) + enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx)) + enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx)) + + if enableCoreRolesApi || enableRolesApi || enableRoleBindingsApi { if err := iamv0.AddAuthZKnownTypes(scheme); err != nil { return err } @@ -244,9 +254,17 @@ func (b *IdentityAccessManagementAPIBuilder) AllowedV0Alpha1Resources() []string func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storage := map[string]rest.Storage{} + client := openfeature.NewDefaultClient() + ctx, cancelFn := context.WithTimeout(context.Background(), time.Second*5) + defer cancelFn() + //nolint:staticcheck // not yet migrated to OpenFeature enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync) + enableCoreRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzCoreRolesApi, false, openfeature.TransactionContext(ctx)) + enableRolesApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRolesApi, false, openfeature.TransactionContext(ctx)) + enableRoleBindingsApi := client.Boolean(ctx, featuremgmt.FlagKubernetesAuthzRoleBindingsApi, false, openfeature.TransactionContext(ctx)) + // teams + users must have shorter names because they are often used as part of another name opts.StorageOptsRegister(iamv0.TeamResourceInfo.GroupResource(), apistore.StorageOptions{ MaximumNameLength: 80, @@ -255,6 +273,64 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge MaximumNameLength: 80, }) + if err := b.UpdateTeamsAPIGroup(opts, storage); err != nil { + return err + } + + if err := b.UpdateTeamBindingsAPIGroup(opts, storage, enableZanzanaSync); err != nil { + return err + } + + if err := b.UpdateUsersAPIGroup(opts, storage, enableZanzanaSync); err != nil { + return err + } + + if err := b.UpdateServiceAccountsAPIGroup(opts, storage); err != nil { + return err + } + + // SSO settings apis + if b.ssoLegacyStore != nil { + ssoResource := legacyiamv0.SSOSettingResourceInfo + storage[ssoResource.StoragePath()] = b.ssoLegacyStore + } + + if err := b.UpdateExternalGroupMappingAPIGroup(apiGroupInfo, opts, storage); err != nil { + return err + } + + if enableCoreRolesApi { + // v0alpha1 + if err := b.UpdateCoreRolesAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + } + + if enableRolesApi { + // Role registration is delegated to the RoleApiInstaller + if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { + return err + } + } + + if enableRoleBindingsApi { + if err := b.UpdateRoleBindingsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + } + + //nolint:staticcheck // not yet migrated to OpenFeature + if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { + if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { + return err + } + } + + apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateTeamsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage) error { teamResource := iamv0.TeamResourceInfo teamUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamResource, opts.OptsGetter) if err != nil { @@ -271,11 +347,15 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[teamResource.StoragePath()] = dw } - storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store) + storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store, b.accessClient) if b.teamGroupsHandler != nil { storage[teamResource.StoragePath("groups")] = b.teamGroupsHandler } + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateTeamBindingsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage, enableZanzanaSync bool) error { teamBindingResource := iamv0.TeamBindingResourceInfo teamBindingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamBindingResource, opts.OptsGetter) if err != nil { @@ -298,8 +378,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge } storage[teamBindingResource.StoragePath()] = dw } + return nil +} - // User store registration +func (b *IdentityAccessManagementAPIBuilder) UpdateUsersAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage, enableZanzanaSync bool) error { userResource := iamv0.UserResourceInfo userUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, userResource, opts.OptsGetter) if err != nil { @@ -325,7 +407,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store) - // Service Accounts store registration + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateServiceAccountsAPIGroup(opts builder.APIGroupOptions, storage map[string]rest.Storage) error { saResource := iamv0.ServiceAccountResourceInfo saUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, saResource, opts.OptsGetter) if err != nil { @@ -343,17 +428,17 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[saResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store) - if b.ssoLegacyStore != nil { - ssoResource := legacyiamv0.SSOSettingResourceInfo - storage[ssoResource.StoragePath()] = b.ssoLegacyStore - } + return nil +} +func (b *IdentityAccessManagementAPIBuilder) UpdateExternalGroupMappingAPIGroup(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions, storage map[string]rest.Storage) error { extGroupMappingResource := iamv0.ExternalGroupMappingResourceInfo extGroupMappingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, extGroupMappingResource, opts.OptsGetter) if err != nil { return err } - storage[extGroupMappingResource.StoragePath()] = extGroupMappingUniStore + + var extGroupMappingStore storewrapper.K8sStorage = extGroupMappingUniStore if b.externalGroupMappingStorage != nil { extGroupMappingLegacyStore, err := NewLocalStore(extGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage) @@ -365,50 +450,57 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge if err != nil { return err } - storage[extGroupMappingResource.StoragePath()] = dw - } - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) { - // v0alpha1 - coreRoleStore, err := NewLocalStore(iamv0.CoreRoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.coreRolesStorage) - if err != nil { - return err - } - if enableZanzanaSync { - b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana") - h := NewRoleHooks(b.zClient, b.zTickets, b.logger) - coreRoleStore.AfterCreate = h.AfterRoleCreate - coreRoleStore.AfterDelete = h.AfterRoleDelete - coreRoleStore.BeginUpdate = h.BeginRoleUpdate - } - storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore - - // Role registration is delegated to the RoleApiInstaller - if err := b.roleApiInstaller.RegisterStorage(apiGroupInfo, &opts, storage); err != nil { - return err - } - - roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage) - if err != nil { - return err - } - if enableZanzanaSync { - b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") - roleBindingStore.AfterCreate = b.AfterRoleBindingCreate - roleBindingStore.AfterDelete = b.AfterRoleBindingDelete - roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate - } - storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore - } - //nolint:staticcheck // not yet migrated to OpenFeature - if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { - if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { - return err + var ok bool + extGroupMappingStore, ok = dw.(storewrapper.K8sStorage) + if !ok { + return fmt.Errorf("expected storewrapper.K8sStorage, got %T", dw) } } - apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage + authzWrapper := storewrapper.New(extGroupMappingStore, iamauthorizer.NewExternalGroupMappingAuthorizer(b.accessClient)) + storage[extGroupMappingResource.StoragePath()] = authzWrapper + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateCoreRolesAPIGroup( + apiGroupInfo *genericapiserver.APIGroupInfo, + opts builder.APIGroupOptions, + storage map[string]rest.Storage, + enableZanzanaSync bool, +) error { + coreRoleStore, err := NewLocalStore(iamv0.CoreRoleInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.coreRolesStorage) + if err != nil { + return err + } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana") + h := NewRoleHooks(b.zClient, b.zTickets, b.logger) + coreRoleStore.AfterCreate = h.AfterRoleCreate + coreRoleStore.AfterDelete = h.AfterRoleDelete + coreRoleStore.BeginUpdate = h.BeginRoleUpdate + } + storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore + return nil +} + +func (b *IdentityAccessManagementAPIBuilder) UpdateRoleBindingsAPIGroup( + apiGroupInfo *genericapiserver.APIGroupInfo, + opts builder.APIGroupOptions, + storage map[string]rest.Storage, + enableZanzanaSync bool, +) error { + roleBindingStore, err := NewLocalStore(iamv0.RoleBindingInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.roleBindingsStorage) + if err != nil { + return err + } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana") + roleBindingStore.AfterCreate = b.AfterRoleBindingCreate + roleBindingStore.AfterDelete = b.AfterRoleBindingDelete + roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate + } + storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore return nil } diff --git a/pkg/registry/apis/iam/serviceaccount/store.go b/pkg/registry/apis/iam/serviceaccount/store.go index db4709a4e2e..6d1ac774174 100644 --- a/pkg/registry/apis/iam/serviceaccount/store.go +++ b/pkg/registry/apis/iam/serviceaccount/store.go @@ -178,7 +178,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, resource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.ServiceAccount], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.ServiceAccount], error) { found, err := s.store.ListServiceAccounts(ctx, ns, legacy.ListServiceAccountsQuery{ Pagination: p, }) @@ -187,12 +187,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - items := make([]iamv0alpha1.ServiceAccount, 0, len(found.Items)) + items := make([]*iamv0alpha1.ServiceAccount, 0, len(found.Items)) for _, sa := range found.Items { - items = append(items, s.toSAItem(sa, ns.Value)) + saItem := s.toSAItem(sa, ns.Value) + items = append(items, &saItem) } - return &common.ListResponse[iamv0alpha1.ServiceAccount]{ + return &common.ListResponse[*iamv0alpha1.ServiceAccount]{ Items: items, RV: found.RV, Continue: found.Continue, @@ -204,7 +205,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - obj := &iamv0alpha1.ServiceAccountList{Items: res.Items} + items := make([]iamv0alpha1.ServiceAccount, len(res.Items)) + for i, sa := range res.Items { + items[i] = *sa + } + + obj := &iamv0alpha1.ServiceAccountList{Items: items} obj.Continue = common.OptionalFormatInt(res.Continue) obj.ResourceVersion = common.OptionalFormatInt(res.RV) return obj, nil diff --git a/pkg/registry/apis/iam/team/rest_members.go b/pkg/registry/apis/iam/team/rest_members.go index 6659823b9fa..586b074d710 100644 --- a/pkg/registry/apis/iam/team/rest_members.go +++ b/pkg/registry/apis/iam/team/rest_members.go @@ -2,14 +2,20 @@ package team import ( "context" + "fmt" "net/http" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" claims "github.com/grafana/authlib/types" + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/apimachinery/utils" iamv0 "github.com/grafana/grafana/pkg/apis/iam/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "github.com/grafana/grafana/pkg/registry/apis/iam/common" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -23,12 +29,13 @@ var ( _ rest.Connecter = (*LegacyTeamMemberREST)(nil) ) -func NewLegacyTeamMemberREST(store legacy.LegacyIdentityStore) *LegacyTeamMemberREST { - return &LegacyTeamMemberREST{store} +func NewLegacyTeamMemberREST(store legacy.LegacyIdentityStore, ac claims.AccessClient) *LegacyTeamMemberREST { + return &LegacyTeamMemberREST{store: store, ac: ac} } type LegacyTeamMemberREST struct { store legacy.LegacyIdentityStore + ac claims.AccessClient } // New implements rest.Storage. @@ -62,6 +69,30 @@ func (s *LegacyTeamMemberREST) Connect(ctx context.Context, name string, options } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ident, err := identity.GetRequester(ctx) + if err != nil { + responder.Error(err) + return + } + + checkResp, err := s.ac.Check(ctx, ident, claims.CheckRequest{ + Group: iamv0alpha1.TeamResourceInfo.GroupResource().Group, + Resource: iamv0alpha1.TeamResourceInfo.GroupResource().Resource, + Name: name, + Namespace: ns.Value, + Verb: utils.VerbGetPermissions, + }, "") + + if err != nil { + responder.Error(err) + return + } + + if !checkResp.Allowed { + responder.Error(apierrors.NewForbidden(iamv0alpha1.TeamResourceInfo.GroupResource(), name, fmt.Errorf("permission denied"))) + return + } + res, err := s.store.ListTeamMembers(ctx, ns, legacy.ListTeamMembersQuery{ UID: name, Pagination: common.PaginationFromListQuery(r.URL.Query()), diff --git a/pkg/registry/apis/iam/team/store.go b/pkg/registry/apis/iam/team/store.go index c667c8ec374..834bba00335 100644 --- a/pkg/registry/apis/iam/team/store.go +++ b/pkg/registry/apis/iam/team/store.go @@ -174,7 +174,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, resource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.Team], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.Team], error) { found, err := s.store.ListTeams(ctx, ns, legacy.ListTeamQuery{ Pagination: p, }) @@ -183,12 +183,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - teams := make([]iamv0alpha1.Team, 0, len(found.Teams)) + teams := make([]*iamv0alpha1.Team, 0, len(found.Teams)) for _, t := range found.Teams { - teams = append(teams, toTeamObject(t, ns)) + team := toTeamObject(t, ns) + teams = append(teams, &team) } - return &common.ListResponse[iamv0alpha1.Team]{ + return &common.ListResponse[*iamv0alpha1.Team]{ Items: teams, RV: found.RV, Continue: found.Continue, @@ -200,7 +201,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, fmt.Errorf("failed to list teams: %w", err) } - list := &iamv0alpha1.TeamList{Items: res.Items} + items := make([]iamv0alpha1.Team, len(res.Items)) + for i, t := range res.Items { + items[i] = *t + } + + list := &iamv0alpha1.TeamList{Items: items} list.Continue = common.OptionalFormatInt(res.Continue) list.ResourceVersion = common.OptionalFormatInt(res.RV) diff --git a/pkg/registry/apis/iam/user/store.go b/pkg/registry/apis/iam/user/store.go index 53a146412ea..5cc9343111a 100644 --- a/pkg/registry/apis/iam/user/store.go +++ b/pkg/registry/apis/iam/user/store.go @@ -183,7 +183,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt res, err := common.List( ctx, userResource, s.ac, common.PaginationFromListOptions(options), - func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[iamv0alpha1.User], error) { + func(ctx context.Context, ns claims.NamespaceInfo, p common.Pagination) (*common.ListResponse[*iamv0alpha1.User], error) { found, err := s.store.ListUsers(ctx, ns, legacy.ListUserQuery{ Pagination: p, }) @@ -192,12 +192,13 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - users := make([]iamv0alpha1.User, 0, len(found.Items)) + users := make([]*iamv0alpha1.User, 0, len(found.Items)) for _, u := range found.Items { - users = append(users, toUserItem(&u, ns.Value)) + user := toUserItem(&u, ns.Value) + users = append(users, &user) } - return &common.ListResponse[iamv0alpha1.User]{ + return &common.ListResponse[*iamv0alpha1.User]{ Items: users, RV: found.RV, Continue: found.Continue, @@ -209,7 +210,12 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return nil, err } - obj := &iamv0alpha1.UserList{Items: res.Items} + items := make([]iamv0alpha1.User, len(res.Items)) + for i, u := range res.Items { + items[i] = *u + } + + obj := &iamv0alpha1.UserList{Items: items} obj.Continue = common.OptionalFormatInt(res.Continue) obj.ResourceVersion = common.OptionalFormatInt(res.RV) return obj, nil diff --git a/pkg/registry/apis/ofrep/register.go b/pkg/registry/apis/ofrep/register.go index d6a443cfee7..7101476a94b 100644 --- a/pkg/registry/apis/ofrep/register.go +++ b/pkg/registry/apis/ofrep/register.go @@ -276,7 +276,7 @@ func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) { return } - if b.providerType == setting.GOFFProviderType { + if b.providerType == setting.FeaturesServiceProviderType || b.providerType == setting.OFREPProviderType { b.proxyFlagReq(ctx, flagKey, isAuthedReq, w, r) return } @@ -304,7 +304,7 @@ func (b *APIBuilder) allFlagsHandler(w http.ResponseWriter, r *http.Request) { isAuthedReq := b.isAuthenticatedRequest(r) span.SetAttributes(attribute.Bool("authenticated", isAuthedReq)) - if b.providerType == setting.GOFFProviderType { + if b.providerType == setting.FeaturesServiceProviderType || b.providerType == setting.OFREPProviderType { b.proxyAllFlagReq(ctx, isAuthedReq, w, r) return } diff --git a/pkg/registry/apis/preferences/legacy/preferences.go b/pkg/registry/apis/preferences/legacy/preferences.go index ab7eb2d4891..6f0210fa694 100644 --- a/pkg/registry/apis/preferences/legacy/preferences.go +++ b/pkg/registry/apis/preferences/legacy/preferences.go @@ -208,6 +208,11 @@ func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runti // Create implements rest.Creater. func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + if createValidation != nil { + if err := createValidation(ctx, obj); err != nil { + return nil, err + } + } return s.save(ctx, obj) } @@ -223,6 +228,12 @@ func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo res return nil, false, err } + if updateValidation != nil { + if err := updateValidation(ctx, obj, old); err != nil { + return nil, false, err + } + } + obj, err = s.save(ctx, obj) return obj, false, err } diff --git a/pkg/registry/apis/preferences/register.go b/pkg/registry/apis/preferences/register.go index e0e6ff947fc..59e6a0b9c05 100644 --- a/pkg/registry/apis/preferences/register.go +++ b/pkg/registry/apis/preferences/register.go @@ -1,9 +1,14 @@ package preferences import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" @@ -24,7 +29,8 @@ import ( ) var ( - _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupValidation = (*APIBuilder)(nil) ) type APIBuilder struct { @@ -108,3 +114,31 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) return b.merger.GetAPIRoutes(defs) } + +// Validate validates that the preference object has valid theme and timezone (if specified) +func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error { + if a.GetResource().Resource != "preferences" { + return nil + } + + op := a.GetOperation() + if op != admission.Create && op != admission.Update { + return nil + } + + obj := a.GetObject() + p, ok := obj.(*preferences.Preferences) + if !ok { + return apierrors.NewBadRequest(fmt.Sprintf("expected Preferences object, got %T", obj)) + } + + if p.Spec.Timezone != nil && !pref.IsValidTimezone(*p.Spec.Timezone) { + return apierrors.NewBadRequest("invalid timezone: must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string") + } + + if p.Spec.Theme != nil && *p.Spec.Theme != "" && !pref.IsValidThemeID(*p.Spec.Theme) { + return apierrors.NewBadRequest("invalid theme") + } + + return nil +} diff --git a/pkg/registry/apis/provisioning/connection_repositories.go b/pkg/registry/apis/provisioning/connection_repositories.go new file mode 100644 index 00000000000..c2e908fc685 --- /dev/null +++ b/pkg/registry/apis/provisioning/connection_repositories.go @@ -0,0 +1,69 @@ +package provisioning + +import ( + "context" + "net/http" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +type connectionRepositoriesConnector struct{} + +func NewConnectionRepositoriesConnector() *connectionRepositoriesConnector { + return &connectionRepositoriesConnector{} +} + +func (*connectionRepositoriesConnector) New() runtime.Object { + return &provisioning.ExternalRepositoryList{} +} + +func (*connectionRepositoriesConnector) Destroy() {} + +func (*connectionRepositoriesConnector) ProducesMIMETypes(verb string) []string { + return []string{"application/json"} +} + +func (*connectionRepositoriesConnector) ProducesObject(verb string) any { + return &provisioning.ExternalRepositoryList{} +} + +func (*connectionRepositoriesConnector) ConnectMethods() []string { + return []string{http.MethodGet} +} + +func (*connectionRepositoriesConnector) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" +} + +func (c *connectionRepositoriesConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + logger := logging.FromContext(ctx).With("logger", "connection-repositories-connector", "connection_name", name) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + responder.Error(apierrors.NewMethodNotSupported(provisioning.ConnectionResourceInfo.GroupResource(), r.Method)) + return + } + + logger.Debug("repositories endpoint called but not yet implemented") + + // TODO: Implement repository listing from external git provider + // This will require: + // 1. Get the Connection object using logging.Context(r.Context(), logger) + // 2. Use the connection credentials to authenticate with the git provider + // 3. List repositories from the provider (GitHub, GitLab, Bitbucket) + // 4. Return ExternalRepositoryList with Name, Owner, and URL for each repository + + responder.Error(apierrors.NewMethodNotSupported(provisioning.ConnectionResourceInfo.GroupResource(), "repositories endpoint not yet implemented")) + }), nil +} + +var ( + _ rest.Storage = (*connectionRepositoriesConnector)(nil) + _ rest.Connecter = (*connectionRepositoriesConnector)(nil) + _ rest.StorageMetadata = (*connectionRepositoriesConnector)(nil) +) diff --git a/pkg/registry/apis/provisioning/connection_repositories_test.go b/pkg/registry/apis/provisioning/connection_repositories_test.go new file mode 100644 index 00000000000..792eb52bb64 --- /dev/null +++ b/pkg/registry/apis/provisioning/connection_repositories_test.go @@ -0,0 +1,101 @@ +package provisioning + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestConnectionRepositoriesConnector(t *testing.T) { + connector := NewConnectionRepositoriesConnector() + + t.Run("New returns ExternalRepositoryList", func(t *testing.T) { + obj := connector.New() + require.IsType(t, &provisioning.ExternalRepositoryList{}, obj) + }) + + t.Run("ProducesMIMETypes returns application/json", func(t *testing.T) { + types := connector.ProducesMIMETypes("GET") + require.Equal(t, []string{"application/json"}, types) + }) + + t.Run("ProducesObject returns ExternalRepositoryList", func(t *testing.T) { + obj := connector.ProducesObject("GET") + require.IsType(t, &provisioning.ExternalRepositoryList{}, obj) + }) + + t.Run("ConnectMethods returns GET", func(t *testing.T) { + methods := connector.ConnectMethods() + require.Equal(t, []string{http.MethodGet}, methods) + }) + + t.Run("NewConnectOptions returns no path component", func(t *testing.T) { + obj, hasPath, path := connector.NewConnectOptions() + require.Nil(t, obj) + require.False(t, hasPath) + require.Empty(t, path) + }) + + t.Run("Connect returns handler that rejects non-GET methods", func(t *testing.T) { + ctx := context.Background() + responder := &mockResponder{} + + handler, err := connector.Connect(ctx, "test-connection", nil, responder) + require.NoError(t, err) + require.NotNil(t, handler) + + // Test POST method (should be rejected) + req := httptest.NewRequest(http.MethodPost, "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.True(t, responder.called) + require.NotNil(t, responder.err) + require.True(t, apierrors.IsMethodNotSupported(responder.err)) + }) + + t.Run("Connect returns handler that returns not implemented for GET", func(t *testing.T) { + ctx := context.Background() + responder := &mockResponder{} + + handler, err := connector.Connect(ctx, "test-connection", nil, responder) + require.NoError(t, err) + require.NotNil(t, handler) + + // Test GET method (should return not implemented) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + require.True(t, responder.called) + require.NotNil(t, responder.err) + require.True(t, apierrors.IsMethodNotSupported(responder.err)) + require.Contains(t, responder.err.Error(), "not yet implemented") + }) +} + +// mockResponder implements rest.Responder for testing +type mockResponder struct { + called bool + err error + obj runtime.Object + code int +} + +func (m *mockResponder) Object(statusCode int, obj runtime.Object) { + m.called = true + m.code = statusCode + m.obj = obj +} + +func (m *mockResponder) Error(err error) { + m.called = true + m.err = err +} diff --git a/pkg/registry/apis/provisioning/controller/connection.go b/pkg/registry/apis/provisioning/controller/connection.go new file mode 100644 index 00000000000..be90908bd49 --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/connection.go @@ -0,0 +1,254 @@ +package controller + +import ( + "context" + "errors" + "fmt" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" + informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions/provisioning/v0alpha1" + listers "github.com/grafana/grafana/apps/provisioning/pkg/generated/listers/provisioning/v0alpha1" +) + +const connectionLoggerName = "provisioning-connection-controller" + +const ( + connectionMaxAttempts = 3 + // connectionHealthyDuration defines how recent a health check must be to be considered "recent" when healthy + connectionHealthyDuration = 5 * time.Minute + // connectionUnhealthyDuration defines how recent a health check must be to be considered "recent" when unhealthy + connectionUnhealthyDuration = 1 * time.Minute +) + +type connectionQueueItem struct { + key string + attempts int +} + +// ConnectionStatusPatcher defines the interface for updating connection status. +// +//go:generate mockery --name=ConnectionStatusPatcher +type ConnectionStatusPatcher interface { + Patch(ctx context.Context, conn *provisioning.Connection, patchOperations ...map[string]interface{}) error +} + +// ConnectionController controls Connection resources. +type ConnectionController struct { + client client.ProvisioningV0alpha1Interface + connLister listers.ConnectionLister + connSynced cache.InformerSynced + logger logging.Logger + + statusPatcher ConnectionStatusPatcher + + queue workqueue.TypedRateLimitingInterface[*connectionQueueItem] +} + +// NewConnectionController creates a new ConnectionController. +func NewConnectionController( + provisioningClient client.ProvisioningV0alpha1Interface, + connInformer informer.ConnectionInformer, + statusPatcher ConnectionStatusPatcher, +) (*ConnectionController, error) { + cc := &ConnectionController{ + client: provisioningClient, + connLister: connInformer.Lister(), + connSynced: connInformer.Informer().HasSynced, + queue: workqueue.NewTypedRateLimitingQueueWithConfig( + workqueue.DefaultTypedControllerRateLimiter[*connectionQueueItem](), + workqueue.TypedRateLimitingQueueConfig[*connectionQueueItem]{ + Name: "provisioningConnectionController", + }, + ), + statusPatcher: statusPatcher, + logger: logging.DefaultLogger.With("logger", connectionLoggerName), + } + + _, err := connInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: cc.enqueue, + UpdateFunc: func(oldObj, newObj interface{}) { + cc.enqueue(newObj) + }, + }) + if err != nil { + return nil, err + } + + return cc, nil +} + +func (cc *ConnectionController) enqueue(obj interface{}) { + key, err := cache.DeletionHandlingMetaNamespaceKeyFunc(obj) + if err != nil { + cc.logger.Error("failed to get key for object", "error", err) + return + } + cc.queue.Add(&connectionQueueItem{key: key}) +} + +// Run starts the ConnectionController. +func (cc *ConnectionController) Run(ctx context.Context, workerCount int) { + defer utilruntime.HandleCrash() + defer cc.queue.ShutDown() + + cc.logger.Info("starting connection controller", "workers", workerCount) + + for i := 0; i < workerCount; i++ { + go wait.UntilWithContext(ctx, cc.runWorker, time.Second) + } + + <-ctx.Done() + cc.logger.Info("shutting down connection controller") +} + +func (cc *ConnectionController) runWorker(ctx context.Context) { + for cc.processNextWorkItem(ctx) { + } +} + +func (cc *ConnectionController) processNextWorkItem(ctx context.Context) bool { + item, quit := cc.queue.Get() + if quit { + return false + } + defer cc.queue.Done(item) + + logger := logging.FromContext(ctx).With("work_key", item.key) + logger.Info("ConnectionController processing key") + + err := cc.process(ctx, item) + if err == nil { + cc.queue.Forget(item) + return true + } + + item.attempts++ + logger = logger.With("error", err, "attempts", item.attempts) + logger.Error("ConnectionController failed to process key") + + if item.attempts >= connectionMaxAttempts { + logger.Error("ConnectionController failed too many times") + cc.queue.Forget(item) + return true + } + + if !apierrors.IsServiceUnavailable(err) { + logger.Info("ConnectionController will not retry") + cc.queue.Forget(item) + return true + } + + logger.Info("ConnectionController will retry as service is unavailable") + utilruntime.HandleError(fmt.Errorf("%v failed with: %v", item, err)) + cc.queue.AddRateLimited(item) + + return true +} + +func (cc *ConnectionController) process(ctx context.Context, item *connectionQueueItem) error { + logger := cc.logger.With("key", item.key) + ctx = logging.Context(ctx, logger) + + namespace, name, err := cache.SplitMetaNamespaceKey(item.key) + if err != nil { + return err + } + + conn, err := cc.connLister.Connections(namespace).Get(name) + switch { + case apierrors.IsNotFound(err): + return errors.New("connection not found in cache") + case err != nil: + return err + } + + // Skip if being deleted + if conn.DeletionTimestamp != nil { + logger.Info("connection is being deleted, skipping") + return nil + } + + hasSpecChanged := conn.Generation != conn.Status.ObservedGeneration + shouldCheckHealth := cc.shouldCheckHealth(conn) + + // Determine the main triggering condition + switch { + case hasSpecChanged: + logger.Info("spec changed, reconciling", "generation", conn.Generation, "observedGeneration", conn.Status.ObservedGeneration) + case shouldCheckHealth: + logger.Info("health is stale, refreshing", "lastChecked", conn.Status.Health.Checked, "healthy", conn.Status.Health.Healthy) + default: + logger.Debug("skipping as conditions are not met", "generation", conn.Generation, "observedGeneration", conn.Status.ObservedGeneration) + return nil + } + + // For now, just update the state to connected, health to healthy, and observed generation + // Future: Add credential validation logic here + patchOperations := []map[string]interface{}{} + + // Only update observedGeneration when spec changes + if hasSpecChanged { + patchOperations = append(patchOperations, map[string]interface{}{ + "op": "replace", + "path": "/status/observedGeneration", + "value": conn.Generation, + }) + } + + // Always update state and health + patchOperations = append(patchOperations, + map[string]interface{}{ + "op": "replace", + "path": "/status/state", + "value": provisioning.ConnectionStateConnected, + }, + map[string]interface{}{ + "op": "replace", + "path": "/status/health", + "value": provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + ) + + if err := cc.statusPatcher.Patch(ctx, conn, patchOperations...); err != nil { + return fmt.Errorf("failed to update connection status: %w", err) + } + + logger.Info("connection reconciled successfully") + return nil +} + +// shouldCheckHealth determines if a connection health check should be performed. +func (cc *ConnectionController) shouldCheckHealth(conn *provisioning.Connection) bool { + // If the connection has been updated, always check health + if conn.Generation != conn.Status.ObservedGeneration { + return true + } + + // Check if health check is stale + return !cc.hasRecentHealthCheck(conn.Status.Health) +} + +// hasRecentHealthCheck checks if a health check was performed recently. +func (cc *ConnectionController) hasRecentHealthCheck(healthStatus provisioning.HealthStatus) bool { + if healthStatus.Checked == 0 { + return false // Never checked + } + + age := time.Since(time.UnixMilli(healthStatus.Checked)) + if healthStatus.Healthy { + return age <= connectionHealthyDuration + } + return age <= connectionUnhealthyDuration +} diff --git a/pkg/registry/apis/provisioning/controller/connection_test.go b/pkg/registry/apis/provisioning/controller/connection_test.go new file mode 100644 index 00000000000..b033ddb39a9 --- /dev/null +++ b/pkg/registry/apis/provisioning/controller/connection_test.go @@ -0,0 +1,287 @@ +package controller + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestConnectionController_shouldCheckHealth(t *testing.T) { + testCases := []struct { + name string + conn *provisioning.Connection + expected bool + }{ + { + name: "should check health when generation differs from observed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 2, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + }, + }, + expected: true, + }, + { + name: "should check health when never checked before", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Checked: 0, + }, + }, + }, + expected: true, + }, + { + name: "should check health when healthy check is stale (>5 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-6 * time.Minute).UnixMilli(), + }, + }, + }, + expected: true, + }, + { + name: "should check health when unhealthy check is stale (>1 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + expected: true, + }, + { + name: "should not check health when healthy check is recent (<5 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + }, + }, + expected: false, + }, + { + name: "should not check health when unhealthy check is recent (<1 min)", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-30 * time.Second).UnixMilli(), + }, + }, + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + result := cc.shouldCheckHealth(tc.conn) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConnectionController_hasRecentHealthCheck(t *testing.T) { + testCases := []struct { + name string + healthStatus provisioning.HealthStatus + expected bool + }{ + { + name: "never checked", + healthStatus: provisioning.HealthStatus{ + Checked: 0, + }, + expected: false, + }, + { + name: "healthy and recent", + healthStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + expected: true, + }, + { + name: "healthy and stale", + healthStatus: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-10 * time.Minute).UnixMilli(), + }, + expected: false, + }, + { + name: "unhealthy and recent", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-30 * time.Second).UnixMilli(), + }, + expected: true, + }, + { + name: "unhealthy and stale", + healthStatus: provisioning.HealthStatus{ + Healthy: false, + Checked: time.Now().Add(-2 * time.Minute).UnixMilli(), + }, + expected: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + result := cc.hasRecentHealthCheck(tc.healthStatus) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestConnectionController_reconcileConditions(t *testing.T) { + testCases := []struct { + name string + conn *provisioning.Connection + expectReconcile bool + expectSpecChanged bool + description string + }{ + { + name: "skip when being deleted", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + DeletionTimestamp: &metav1.Time{Time: time.Now()}, + }, + }, + expectReconcile: false, + expectSpecChanged: false, + description: "deleted connections should be skipped", + }, + { + name: "skip when no changes needed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + }, + expectReconcile: false, + expectSpecChanged: false, + description: "no reconcile when generation matches and health is recent", + }, + { + name: "reconcile when spec changed", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 2, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().UnixMilli(), + }, + }, + }, + expectReconcile: true, + expectSpecChanged: true, + description: "reconcile when generation differs", + }, + { + name: "reconcile when health is stale", + conn: &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-conn", + Namespace: "default", + Generation: 1, + }, + Status: provisioning.ConnectionStatus{ + ObservedGeneration: 1, + Health: provisioning.HealthStatus{ + Healthy: true, + Checked: time.Now().Add(-10 * time.Minute).UnixMilli(), + }, + }, + }, + expectReconcile: true, + expectSpecChanged: false, + description: "reconcile when health check is stale", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + cc := &ConnectionController{} + + // Test the core reconciliation conditions + if tc.conn.DeletionTimestamp != nil { + assert.False(t, tc.expectReconcile, tc.description) + return + } + + hasSpecChanged := tc.conn.Generation != tc.conn.Status.ObservedGeneration + shouldCheckHealth := cc.shouldCheckHealth(tc.conn) + + needsReconcile := hasSpecChanged || shouldCheckHealth + + assert.Equal(t, tc.expectReconcile, needsReconcile, tc.description) + assert.Equal(t, tc.expectSpecChanged, hasSpecChanged, "spec changed check") + }) + } +} + +func TestConnectionController_processNextWorkItem(t *testing.T) { + t.Run("returns false when queue is shut down", func(t *testing.T) { + cc := &ConnectionController{} + // This test verifies the structure is correct + assert.NotNil(t, cc) + }) +} diff --git a/pkg/registry/apis/provisioning/extras/register.go b/pkg/registry/apis/provisioning/extras/register.go index caa3c2a2fa5..43c7064173f 100644 --- a/pkg/registry/apis/provisioning/extras/register.go +++ b/pkg/registry/apis/provisioning/extras/register.go @@ -2,6 +2,8 @@ package extras import ( apisprovisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" + ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository" "github.com/grafana/grafana/apps/provisioning/pkg/repository/git" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" @@ -42,6 +44,15 @@ func ProvideProvisioningOSSRepositoryExtras( } } +func ProvideProvisioningOSSConnectionExtras( + _ *setting.Cfg, + ghFactory ghconnection.GithubFactory, +) []connection.Extra { + return []connection.Extra{ + ghconnection.Extra(ghFactory), + } +} + func ProvideExtraWorkers(pullRequestWorker *pullrequest.PullRequestWorker) []jobs.Worker { return []jobs.Worker{pullRequestWorker} } @@ -54,3 +65,12 @@ func ProvideFactoryFromConfig(cfg *setting.Cfg, extras []repository.Extra) (repo return repository.ProvideFactory(enabledTypes, extras) } + +func ProvideConnectionFactoryFromConfig(cfg *setting.Cfg, extras []connection.Extra) (connection.Factory, error) { + enabledTypes := make(map[apisprovisioning.ConnectionType]struct{}, len(cfg.ProvisioningRepositoryTypes)) + for _, e := range cfg.ProvisioningRepositoryTypes { + enabledTypes[apisprovisioning.ConnectionType(e)] = struct{}{} + } + + return connection.ProvideFactory(enabledTypes, extras) +} diff --git a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go index 45d8572e94a..efb2bd52697 100644 --- a/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go +++ b/pkg/registry/apis/provisioning/jobs/job_progress_recorder_mock.go @@ -71,6 +71,98 @@ func (_c *MockJobProgressRecorder_Complete_Call) RunAndReturn(run func(context.C return _c } +// HasDirPathFailedDeletion provides a mock function with given fields: folderPath +func (_m *MockJobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool { + ret := _m.Called(folderPath) + + if len(ret) == 0 { + panic("no return value specified for HasDirPathFailedDeletion") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(folderPath) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockJobProgressRecorder_HasDirPathFailedDeletion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedDeletion' +type MockJobProgressRecorder_HasDirPathFailedDeletion_Call struct { + *mock.Call +} + +// HasDirPathFailedDeletion is a helper method to define mock.On call +// - folderPath string +func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedDeletion(folderPath interface{}) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + return &MockJobProgressRecorder_HasDirPathFailedDeletion_Call{Call: _e.mock.On("HasDirPathFailedDeletion", folderPath)} +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Run(run func(folderPath string)) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedDeletion_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedDeletion_Call { + _c.Call.Return(run) + return _c +} + +// HasDirPathFailedCreation provides a mock function with given fields: path +func (_m *MockJobProgressRecorder) HasDirPathFailedCreation(path string) bool { + ret := _m.Called(path) + + if len(ret) == 0 { + panic("no return value specified for HasDirPathFailedCreation") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(path) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockJobProgressRecorder_HasDirPathFailedCreation_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasDirPathFailedCreation' +type MockJobProgressRecorder_HasDirPathFailedCreation_Call struct { + *mock.Call +} + +// HasDirPathFailedCreation is a helper method to define mock.On call +// - path string +func (_e *MockJobProgressRecorder_Expecter) HasDirPathFailedCreation(path interface{}) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + return &MockJobProgressRecorder_HasDirPathFailedCreation_Call{Call: _e.mock.On("HasDirPathFailedCreation", path)} +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Run(run func(path string)) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) Return(_a0 bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockJobProgressRecorder_HasDirPathFailedCreation_Call) RunAndReturn(run func(string) bool) *MockJobProgressRecorder_HasDirPathFailedCreation_Call { + _c.Call.Return(run) + return _c +} + // Record provides a mock function with given fields: ctx, result func (_m *MockJobProgressRecorder) Record(ctx context.Context, result JobResourceResult) { _m.Called(ctx, result) diff --git a/pkg/registry/apis/provisioning/jobs/progress.go b/pkg/registry/apis/provisioning/jobs/progress.go index 2cb9dc9ddcf..3ba61eb6278 100644 --- a/pkg/registry/apis/provisioning/jobs/progress.go +++ b/pkg/registry/apis/provisioning/jobs/progress.go @@ -2,6 +2,7 @@ package jobs import ( "context" + "errors" "fmt" "sync" "time" @@ -9,6 +10,8 @@ import ( "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/apps/provisioning/pkg/safepath" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" ) // maybeNotifyProgress will only notify if a certain amount of time has passed @@ -58,6 +61,8 @@ type jobProgressRecorder struct { notifyImmediatelyFn ProgressFn maybeNotifyFn ProgressFn summaries map[string]*provisioning.JobResourceSummary + failedCreations []string // Tracks folder paths that failed to be created + failedDeletions []string // Tracks resource paths that failed to be deleted } func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder { @@ -84,10 +89,26 @@ func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResu if result.Error != nil { shouldLogError = true logErr = result.Error - if len(r.errors) < 20 { - r.errors = append(r.errors, result.Error.Error()) + + // Don't count ignored actions as errors in error count or error list + if result.Action != repository.FileActionIgnored { + if len(r.errors) < 20 { + r.errors = append(r.errors, result.Error.Error()) + } + r.errorCount++ + } + + // Automatically track failed operations based on error type and action + // Check if this is a PathCreationError (folder creation failure) + var pathErr *resources.PathCreationError + if errors.As(result.Error, &pathErr) { + r.failedCreations = append(r.failedCreations, pathErr.Path) + } + + // Track failed deletions, any deletion will stop the deletion of the parent folder (as it won't be empty) + if result.Action == repository.FileActionDeleted { + r.failedDeletions = append(r.failedDeletions, result.Path) } - r.errorCount++ } r.updateSummary(result) @@ -112,6 +133,8 @@ func (r *jobProgressRecorder) ResetResults() { r.errorCount = 0 r.errors = nil r.summaries = make(map[string]*provisioning.JobResourceSummary) + r.failedCreations = nil + r.failedDeletions = nil } func (r *jobProgressRecorder) SetMessage(ctx context.Context, msg string) { @@ -309,3 +332,29 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision return jobStatus } + +// HasDirPathFailedCreation checks if a path is nested under any failed folder creation +func (r *jobProgressRecorder) HasDirPathFailedCreation(path string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, failedCreation := range r.failedCreations { + if safepath.InDir(path, failedCreation) { + return true + } + } + return false +} + +// HasDirPathFailedDeletion checks if any resource deletions failed under a folder path +func (r *jobProgressRecorder) HasDirPathFailedDeletion(folderPath string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, failedDeletion := range r.failedDeletions { + if safepath.InDir(failedDeletion, folderPath) { + return true + } + } + return false +} diff --git a/pkg/registry/apis/provisioning/jobs/progress_test.go b/pkg/registry/apis/provisioning/jobs/progress_test.go index 7e849491bbe..0879ba111af 100644 --- a/pkg/registry/apis/provisioning/jobs/progress_test.go +++ b/pkg/registry/apis/provisioning/jobs/progress_test.go @@ -7,6 +7,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -252,3 +253,221 @@ func TestJobProgressRecorderWarningOnlyNoErrors(t *testing.T) { require.NotNil(t, finalStatus.Warnings) assert.Len(t, finalStatus.Warnings, 1) } + +func TestJobProgressRecorderFolderFailureTracking(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Record a folder creation failure with PathCreationError + pathErr := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr, + }) + + // Record another PathCreationError for a different folder + pathErr2 := &resources.PathCreationError{ + Path: "folder2/subfolder/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file.json", + Action: repository.FileActionCreated, + Error: pathErr2, + }) + + // Record a deletion failure + recorder.Record(ctx, JobResourceResult{ + Path: "folder3/file1.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Record another deletion failure + recorder.Record(ctx, JobResourceResult{ + Path: "folder4/subfolder/file2.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Verify failed creations are tracked + recorder.mu.RLock() + assert.Len(t, recorder.failedCreations, 2) + assert.Contains(t, recorder.failedCreations, "folder1/") + assert.Contains(t, recorder.failedCreations, "folder2/subfolder/") + + // Verify failed deletions are tracked + assert.Len(t, recorder.failedDeletions, 2) + assert.Contains(t, recorder.failedDeletions, "folder3/file1.json") + assert.Contains(t, recorder.failedDeletions, "folder4/subfolder/file2.json") + recorder.mu.RUnlock() +} + +func TestJobProgressRecorderHasDirPathFailedCreation(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add failed creations via Record + pathErr1 := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr1, + }) + + pathErr2 := &resources.PathCreationError{ + Path: "folder2/subfolder/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file.json", + Action: repository.FileActionCreated, + Error: pathErr2, + }) + + // Test nested paths + assert.True(t, recorder.HasDirPathFailedCreation("folder1/file.json")) + assert.True(t, recorder.HasDirPathFailedCreation("folder1/nested/file.json")) + assert.True(t, recorder.HasDirPathFailedCreation("folder2/subfolder/file.json")) + + // Test non-nested paths + assert.False(t, recorder.HasDirPathFailedCreation("folder2/file2.json")) + assert.False(t, recorder.HasDirPathFailedCreation("folder2/othersubfolder/inside.json")) + assert.False(t, recorder.HasDirPathFailedCreation("other/file.json")) + assert.False(t, recorder.HasDirPathFailedCreation("folder3/file.json")) + assert.False(t, recorder.HasDirPathFailedCreation("file.json")) +} + +func TestJobProgressRecorderHasDirPathFailedDeletion(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add failed deletions via Record + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file1.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/subfolder/file2.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder3/nested/deep/file3.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Test folder paths with failed deletions + assert.True(t, recorder.HasDirPathFailedDeletion("folder1/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder2/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/")) + assert.True(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/")) + + // Test folder paths without failed deletions + assert.False(t, recorder.HasDirPathFailedDeletion("other/")) + assert.False(t, recorder.HasDirPathFailedDeletion("different/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder2/othersubfolder/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder2/subfolder/othersubfolder/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/anotherdeep/")) + assert.False(t, recorder.HasDirPathFailedDeletion("folder3/nested/deep/insidedeep/")) +} + +func TestJobProgressRecorderResetResults(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Add some data via Record + pathErr := &resources.PathCreationError{ + Path: "folder1/", + Err: assert.AnError, + } + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file.json", + Action: repository.FileActionCreated, + Error: pathErr, + }) + + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/file.json", + Action: repository.FileActionDeleted, + Error: assert.AnError, + }) + + // Verify data is stored + recorder.mu.RLock() + assert.Len(t, recorder.failedCreations, 1) + assert.Len(t, recorder.failedDeletions, 1) + recorder.mu.RUnlock() + + // Reset results + recorder.ResetResults() + + // Verify data is cleared + recorder.mu.RLock() + assert.Nil(t, recorder.failedCreations) + assert.Nil(t, recorder.failedDeletions) + recorder.mu.RUnlock() +} + +func TestJobProgressRecorderIgnoredActionsDontCountAsErrors(t *testing.T) { + ctx := context.Background() + + // Create a progress recorder + mockProgressFn := func(ctx context.Context, status provisioning.JobStatus) error { + return nil + } + recorder := newJobProgressRecorder(mockProgressFn).(*jobProgressRecorder) + + // Record an ignored action with error + recorder.Record(ctx, JobResourceResult{ + Path: "folder1/file1.json", + Action: repository.FileActionIgnored, + Error: assert.AnError, + }) + + // Record a real error for comparison + recorder.Record(ctx, JobResourceResult{ + Path: "folder2/file2.json", + Action: repository.FileActionCreated, + Error: assert.AnError, + }) + + // Verify error count doesn't include ignored actions + recorder.mu.RLock() + assert.Equal(t, 1, recorder.errorCount, "ignored actions should not be counted as errors") + assert.Len(t, recorder.errors, 1, "ignored action errors should not be in error list") + recorder.mu.RUnlock() +} diff --git a/pkg/registry/apis/provisioning/jobs/queue.go b/pkg/registry/apis/provisioning/jobs/queue.go index e1992395efd..90b50aa4ee7 100644 --- a/pkg/registry/apis/provisioning/jobs/queue.go +++ b/pkg/registry/apis/provisioning/jobs/queue.go @@ -29,6 +29,10 @@ type JobProgressRecorder interface { StrictMaxErrors(maxErrors int) SetRefURLs(ctx context.Context, refURLs *provisioning.RepositoryURLs) Complete(ctx context.Context, err error) provisioning.JobStatus + // HasDirPathFailedCreation checks if a path has any folder creations that failed + HasDirPathFailedCreation(path string) bool + // HasDirPathFailedDeletion checks if a folderPath has any folder deletions that failed + HasDirPathFailedDeletion(folderPath string) bool } // Worker is a worker that can process a job diff --git a/pkg/registry/apis/provisioning/jobs/sync/full.go b/pkg/registry/apis/provisioning/jobs/sync/full.go index 10aad46693b..50ab5c39bcf 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full.go @@ -75,11 +75,47 @@ func FullSync( return applyChanges(ctx, changes, clients, repositoryResources, progress, tracer, maxSyncWorkers, metrics) } +// shouldSkipChange checks if a change should be skipped based on previous failures on parent/child folders. +// If there is a previous failure on the path, we don't need to process the change as it will fail anyway. +func shouldSkipChange(ctx context.Context, change ResourceFileChange, progress jobs.JobProgressRecorder, tracer tracing.Tracer) bool { + if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) { + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_nested_resource") + skipSpan.SetAttributes(attribute.String("path", change.Path)) + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"), + }) + skipSpan.End() + return true + } + + if change.Action == repository.FileActionDeleted && safepath.IsDir(change.Path) && progress.HasDirPathFailedDeletion(change.Path) { + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.skip_folder_with_failed_deletions") + skipSpan.SetAttributes(attribute.String("path", change.Path)) + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Group: resources.FolderKind.Group, + Kind: resources.FolderKind.Kind, + Warning: fmt.Errorf("folder was not processed because children resources in its path could not be deleted"), + }) + skipSpan.End() + return true + } + + return false +} + func applyChange(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) { if ctx.Err() != nil { return } + if shouldSkipChange(ctx, change, progress, tracer) { + return + } + if change.Action == repository.FileActionDeleted { deleteCtx, deleteSpan := tracer.Start(ctx, "provisioning.sync.full.apply_changes.delete") result := jobs.JobResourceResult{ @@ -138,6 +174,7 @@ func applyChange(ctx context.Context, change ResourceFileChange, clients resourc ensureFolderSpan.RecordError(err) ensureFolderSpan.End() progress.Record(ctx, result) + return } @@ -253,8 +290,6 @@ func applyChanges(ctx context.Context, changes []ResourceFileChange, clients res } func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer) error { - logger := logging.FromContext(ctx) - for _, folder := range folders { if ctx.Err() != nil { return ctx.Err() @@ -264,23 +299,9 @@ func applyFoldersSerially(ctx context.Context, folders []ResourceFileChange, cli return err } - folderCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - - applyChange(folderCtx, folder, clients, repositoryResources, progress, tracer) - - if folderCtx.Err() == context.DeadlineExceeded { - logger.Error("operation timed out after 15 seconds", "path", folder.Path, "action", folder.Action) - - recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second) - progress.Record(recordCtx, jobs.JobResourceResult{ - Path: folder.Path, - Action: folder.Action, - Error: fmt.Errorf("operation timed out after 15 seconds"), - }) - recordCancel() - } - - cancel() + wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) { + applyChange(timeoutCtx, folder, clients, repositoryResources, progress, tracer) + }) } return nil @@ -318,7 +339,9 @@ loop: defer wg.Done() defer func() { <-sem }() - applyChangeWithTimeout(ctx, change, clients, repositoryResources, progress, tracer, logger) + wrapWithTimeout(ctx, 15*time.Second, func(timeoutCtx context.Context) { + applyChange(timeoutCtx, change, clients, repositoryResources, progress, tracer) + }) }(change) } @@ -331,21 +354,10 @@ loop: return ctx.Err() } -func applyChangeWithTimeout(ctx context.Context, change ResourceFileChange, clients resources.ResourceClients, repositoryResources resources.RepositoryResources, progress jobs.JobProgressRecorder, tracer tracing.Tracer, logger logging.Logger) { - changeCtx, cancel := context.WithTimeout(ctx, 15*time.Second) +// wrapWithTimeout wraps a function call with a timeout context +func wrapWithTimeout(ctx context.Context, timeout time.Duration, fn func(context.Context)) { + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - applyChange(changeCtx, change, clients, repositoryResources, progress, tracer) - - if changeCtx.Err() == context.DeadlineExceeded { - logger.Error("operation timed out after 15 seconds", "path", change.Path, "action", change.Action) - - recordCtx, recordCancel := context.WithTimeout(context.Background(), 15*time.Second) - progress.Record(recordCtx, jobs.JobResourceResult{ - Path: change.Path, - Action: change.Action, - Error: fmt.Errorf("operation timed out after 15 seconds"), - }) - recordCancel() - } + fn(timeoutCtx) } diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go new file mode 100644 index 00000000000..2bc0ea8779e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/full_hierarchical_test.go @@ -0,0 +1,432 @@ +package sync + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8testing "k8s.io/client-go/testing" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +/* +TestFullSync_HierarchicalErrorHandling tests the hierarchical error handling behavior: + +FOLDER CREATION FAILURES: +- When a folder fails to be created with PathCreationError, all nested resources are skipped +- Nested resources are recorded with FileActionIgnored and error "folder was not processed because children resources in its path could not be deleted" +- Only the folder creation error counts toward error limits +- Nested resource skips do NOT count toward error limits + +FOLDER DELETION FAILURES: +- When a file deletion fails, it's tracked in failedDeletions +- When cleaning up folders, we check HasDirPathFailedDeletion() +- If children failed to delete, folder deletion is skipped with FileActionIgnored +- This prevents orphaning resources that still exist + +DELETIONS NOT AFFECTED BY CREATION FAILURES: +- If a folder creation fails, deletion operations for resources in that folder still proceed +- This is because the resource might already exist from a previous sync +- Only creations/updates/renames are affected by failed folder creation + +AUTOMATIC TRACKING: +- Record() automatically detects PathCreationError and adds to failedCreations +- Record() automatically detects deletion failures and adds to failedDeletions +- No manual calls to AddFailedCreation/AddFailedDeletion needed +*/ +func TestFullSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo + tests := []struct { + name string + setupMocks func(*repository.MockRepository, *resources.MockRepositoryResources, *resources.MockResourceClients, *jobs.MockJobProgressRecorder, *dynamicfake.FakeDynamicClient) + changes []ResourceFileChange + description string + expectError bool + errorContains string + }{ + { + name: "folder creation fails, nested file skipped", + description: "When folder1/ fails to create, folder1/file.json should be skipped with FileActionIgnored", + changes: []ResourceFileChange{ + {Path: "folder1/file.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First, check if nested under failed creation - not yet + progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once() + + // WriteResourceFromFile fails with PathCreationError for folder1/ + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + // File will be recorded with error, triggering automatic tracking of folder1/ failure + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && r.Error != nil && r.Action == repository.FileActionCreated + })).Return().Once() + }, + }, + { + name: "folder creation fails, multiple nested resources skipped", + description: "When folder1/ fails to create, all nested resources (subfolder, files) are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated}, + {Path: "folder1/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First file triggers folder creation failure + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + // Subsequent files in same folder are skipped + progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/file2.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "folder1/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file3.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + }, + }, + { + name: "file deletion failure tracked", + description: "When a file deletion fails, it's automatically tracked in failedDeletions", + changes: []ResourceFileChange{ + { + Path: "folder1/file.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{ + Name: "file1", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + }, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + // File deletion fails + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + // File deletion recorded with error, automatically tracked in failedDeletions + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && + r.Action == repository.FileActionDeleted && + r.Error != nil + })).Return().Once() + }, + }, + { + name: "deletion proceeds despite creation failure", + description: "When folder1/ fails to create, deletion of folder1/file2.json still proceeds (resource might exist from previous sync)", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + { + Path: "folder1/file2.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{ + Name: "file2", + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + }, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + // Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions) + // Note: deletion will fail because resource doesn't exist, but that's fine for this test + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + // Record deletion attempt (will have error since resource doesn't exist, but that's ok) + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && + r.Action == repository.FileActionDeleted + // Not checking r.Error because resource doesn't exist in fake client + })).Return().Once() + }, + }, + { + name: "multi-level nesting - all skipped", + description: "When level1/ fails, level1/level2/level3/file.json is also skipped", + changes: []ResourceFileChange{ + {Path: "level1/file1.json", Action: repository.FileActionCreated}, + {Path: "level1/level2/file2.json", Action: repository.FileActionCreated}, + {Path: "level1/level2/level3/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // First file triggers level1/ failure + progress.On("HasDirPathFailedCreation", "level1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "level1/file1.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file1.json" && r.Error != nil + })).Return().Once() + + // All nested files are skipped + for _, path := range []string{"level1/level2/file2.json", "level1/level2/level3/file3.json"} { + progress.On("HasDirPathFailedCreation", path).Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "mixed success and failure", + description: "When success/ works and failure/ fails, only failure/* are skipped", + changes: []ResourceFileChange{ + {Path: "success/file1.json", Action: repository.FileActionCreated}, + {Path: "failure/file2.json", Action: repository.FileActionCreated}, + {Path: "failure/nested/file3.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + // Success path works + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", ""). + Return("resource1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Error == nil + })).Return().Once() + + // Failure path fails + progress.On("HasDirPathFailedCreation", "failure/file2.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + repoResources.On("WriteResourceFromFile", mock.Anything, "failure/file2.json", ""). + Return("", schema.GroupVersionKind{}, folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file2.json" && r.Error != nil + })).Return().Once() + + // Nested file in failure path is skipped + progress.On("HasDirPathFailedCreation", "failure/nested/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file3.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "folder creation fails with explicit folder in changes", + description: "When folder1/ is explicitly in changes and fails to create, all nested resources (subfolders and files) are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/", Action: repository.FileActionCreated}, + {Path: "folder1/file1.json", Action: repository.FileActionCreated}, + {Path: "folder1/subfolder/file2.json", Action: repository.FileActionCreated}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, _ *dynamicfake.FakeDynamicClient) { + progress.On("HasDirPathFailedCreation", "folder1/").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "folder1/").Return("", folderErr).Once() + + progress.On("HasDirPathFailedCreation", "folder1/subfolder/").Return(true).Once() + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once() + progress.On("HasDirPathFailedCreation", "folder1/subfolder/file2.json").Return(true).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/" && r.Error != nil + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/subfolder/file2.json" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "folder deletion prevented when child deletion fails", + description: "When a file deletion fails, folder deletion is skipped with FileActionIgnored to prevent orphaning resources", + changes: []ResourceFileChange{ + { + Path: "folder1/file1.json", + Action: repository.FileActionDeleted, + Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}, + }, + {Path: "folder1/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "folder1", Group: "folder.grafana.app", Resource: "Folder"}}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "multiple folder deletion failures", + description: "When multiple independent folders have child deletion failures, all folder deletions are skipped", + changes: []ResourceFileChange{ + {Path: "folder1/file1.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "folder1/", Action: repository.FileActionDeleted}, + {Path: "folder2/file2.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file2", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "folder2/", Action: repository.FileActionDeleted}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + for _, path := range []string{"folder1/file1.json", "folder2/file2.json"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Error != nil + })).Return().Once() + } + + progress.On("HasDirPathFailedDeletion", "folder1/").Return(true).Once() + progress.On("HasDirPathFailedDeletion", "folder2/").Return(true).Once() + + for _, path := range []string{"folder1/", "folder2/"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "nested subfolder deletion failure", + description: "When a file deletion fails in a nested subfolder, both the subfolder and parent folder deletions are skipped", + changes: []ResourceFileChange{ + {Path: "parent/subfolder/file.json", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "file1", Group: "dashboard.grafana.app", Resource: "dashboards"}}, + {Path: "parent/subfolder/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "subfolder", Group: "folder.grafana.app", Resource: "Folder"}}, + {Path: "parent/", Action: repository.FileActionDeleted, Existing: &provisioning.ResourceListItem{Name: "parent", Group: "folder.grafana.app", Resource: "Folder"}}, + }, + setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, dynamicClient *dynamicfake.FakeDynamicClient) { + gvk := schema.GroupVersionKind{Group: "dashboard.grafana.app", Kind: "Dashboard", Version: "v1"} + gvr := schema.GroupVersionResource{Group: "dashboard.grafana.app", Resource: "dashboards", Version: "v1"} + clients.On("ForResource", mock.Anything, mock.MatchedBy(func(gvr schema.GroupVersionResource) bool { + return gvr.Group == "dashboard.grafana.app" + })).Return(dynamicClient.Resource(gvr), gvk, nil) + + dynamicClient.PrependReactor("delete", "dashboards", func(action k8testing.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("permission denied") + }) + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "parent/subfolder/file.json" && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "parent/subfolder/").Return(true).Once() + progress.On("HasDirPathFailedDeletion", "parent/").Return(true).Once() + + for _, path := range []string{"parent/subfolder/", "parent/"} { + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scheme := runtime.NewScheme() + dynamicClient := dynamicfake.NewSimpleDynamicClient(scheme) + + repo := repository.NewMockRepository(t) + repoResources := resources.NewMockRepositoryResources(t) + clients := resources.NewMockResourceClients(t) + progress := jobs.NewMockJobProgressRecorder(t) + compareFn := NewMockCompareFn(t) + + repo.On("Config").Return(&provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{Name: "test-repo"}, + Spec: provisioning.RepositorySpec{Title: "Test Repo"}, + }) + + tt.setupMocks(repo, repoResources, clients, progress, dynamicClient) + + compareFn.On("Execute", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tt.changes, nil) + progress.On("SetTotal", mock.Anything, len(tt.changes)).Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + err := FullSync(context.Background(), repo, compareFn.Execute, clients, "ref", repoResources, progress, tracing.NewNoopTracerService(), 10, jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) + }) + } +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/full_test.go b/pkg/registry/apis/provisioning/jobs/sync/full_test.go index aaa61ee61db..d045c67c6be 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/full_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/full_test.go @@ -213,6 +213,10 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo return nil }) + progress.On("HasDirPathFailedCreation", mock.MatchedBy(func(path string) bool { + return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json" + })).Return(false).Maybe() + repoResources.On("WriteResourceFromFile", mock.Anything, mock.MatchedBy(func(path string) bool { return path == "dashboards/one.json" || path == "dashboards/two.json" || path == "dashboards/three.json" }), "").Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil).Maybe() @@ -235,6 +239,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -259,6 +264,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) @@ -285,6 +291,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -309,6 +316,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", ""). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write error")) @@ -335,6 +343,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false) repoResources.On("EnsureFolderPathExist", mock.Anything, "one/two/three/").Return("some-folder", nil) progress.On("Record", mock.Anything, jobs.JobResourceResult{ @@ -357,6 +366,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "one/two/three/").Return(false) repoResources.On( "EnsureFolderPathExist", @@ -581,6 +591,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false) scheme := runtime.NewScheme() require.NoError(t, metav1.AddMetaToScheme(scheme)) @@ -640,6 +651,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedDeletion", "to-be-deleted/").Return(false) scheme := runtime.NewScheme() require.NoError(t, metav1.AddMetaToScheme(scheme)) @@ -695,6 +707,7 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }, setupMocks: func(repo *repository.MockRepository, repoResources *resources.MockRepositoryResources, clients *resources.MockResourceClients, progress *jobs.MockJobProgressRecorder, compareFn *MockCompareFn) { progress.On("TooManyErrors").Return(nil) + progress.On("HasDirPathFailedCreation", "dashboards/slow.json").Return(false) repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/slow.json", ""). Run(func(args mock.Arguments) { @@ -708,19 +721,13 @@ func TestFullSync_ApplyChanges(t *testing.T) { //nolint:gocyclo }). Return("", schema.GroupVersionKind{}, context.DeadlineExceeded) + // applyChange records the error from WriteResourceFromFile progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { return result.Action == repository.FileActionCreated && result.Path == "dashboards/slow.json" && result.Error != nil && result.Error.Error() == "writing resource from file dashboards/slow.json: context deadline exceeded" })).Return().Once() - - progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { - return result.Action == repository.FileActionCreated && - result.Path == "dashboards/slow.json" && - result.Error != nil && - result.Error.Error() == "operation timed out after 15 seconds" - })).Return().Once() }, }, } diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental.go b/pkg/registry/apis/provisioning/jobs/sync/incremental.go index daa94d94636..5ae33f1e4d1 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental.go @@ -60,7 +60,7 @@ func IncrementalSync(ctx context.Context, repo repository.Versioned, previousRef if len(affectedFolders) > 0 { cleanupStart := time.Now() span.AddEvent("checking if impacted folders should be deleted", trace.WithAttributes(attribute.Int("affected_folders", len(affectedFolders)))) - err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer) + err := cleanupOrphanedFolders(ctx, repo, affectedFolders, repositoryResources, tracer, progress) metrics.RecordIncrementalSyncPhase(jobs.IncrementalSyncPhaseCleanup, time.Since(cleanupStart)) if err != nil { return tracing.Error(span, fmt.Errorf("cleanup orphaned folders: %w", err)) @@ -85,6 +85,20 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil return nil, tracing.Error(span, err) } + // Check if this resource is nested under a failed folder creation + // This only applies to creation/update/rename operations, not deletions + if change.Action != repository.FileActionDeleted && progress.HasDirPathFailedCreation(change.Path) { + // Skip this resource since its parent folder failed to be created + skipCtx, skipSpan := tracer.Start(ctx, "provisioning.sync.incremental.skip_nested_resource") + progress.Record(skipCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Warning: fmt.Errorf("resource was not processed because the parent folder could not be created"), + }) + skipSpan.End() + continue + } + if err := resources.IsPathSupported(change.Path); err != nil { ensureFolderCtx, ensureFolderSpan := tracer.Start(ctx, "provisioning.sync.incremental.ensure_folder_path_exist") // Maintain the safe segment for empty folders @@ -98,7 +112,15 @@ func applyIncrementalChanges(ctx context.Context, diff []repository.VersionedFil if err != nil { ensureFolderSpan.RecordError(err) ensureFolderSpan.End() - return nil, tracing.Error(span, fmt.Errorf("unable to create empty file folder: %w", err)) + + progress.Record(ensureFolderCtx, jobs.JobResourceResult{ + Path: change.Path, + Action: repository.FileActionIgnored, + Group: resources.FolderKind.Group, + Kind: resources.FolderKind.Kind, + Error: err, + }) + continue } progress.Record(ensureFolderCtx, jobs.JobResourceResult{ @@ -185,6 +207,7 @@ func cleanupOrphanedFolders( affectedFolders map[string]string, repositoryResources resources.RepositoryResources, tracer tracing.Tracer, + progress jobs.JobProgressRecorder, ) error { ctx, span := tracer.Start(ctx, "provisioning.sync.incremental.cleanup_orphaned_folders") defer span.End() @@ -198,6 +221,12 @@ func cleanupOrphanedFolders( for path, folderName := range affectedFolders { span.SetAttributes(attribute.String("folder", folderName)) + // Check if any resources under this folder failed to delete + if progress.HasDirPathFailedDeletion(path) { + span.AddEvent("skipping orphaned folder cleanup: a child resource in its path failed to be deleted") + continue + } + // if we can no longer find the folder in git, then we can delete it from grafana _, err := readerRepo.Read(ctx, path, "") if err != nil && (errors.Is(err, repository.ErrFileNotFound) || apierrors.IsNotFound(err)) { diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go new file mode 100644 index 00000000000..ff4212eff1e --- /dev/null +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_hierarchical_test.go @@ -0,0 +1,623 @@ +package sync + +import ( + "context" + "fmt" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/apps/provisioning/pkg/repository" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" +) + +/* +TestIncrementalSync_HierarchicalErrorHandling tests the hierarchical error handling behavior: + +FOLDER CREATION FAILURES: +- When EnsureFolderPathExist fails with PathCreationError, the path is tracked +- Subsequent resources under that path are skipped with FileActionIgnored +- Only the initial folder creation error counts toward error limits +- WriteResourceFromFile can also return PathCreationError for implicit folder creation + +FOLDER DELETION FAILURES (cleanupOrphanedFolders): +- When RemoveResourceFromFile fails, path is tracked in failedDeletions +- In cleanupOrphanedFolders, HasDirPathFailedDeletion() is checked before RemoveFolder +- If children failed to delete, folder cleanup is skipped with a span event + +DELETIONS NOT AFFECTED BY CREATION FAILURES: +- HasDirPathFailedCreation is NOT checked for FileActionDeleted +- Deletions proceed even if their parent folder failed to be created +- This handles cleanup of resources that exist from previous syncs + +RENAME OPERATIONS: +- RenameResourceFile can return PathCreationError for the destination folder +- Renames are affected by failed destination folder creation +- Renames are NOT skipped due to source folder creation failures + +AUTOMATIC TRACKING: +- Record() automatically detects PathCreationError via errors.As() and adds to failedCreations +- Record() automatically detects FileActionDeleted with error and adds to failedDeletions +- No manual tracking calls needed +*/ +func TestIncrementalSync_HierarchicalErrorHandling(t *testing.T) { // nolint:gocyclo + tests := []struct { + name string + setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) + changes []repository.VersionedFileChange + previousRef string + currentRef string + description string + expectError bool + errorContains string + }{ + { + name: "folder creation fails, nested file skipped", + description: "When unsupported/ fails to create via EnsureFolderPathExist, nested file is skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/nested/file2.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file triggers folder creation which fails + progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once() + + // First file recorded with error (note: error is from folder creation, but recorded against file) + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file.txt" && + r.Action == repository.FileActionIgnored && + r.Error != nil + })).Return().Once() + + // Second file is skipped because parent folder failed + progress.On("HasDirPathFailedCreation", "unsupported/nested/file2.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/nested/file2.txt" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && + r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + }, + }, + { + name: "WriteResourceFromFile returns PathCreationError, nested resources skipped", + description: "When WriteResourceFromFile implicitly creates a folder and fails, nested resources are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/nested/file3.json", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file write fails with PathCreationError + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file1.json", "new-ref"). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + // First file recorded with error, automatically tracked + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && + r.Action == repository.FileActionCreated && + r.Error != nil + })).Return().Once() + + // Subsequent files are skipped + progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "folder1/nested/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/nested/file3.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + }, + }, + { + name: "file deletion fails, folder cleanup skipped", + description: "When RemoveResourceFromFile fails, cleanupOrphanedFolders skips folder removal", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // File deletion fails (deletions don't check HasDirPathFailedCreation) + repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref"). + Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once() + + // Error recorded, automatically tracked in failedDeletions + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "dashboards/file1.json" && + r.Action == repository.FileActionDeleted && + r.Error != nil + })).Return().Once() + + // During cleanup, folder deletion is skipped + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once() + + // Note: RemoveFolder should NOT be called (verified via AssertNotCalled in test) + }, + }, + { + name: "deletion proceeds despite creation failure", + description: "When folder1/ creation fails, deletion of folder1/old.json still proceeds", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/new.json", Ref: "new-ref"}, + {Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/new.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")} + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/new.json", "new-ref"). + Return("", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/new.json" && r.Error != nil + })).Return().Once() + + // Deletion proceeds (NOT checking HasDirPathFailedCreation for deletions) + repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref"). + Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/old.json" && + r.Action == repository.FileActionDeleted && + r.Error == nil // Deletion succeeds! + })).Return().Once() + }, + }, + { + name: "multi-level nesting cascade", + description: "When level1/ fails, level1/level2/level3/file.json is also skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // First file triggers level1/ failure + progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + // All nested files are skipped + for _, path := range []string{"level1/level2/file.txt", "level1/level2/level3/file.txt"} { + progress.On("HasDirPathFailedCreation", path).Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == path && r.Action == repository.FileActionIgnored + })).Return().Once() + } + }, + }, + { + name: "mixed success and failure", + description: "When success/ works and failure/ fails, only failure/* are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Success path works + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref"). + Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Error == nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref"). + Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/nested/file2.json" && r.Error == nil + })).Return().Once() + + // Failure path fails + progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + // Nested file in failure path is skipped + progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + }, + }, + { + name: "rename with failed destination folder", + description: "When RenameResourceFile fails with PathCreationError for destination, rename is skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + { + Action: repository.FileActionRenamed, + Path: "newfolder/file.json", + PreviousPath: "oldfolder/file.json", + Ref: "new-ref", + PreviousRef: "old-ref", + }, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Rename fails with PathCreationError for destination folder + progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(false).Once() + folderErr := &resources.PathCreationError{Path: "newfolder/", Err: fmt.Errorf("permission denied")} + repoResources.On("RenameResourceFile", mock.Anything, "oldfolder/file.json", "old-ref", "newfolder/file.json", "new-ref"). + Return("", "", schema.GroupVersionKind{}, folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "newfolder/file.json" && + r.Action == repository.FileActionRenamed && + r.Error != nil + })).Return().Once() + }, + }, + { + name: "renamed file still checked, subsequent nested resources skipped", + description: "After rename fails for folder1/file.json, other folder1/* files are skipped", + previousRef: "old-ref", + currentRef: "new-ref", + changes: []repository.VersionedFileChange{ + {Action: repository.FileActionRenamed, Path: "folder1/file1.json", PreviousPath: "old/file1.json", Ref: "new-ref", PreviousRef: "old-ref"}, + {Action: repository.FileActionCreated, Path: "folder1/file2.json", Ref: "new-ref"}, + }, + setupMocks: func(repo *repository.MockVersioned, repoResources *resources.MockRepositoryResources, progress *jobs.MockJobProgressRecorder) { + // Rename is NOT skipped for creation failures (it's checking the destination path) + progress.On("HasDirPathFailedCreation", "folder1/file1.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file1.json" && + r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + // Second file also skipped + progress.On("HasDirPathFailedCreation", "folder1/file2.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file2.json" && r.Action == repository.FileActionIgnored && r.Warning != nil + })).Return().Once() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runHierarchicalErrorHandlingTest(t, tt) + }) + } +} + +type compositeRepoForTest struct { + *repository.MockVersioned + *repository.MockReader +} + +func runHierarchicalErrorHandlingTest(t *testing.T, tt struct { + name string + setupMocks func(*repository.MockVersioned, *resources.MockRepositoryResources, *jobs.MockJobProgressRecorder) + changes []repository.VersionedFileChange + previousRef string + currentRef string + description string + expectError bool + errorContains string +}) { + var repo repository.Versioned + mockVersioned := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + // For tests that need cleanup (folder deletion), use composite repo + if tt.name == "file deletion fails, folder cleanup skipped" { + mockReader := repository.NewMockReader(t) + repo = &compositeRepoForTest{ + MockVersioned: mockVersioned, + MockReader: mockReader, + } + } else { + repo = mockVersioned + } + + mockVersioned.On("CompareFiles", mock.Anything, tt.previousRef, tt.currentRef).Return(tt.changes, nil) + progress.On("SetTotal", mock.Anything, len(tt.changes)).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + tt.setupMocks(mockVersioned, repoResources, progress) + + err := IncrementalSync(context.Background(), repo, tt.previousRef, tt.currentRef, repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) + // For deletion tests, verify RemoveFolder was NOT called + if tt.name == "file deletion fails, folder cleanup skipped" { + repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything) + } +} + +// TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation tests nested resource skipping +func TestIncrementalSync_HierarchicalErrorHandling_FailedFolderCreation(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "unsupported/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/subfolder/file2.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "unsupported/file3.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "other/file.json", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 4).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + folderErr := &resources.PathCreationError{Path: "unsupported/", Err: fmt.Errorf("permission denied")} + // First check is before it fails. + progress.On("HasDirPathFailedCreation", "unsupported/file.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "unsupported/subfolder/file2.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/subfolder/file2.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "unsupported/file3.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "unsupported/file3.json" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "other/file.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "other/file.json", "new-ref"). + Return("test-resource", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "other/file.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion tests folder cleanup prevention +func TestIncrementalSync_HierarchicalErrorHandling_FailedFileDeletion(t *testing.T) { + mockVersioned := repository.NewMockVersioned(t) + mockReader := repository.NewMockReader(t) + repo := &compositeRepoForTest{MockVersioned: mockVersioned, MockReader: mockReader} + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionDeleted, Path: "dashboards/file1.json", PreviousRef: "old-ref"}, + } + + mockVersioned.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 1).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + // Deletions don't check HasDirPathFailedCreation, they go straight to removal + repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/file1.json", "old-ref"). + Return("dashboard-1", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard"}, fmt.Errorf("permission denied")).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "dashboards/file1.json" && r.Action == repository.FileActionDeleted && + r.Error != nil && r.Error.Error() == "removing resource from file dashboards/file1.json: permission denied" + })).Return().Once() + + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(true).Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) + repoResources.AssertNotCalled(t, "RemoveFolder", mock.Anything, mock.Anything) +} + +// TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure tests deletions proceed despite creation failures +func TestIncrementalSync_HierarchicalErrorHandling_DeletionNotAffectedByCreationFailure(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "folder1/file.json", Ref: "new-ref"}, + {Action: repository.FileActionDeleted, Path: "folder1/old.json", PreviousRef: "old-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 2).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + // Creation fails + progress.On("HasDirPathFailedCreation", "folder1/file.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "folder1/file.json", "new-ref"). + Return("", schema.GroupVersionKind{}, &resources.PathCreationError{Path: "folder1/", Err: fmt.Errorf("permission denied")}).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/file.json" && r.Error != nil + })).Return().Once() + + // Deletion should NOT be skipped (not checking HasDirPathFailedCreation for deletions) + // Deletions don't check HasDirPathFailedCreation, they go straight to removal + repoResources.On("RemoveResourceFromFile", mock.Anything, "folder1/old.json", "old-ref"). + Return("old-resource", "", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "folder1/old.json" && r.Action == repository.FileActionDeleted && r.Error == nil + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting tests multi-level cascade +func TestIncrementalSync_HierarchicalErrorHandling_MultiLevelNesting(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "level1/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/file.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "level1/level2/level3/file.txt", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 3).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + folderErr := &resources.PathCreationError{Path: "level1/", Err: fmt.Errorf("permission denied")} + // First check is before it fails. + progress.On("HasDirPathFailedCreation", "level1/file.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "level1/").Return("", folderErr).Once() + + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/file.txt" && r.Action == repository.FileActionIgnored && r.Error != nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "level1/level2/file.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/level2/file.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "level1/level2/level3/file.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "level1/level2/level3/file.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure tests partial failures +func TestIncrementalSync_HierarchicalErrorHandling_MixedSuccessAndFailure(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionCreated, Path: "success/file1.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "success/nested/file2.json", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/file3.txt", Ref: "new-ref"}, + {Action: repository.FileActionCreated, Path: "failure/nested/file4.txt", Ref: "new-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 4).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + progress.On("HasDirPathFailedCreation", "success/file1.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/file1.json", "new-ref"). + Return("resource-1", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/file1.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "success/nested/file2.json").Return(false).Once() + repoResources.On("WriteResourceFromFile", mock.Anything, "success/nested/file2.json", "new-ref"). + Return("resource-2", schema.GroupVersionKind{Kind: "Dashboard"}, nil).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "success/nested/file2.json" && r.Action == repository.FileActionCreated && r.Error == nil + })).Return().Once() + + folderErr := &resources.PathCreationError{Path: "failure/", Err: fmt.Errorf("disk full")} + progress.On("HasDirPathFailedCreation", "failure/file3.txt").Return(false).Once() + repoResources.On("EnsureFolderPathExist", mock.Anything, "failure/").Return("", folderErr).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/file3.txt" && r.Action == repository.FileActionIgnored + })).Return().Once() + + progress.On("HasDirPathFailedCreation", "failure/nested/file4.txt").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "failure/nested/file4.txt" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) + repoResources.AssertExpectations(t) +} + +// TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation tests rename operations affected by folder failures +func TestIncrementalSync_HierarchicalErrorHandling_RenameWithFailedFolderCreation(t *testing.T) { + repo := repository.NewMockVersioned(t) + repoResources := resources.NewMockRepositoryResources(t) + progress := jobs.NewMockJobProgressRecorder(t) + + changes := []repository.VersionedFileChange{ + {Action: repository.FileActionRenamed, Path: "newfolder/file.json", PreviousPath: "oldfolder/file.json", Ref: "new-ref", PreviousRef: "old-ref"}, + } + + repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) + progress.On("SetTotal", mock.Anything, 1).Return() + progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + progress.On("TooManyErrors").Return(nil).Maybe() + + progress.On("HasDirPathFailedCreation", "newfolder/file.json").Return(true).Once() + progress.On("Record", mock.Anything, mock.MatchedBy(func(r jobs.JobResourceResult) bool { + return r.Path == "newfolder/file.json" && r.Action == repository.FileActionIgnored && + r.Warning != nil && r.Warning.Error() == "resource was not processed because the parent folder could not be created" + })).Return().Once() + + err := IncrementalSync(context.Background(), repo, "old-ref", "new-ref", repoResources, progress, tracing.NewNoopTracerService(), jobs.RegisterJobMetrics(prometheus.NewPedanticRegistry())) + require.NoError(t, err) + progress.AssertExpectations(t) +} diff --git a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go index f694d7f5068..38c537635cf 100644 --- a/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go +++ b/pkg/registry/apis/provisioning/jobs/sync/incremental_test.go @@ -92,6 +92,10 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation checks + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) + progress.On("HasDirPathFailedCreation", "alerts/alert.yaml").Return(false) + // Mock successful resource writes repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref"). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -127,6 +131,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false) + // Mock folder creation repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/"). Return("test-folder", nil) @@ -161,6 +168,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", ".unsupported/path/file.txt").Return(false) + progress.On("Record", mock.Anything, jobs.JobResourceResult{ Action: repository.FileActionIgnored, Path: ".unsupported/path/file.txt", @@ -222,6 +232,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/new.json").Return(false) + // Mock resource rename repoResources.On("RenameResourceFile", mock.Anything, "dashboards/old.json", "old-ref", "dashboards/new.json", "new-ref"). Return("renamed-dashboard", "", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) @@ -254,6 +267,10 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/ignored.json").Return(false) + progress.On("Record", mock.Anything, jobs.JobResourceResult{ Action: repository.FileActionIgnored, Path: "dashboards/ignored.json", @@ -277,16 +294,28 @@ func TestIncrementalSync(t *testing.T) { repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() + progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "unsupported/path/file.txt").Return(false) // Mock folder creation error repoResources.On("EnsureFolderPathExist", mock.Anything, "unsupported/path/"). Return("", fmt.Errorf("failed to create folder")) + // Mock progress recording with error + progress.On("Record", mock.Anything, mock.MatchedBy(func(result jobs.JobResourceResult) bool { + return result.Action == repository.FileActionIgnored && + result.Path == "unsupported/path/file.txt" && + result.Error != nil && + result.Error.Error() == "failed to create folder" + })).Return() + progress.On("TooManyErrors").Return(nil) }, previousRef: "old-ref", currentRef: "new-ref", - expectedError: "unable to create empty file folder: failed to create folder", + expectedCalls: 1, }, { name: "error writing resource", @@ -303,6 +332,9 @@ func TestIncrementalSync(t *testing.T) { progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() + // Mock HasDirPathFailedCreation check + progress.On("HasDirPathFailedCreation", "dashboards/test.json").Return(false) + // Mock resource write error repoResources.On("WriteResourceFromFile", mock.Anything, "dashboards/test.json", "new-ref"). Return("test-dashboard", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, fmt.Errorf("write failed")) @@ -372,7 +404,8 @@ func TestIncrementalSync(t *testing.T) { repo.On("CompareFiles", mock.Anything, "old-ref", "new-ref").Return(changes, nil) progress.On("SetTotal", mock.Anything, 1).Return() progress.On("SetMessage", mock.Anything, "replicating versioned changes").Return() - // Mock too many errors + + // Mock too many errors - this is checked before processing files, so HasDirPathFailedCreation won't be called progress.On("TooManyErrors").Return(fmt.Errorf("too many errors occurred")) }, previousRef: "old-ref", @@ -428,6 +461,9 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref"). Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) + // Mock HasDirPathFailedDeletion check for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + // if the folder is not found in git, there should be a call to remove the folder from grafana repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) @@ -453,6 +489,10 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { progress.On("SetMessage", mock.Anything, "versioned changes replicated").Return() repoResources.On("RemoveResourceFromFile", mock.Anything, "dashboards/old.json", "old-ref"). Return("old-dashboard", "folder-uid", schema.GroupVersionKind{Kind: "Dashboard", Group: "dashboards"}, nil) + + // Mock HasDirPathFailedDeletion check for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + // if the folder still exists in git, there should not be a call to delete it from grafana repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return(&repository.FileInfo{}, nil) @@ -485,6 +525,13 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { repoResources.On("RemoveResourceFromFile", mock.Anything, "alerts/old-alert.yaml", "old-ref"). Return("old-alert", "folder-uid-2", schema.GroupVersionKind{Kind: "Alert", Group: "alerts"}, nil) + progress.On("Record", mock.Anything, mock.Anything).Return() + progress.On("TooManyErrors").Return(nil) + + // Mock HasDirPathFailedDeletion checks for cleanup + progress.On("HasDirPathFailedDeletion", "dashboards/").Return(false) + progress.On("HasDirPathFailedDeletion", "alerts/").Return(false) + // both not found in git, both should be deleted repo.MockReader.On("Read", mock.Anything, "dashboards/", ""). Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) @@ -492,9 +539,6 @@ func TestIncrementalSync_CleanupOrphanedFolders(t *testing.T) { Return((*repository.FileInfo)(nil), repository.ErrFileNotFound) repoResources.On("RemoveFolder", mock.Anything, "folder-uid-1").Return(nil) repoResources.On("RemoveFolder", mock.Anything, "folder-uid-2").Return(nil) - - progress.On("Record", mock.Anything, mock.Anything).Return() - progress.On("TooManyErrors").Return(nil) }, }, } diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 26e97f56b3c..901bc829c97 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -30,7 +30,7 @@ import ( provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/auth" - connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection" + "github.com/grafana/grafana/apps/provisioning/pkg/connection" appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller" clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1" @@ -105,20 +105,21 @@ type APIBuilder struct { jobs.Queue jobs.Store } - jobHistoryConfig *JobHistoryConfig - jobHistoryLoki *jobs.LokiJobHistory - resourceLister resources.ResourceLister - dashboardAccess legacy.MigrationDashboardAccessor - unified resource.ResourceClient - repoFactory repository.Factory - client client.ProvisioningV0alpha1Interface - access auth.AccessChecker - accessWithAdmin auth.AccessChecker - accessWithEditor auth.AccessChecker - accessWithViewer auth.AccessChecker - statusPatcher *appcontroller.RepositoryStatusPatcher - healthChecker *controller.HealthChecker - validator repository.RepositoryValidator + jobHistoryConfig *JobHistoryConfig + jobHistoryLoki *jobs.LokiJobHistory + resourceLister resources.ResourceLister + dashboardAccess legacy.MigrationDashboardAccessor + unified resource.ResourceClient + repoFactory repository.Factory + connectionFactory connection.Factory + client client.ProvisioningV0alpha1Interface + access auth.AccessChecker + accessWithAdmin auth.AccessChecker + accessWithEditor auth.AccessChecker + accessWithViewer auth.AccessChecker + statusPatcher *appcontroller.RepositoryStatusPatcher + healthChecker *controller.HealthChecker + repoValidator repository.RepositoryValidator // Extras provides additional functionality to the API. extras []Extra extraWorkers []jobs.Worker @@ -133,6 +134,7 @@ type APIBuilder struct { func NewAPIBuilder( onlyApiServer bool, repoFactory repository.Factory, + connectionFactory connection.Factory, features featuremgmt.FeatureToggles, unified resource.ResourceClient, configProvider apiserver.RestConfigProvider, @@ -176,6 +178,7 @@ func NewAPIBuilder( usageStats: usageStats, features: features, repoFactory: repoFactory, + connectionFactory: connectionFactory, clients: clients, parsers: parsers, repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister), @@ -192,7 +195,7 @@ func NewAPIBuilder( allowedTargets: allowedTargets, allowImageRendering: allowImageRendering, registry: registry, - validator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering), + repoValidator: repository.NewValidator(minSyncInterval, allowedTargets, allowImageRendering), useExclusivelyAccessCheckerForAuthz: useExclusivelyAccessCheckerForAuthz, } @@ -253,6 +256,7 @@ func RegisterAPIService( extraBuilders []ExtraBuilder, extraWorkers []jobs.Worker, repoFactory repository.Factory, + connectionFactory connection.Factory, ) (*APIBuilder, error) { //nolint:staticcheck // not yet migrated to OpenFeature if !features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { @@ -271,6 +275,7 @@ func RegisterAPIService( builder := NewAPIBuilder( cfg.DisableControllers, repoFactory, + connectionFactory, features, client, configProvider, @@ -480,6 +485,16 @@ func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a autho Namespace: a.GetNamespace(), }, "")) + // Repositories is read-only + case "repositories": + return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{ + Verb: apiutils.VerbGet, + Group: provisioning.GROUP, + Resource: provisioning.ConnectionResourceInfo.GetName(), + Name: a.GetName(), + Namespace: a.GetNamespace(), + }, "")) + default: id, err := identity.GetRequester(ctx) if err != nil { @@ -549,6 +564,22 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error { return err } + // Register custom field label conversion for Repository to enable field selectors like spec.connection.name + err = scheme.AddFieldLabelConversionFunc( + provisioning.SchemeGroupVersion.WithKind("Repository"), + func(label, value string) (string, string, error) { + switch label { + case "metadata.name", "metadata.namespace", "spec.connection.name": + return label, value, nil + default: + return "", "", fmt.Errorf("field label not supported for Repository: %s", label) + } + }, + ) + if err != nil { + return err + } + metav1.AddToGroupVersion(scheme, provisioning.SchemeGroupVersion) // Only 1 version (for now?) return scheme.SetVersionPriority(provisioning.SchemeGroupVersion) @@ -559,10 +590,19 @@ func (b *APIBuilder) AllowedV0Alpha1Resources() []string { } func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { - repositoryStorage, err := grafanaregistry.NewRegistryStore(opts.Scheme, provisioning.RepositoryResourceInfo, opts.OptsGetter) + // Create repository storage with custom field selectors (e.g., spec.connection.name) + repositoryStorage, err := grafanaregistry.NewRegistryStoreWithSelectableFields( + opts.Scheme, + provisioning.RepositoryResourceInfo, + opts.OptsGetter, + grafanaregistry.SelectableFieldsOptions{ + GetAttrs: RepositoryGetAttrs, + }, + ) if err != nil { return fmt.Errorf("failed to create repository storage: %w", err) } + repositoryStatusStorage := grafanaregistry.NewRegistryStatusStore(opts.Scheme, repositoryStorage) b.store = repositoryStorage @@ -603,9 +643,10 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI storage[provisioning.ConnectionResourceInfo.StoragePath()] = connectionsStore storage[provisioning.ConnectionResourceInfo.StoragePath("status")] = connectionStatusStorage + storage[provisioning.ConnectionResourceInfo.StoragePath("repositories")] = NewConnectionRepositoriesConnector() // TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place - storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories)) + storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.repoValidator), b.VerifyAgainstExistingRepositories)) storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.accessWithAdmin) storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b) storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{ @@ -646,10 +687,15 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis if ok { return nil } - // TODO: complete this as part of https://github.com/grafana/git-ui-sync-project/issues/700 + c, ok := obj.(*provisioning.Connection) if ok { - return connectionvalidation.MutateConnection(c) + conn, err := b.asConnection(ctx, c, nil) + if err != nil { + return err + } + + return conn.Mutate(ctx) } r, ok := obj.(*provisioning.Repository) @@ -700,9 +746,15 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm return nil } - connection, ok := obj.(*provisioning.Connection) + // Validate connections + c, ok := obj.(*provisioning.Connection) if ok { - return connectionvalidation.ValidateConnection(connection) + conn, err := b.asConnection(ctx, c, a.GetOldObject()) + if err != nil { + return err + } + + return conn.Validate(ctx) } // Validate Jobs @@ -722,7 +774,7 @@ func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o adm // the only time to add configuration checks here is if you need to compare // the incoming change to the current configuration isCreate := a.GetOperation() == admission.Create - list := b.validator.ValidateRepository(repo, isCreate) + list := b.repoValidator.ValidateRepository(repo, isCreate) cfg := repo.Config() if a.GetOperation() == admission.Update { @@ -795,7 +847,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH } b.statusPatcher = appcontroller.NewRepositoryStatusPatcher(b.GetClient()) - b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.validator)) + b.healthChecker = controller.NewHealthChecker(b.statusPatcher, b.registry, repository.NewSimpleRepositoryTester(b.repoValidator)) // if running solely CRUD, skip the rest of the setup if b.onlyApiServer { @@ -806,8 +858,10 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH sharedInformerFactory := informers.NewSharedInformerFactory(c, 60*time.Second) repoInformer := sharedInformerFactory.Provisioning().V0alpha1().Repositories() jobInformer := sharedInformerFactory.Provisioning().V0alpha1().Jobs() + connInformer := sharedInformerFactory.Provisioning().V0alpha1().Connections() go repoInformer.Informer().Run(postStartHookCtx.Done()) go jobInformer.Informer().Run(postStartHookCtx.Done()) + go connInformer.Informer().Run(postStartHookCtx.Done()) // Create the repository resources factory repositoryListerWrapper := func(ctx context.Context) ([]provisioning.Repository, error) { @@ -928,6 +982,18 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH go repoController.Run(postStartHookCtx.Context, repoControllerWorkers) + // Create and run connection controller + connStatusPatcher := appcontroller.NewConnectionStatusPatcher(b.GetClient()) + connController, err := controller.NewConnectionController( + b.GetClient(), + connInformer, + connStatusPatcher, + ) + if err != nil { + return err + } + go connController.Run(postStartHookCtx.Context, repoControllerWorkers) + // If Loki not used, initialize the API client-based history writer and start the controller for history jobs if b.jobHistoryLoki == nil { // Create HistoryJobController for cleanup of old job history entries @@ -1247,6 +1313,23 @@ spec: oas.Paths.Paths[repoprefix+"/jobs/{uid}"] = sub } + // Document connection repositories endpoint + connectionprefix := root + "namespaces/{namespace}/connections/{name}" + sub = oas.Paths.Paths[connectionprefix+"/repositories"] + if sub != nil { + sub.Get.Description = "List repositories available from the external git provider through this connection" + sub.Get.Summary = "List external repositories" + sub.Get.Parameters = []*spec3.Parameter{} + sub.Post = nil + sub.Put = nil + sub.Delete = nil + + // Replace the content type for this response + mt := sub.Get.Responses.StatusCodeResponses[200].Content + s := defs[defsBase+"ExternalRepositoryList"].Schema + mt["*/*"].Schema = &s + } + // Run all extra post-processors. for _, extra := range b.extras { if err := extra.PostProcessOpenAPI(oas); err != nil { @@ -1382,6 +1465,35 @@ func (b *APIBuilder) asRepository(ctx context.Context, obj runtime.Object, old r return b.repoFactory.Build(ctx, r) } +func (b *APIBuilder) asConnection(ctx context.Context, obj runtime.Object, old runtime.Object) (connection.Connection, error) { + if obj == nil { + return nil, fmt.Errorf("missing connection object") + } + + c, ok := obj.(*provisioning.Connection) + if !ok { + return nil, fmt.Errorf("expected connection object") + } + + // Copy previous values if they exist + if old != nil { + o, ok := old.(*provisioning.Connection) + if ok && !o.Secure.IsZero() { + if c.Secure.PrivateKey.IsZero() { + c.Secure.PrivateKey = o.Secure.PrivateKey + } + if c.Secure.Token.IsZero() { + c.Secure.Token = o.Secure.Token + } + if c.Secure.ClientSecret.IsZero() { + c.Secure.ClientSecret = o.Secure.ClientSecret + } + } + } + + return b.connectionFactory.Build(ctx, c) +} + func getJSONResponse(ref string) *spec3.Responses { return &spec3.Responses{ ResponsesProps: spec3.ResponsesProps{ diff --git a/pkg/registry/apis/provisioning/register_validate_test.go b/pkg/registry/apis/provisioning/register_validate_test.go index 18b366e4de8..f612d3a408c 100644 --- a/pkg/registry/apis/provisioning/register_validate_test.go +++ b/pkg/registry/apis/provisioning/register_validate_test.go @@ -28,7 +28,7 @@ func TestAPIBuilderValidate(t *testing.T) { repoFactory: factory, allowedTargets: []v0alpha1.SyncTargetType{v0alpha1.SyncTargetTypeFolder}, allowImageRendering: false, - validator: validator, + repoValidator: validator, } t.Run("min sync interval is less than 10 seconds", func(t *testing.T) { diff --git a/pkg/registry/apis/provisioning/repository_fields.go b/pkg/registry/apis/provisioning/repository_fields.go new file mode 100644 index 00000000000..0849c558e2f --- /dev/null +++ b/pkg/registry/apis/provisioning/repository_fields.go @@ -0,0 +1,44 @@ +package provisioning + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +// RepositoryToSelectableFields returns a field set that can be used for field selectors. +// This includes standard metadata fields plus custom fields like spec.connection.name. +func RepositoryToSelectableFields(obj *provisioning.Repository) fields.Set { + objectMetaFields := generic.ObjectMetaFieldsSet(&obj.ObjectMeta, true) + + // Add custom selectable fields + specificFields := fields.Set{ + "spec.connection.name": getConnectionName(obj), + } + + return generic.MergeFieldsSets(objectMetaFields, specificFields) +} + +// getConnectionName safely extracts the connection name from a Repository. +// Returns empty string if no connection is configured. +func getConnectionName(obj *provisioning.Repository) string { + if obj == nil || obj.Spec.Connection == nil { + return "" + } + return obj.Spec.Connection.Name +} + +// RepositoryGetAttrs returns labels and fields of a Repository object. +// This is used by the storage layer for filtering. +func RepositoryGetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + repo, ok := obj.(*provisioning.Repository) + if !ok { + return nil, nil, fmt.Errorf("given object is not a Repository") + } + return labels.Set(repo.Labels), RepositoryToSelectableFields(repo), nil +} diff --git a/pkg/registry/apis/provisioning/repository_fields_test.go b/pkg/registry/apis/provisioning/repository_fields_test.go new file mode 100644 index 00000000000..89a2271477c --- /dev/null +++ b/pkg/registry/apis/provisioning/repository_fields_test.go @@ -0,0 +1,184 @@ +package provisioning + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" +) + +func TestGetConnectionName(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expected string + }{ + { + name: "nil repository returns empty string", + repo: nil, + expected: "", + }, + { + name: "repository without connection returns empty string", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + }, + }, + expected: "", + }, + { + name: "repository with connection returns connection name", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + Connection: &provisioning.ConnectionInfo{ + Name: "my-connection", + }, + }, + }, + expected: "my-connection", + }, + { + name: "repository with empty connection name returns empty string", + repo: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Title: "test-repo", + Connection: &provisioning.ConnectionInfo{ + Name: "", + }, + }, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getConnectionName(tt.repo) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestRepositoryToSelectableFields(t *testing.T) { + tests := []struct { + name string + repo *provisioning.Repository + expectedFields map[string]string + }{ + { + name: "includes metadata.name and metadata.namespace", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + }, + }, + expectedFields: map[string]string{ + "metadata.name": "test-repo", + "metadata.namespace": "default", + "spec.connection.name": "", + }, + }, + { + name: "includes spec.connection.name when set", + repo: &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "repo-with-connection", + Namespace: "org-1", + }, + Spec: provisioning.RepositorySpec{ + Title: "Repo With Connection", + Connection: &provisioning.ConnectionInfo{ + Name: "github-connection", + }, + }, + }, + expectedFields: map[string]string{ + "metadata.name": "repo-with-connection", + "metadata.namespace": "org-1", + "spec.connection.name": "github-connection", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := RepositoryToSelectableFields(tt.repo) + + for key, expectedValue := range tt.expectedFields { + actualValue, exists := fields[key] + assert.True(t, exists, "field %s should exist", key) + assert.Equal(t, expectedValue, actualValue, "field %s should have correct value", key) + } + }) + } +} + +func TestRepositoryGetAttrs(t *testing.T) { + t.Run("returns error for non-Repository object", func(t *testing.T) { + // Pass a different runtime.Object type instead of a Repository + connection := &provisioning.Connection{ + ObjectMeta: metav1.ObjectMeta{ + Name: "not-a-repository", + }, + } + _, _, err := RepositoryGetAttrs(connection) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a Repository") + }) + + t.Run("returns labels and fields for valid Repository", func(t *testing.T) { + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + Labels: map[string]string{ + "app": "grafana", + "env": "test", + }, + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + Connection: &provisioning.ConnectionInfo{ + Name: "my-connection", + }, + }, + } + + labels, fields, err := RepositoryGetAttrs(repo) + require.NoError(t, err) + + // Check labels + assert.Equal(t, "grafana", labels["app"]) + assert.Equal(t, "test", labels["env"]) + + // Check fields + assert.Equal(t, "test-repo", fields["metadata.name"]) + assert.Equal(t, "default", fields["metadata.namespace"]) + assert.Equal(t, "my-connection", fields["spec.connection.name"]) + }) + + t.Run("returns empty connection name when not set", func(t *testing.T) { + repo := &provisioning.Repository{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-repo", + Namespace: "default", + }, + Spec: provisioning.RepositorySpec{ + Title: "Test Repository", + }, + } + + _, fields, err := RepositoryGetAttrs(repo) + require.NoError(t, err) + assert.Equal(t, "", fields["spec.connection.name"]) + }) +} diff --git a/pkg/registry/apis/provisioning/resources/folders.go b/pkg/registry/apis/provisioning/resources/folders.go index 8b8f4201745..b4a06f78691 100644 --- a/pkg/registry/apis/provisioning/resources/folders.go +++ b/pkg/registry/apis/provisioning/resources/folders.go @@ -20,6 +20,21 @@ import ( const MaxNumberOfFolders = 10000 +// PathCreationError represents an error that occurred while creating a folder path. +// It contains the path that failed and the underlying error. +type PathCreationError struct { + Path string + Err error +} + +func (e *PathCreationError) Unwrap() error { + return e.Err +} + +func (e *PathCreationError) Error() string { + return fmt.Sprintf("failed to create path %s: %v", e.Path, e.Err) +} + type FolderManager struct { repo repository.ReaderWriter tree FolderTree @@ -73,7 +88,11 @@ func (fm *FolderManager) EnsureFolderPathExist(ctx context.Context, filePath str } if err := fm.EnsureFolderExists(ctx, f, parent); err != nil { - return fmt.Errorf("ensure folder exists: %w", err) + // Wrap in PathCreationError to indicate which path failed + return &PathCreationError{ + Path: f.Path, + Err: fmt.Errorf("ensure folder exists: %w", err), + } } fm.tree.Add(f, parent) diff --git a/pkg/registry/apis/provisioning/resources/folders_test.go b/pkg/registry/apis/provisioning/resources/folders_test.go new file mode 100644 index 00000000000..ed593a7d26c --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/folders_test.go @@ -0,0 +1,68 @@ +package resources_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" + "github.com/stretchr/testify/require" +) + +func TestPathCreationError(t *testing.T) { + t.Run("Error method returns formatted message", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + expectedMsg := "failed to create path grafana/folder-1: underlying error" + require.Equal(t, expectedMsg, pathErr.Error()) + }) + + t.Run("Unwrap returns underlying error", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + unwrapped := pathErr.Unwrap() + require.Equal(t, underlyingErr, unwrapped) + require.EqualError(t, unwrapped, "underlying error") + }) + + t.Run("errors.Is finds underlying error", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + require.True(t, errors.Is(pathErr, underlyingErr)) + require.False(t, errors.Is(pathErr, fmt.Errorf("different error"))) + }) + + t.Run("errors.As extracts PathCreationError", func(t *testing.T) { + underlyingErr := fmt.Errorf("underlying error") + pathErr := &resources.PathCreationError{ + Path: "grafana/folder-1", + Err: underlyingErr, + } + + var extractedErr *resources.PathCreationError + require.True(t, errors.As(pathErr, &extractedErr)) + require.NotNil(t, extractedErr) + require.Equal(t, "grafana/folder-1", extractedErr.Path) + require.Equal(t, underlyingErr, extractedErr.Err) + }) + + t.Run("errors.As returns false for non-PathCreationError", func(t *testing.T) { + regularErr := fmt.Errorf("regular error") + + var extractedErr *resources.PathCreationError + require.False(t, errors.As(regularErr, &extractedErr)) + require.Nil(t, extractedErr) + }) +} diff --git a/pkg/registry/apis/secret/contracts/keeper.go b/pkg/registry/apis/secret/contracts/keeper.go index 1e8f5e2acfb..584deea009e 100644 --- a/pkg/registry/apis/secret/contracts/keeper.go +++ b/pkg/registry/apis/secret/contracts/keeper.go @@ -9,6 +9,11 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" ) +const ( + // This constant can be used as a key in resource tags + GrafanaSecretsManagerName = "grafana-secrets-manager" +) + var ( // The name used to refer to the system keeper SystemKeeperName = "system" @@ -102,8 +107,8 @@ func (s ExternalID) String() string { // Keeper is the interface for secret keepers. type Keeper interface { Store(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64, exposedValueOrRef string) (ExternalID, error) - Update(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64, exposedValueOrRef string) error Expose(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) (secretv1beta1.ExposedSecureValue, error) + RetrieveReference(ctx context.Context, cfg secretv1beta1.KeeperConfig, ref string) (secretv1beta1.ExposedSecureValue, error) Delete(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) error } diff --git a/pkg/registry/apis/secret/contracts/secure_value.go b/pkg/registry/apis/secret/contracts/secure_value.go index 09702fa7f5f..8c3d8988433 100644 --- a/pkg/registry/apis/secret/contracts/secure_value.go +++ b/pkg/registry/apis/secret/contracts/secure_value.go @@ -21,8 +21,10 @@ type DecryptSecureValue struct { } var ( - ErrSecureValueNotFound = errors.New("secure value not found") - ErrSecureValueAlreadyExists = errors.New("secure value already exists") + ErrSecureValueNotFound = errors.New("secure value not found") + ErrSecureValueAlreadyExists = errors.New("secure value already exists") + ErrReferenceWithSystemKeeper = errors.New("tried to create secure value using reference with system keeper, references can only be used with 3rd party keepers") + ErrSecureValueMissingSecretAndRef = errors.New("secure value spec doesn't have neither a secret or reference") ) type ReadOpts struct { diff --git a/pkg/registry/apis/secret/garbagecollectionworker/worker.go b/pkg/registry/apis/secret/garbagecollectionworker/worker.go index b967b1f21a1..2fab7562163 100644 --- a/pkg/registry/apis/secret/garbagecollectionworker/worker.go +++ b/pkg/registry/apis/secret/garbagecollectionworker/worker.go @@ -103,9 +103,12 @@ func (w *Worker) Cleanup(ctx context.Context, sv *secretv1beta1.SecureValue) err return fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Status.Keeper, err) } - // Keeper deletion is idempotent - if err := keeper.Delete(ctx, keeperCfg, xkube.Namespace(sv.Namespace), sv.Name, sv.Status.Version); err != nil { - return fmt.Errorf("deleting secure value from keeper: %w", err) + // If the secure value doesn't use a reference, delete the secret + if sv.Spec.Ref == nil { + // Keeper deletion is idempotent + if err := keeper.Delete(ctx, keeperCfg, xkube.Namespace(sv.Namespace), sv.Name, sv.Status.Version); err != nil { + return fmt.Errorf("deleting secure value from keeper: %w", err) + } } // Metadata deletion is not idempotent but not found errors are ignored diff --git a/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go b/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go index 9b27aceefc1..d8ef76b50a0 100644 --- a/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go +++ b/pkg/registry/apis/secret/garbagecollectionworker/worker_test.go @@ -1,7 +1,6 @@ package garbagecollectionworker_test import ( - "slices" "testing" "time" @@ -97,27 +96,33 @@ func TestBasic(t *testing.T) { require.NoError(t, sut.GarbageCollectionWorker.Cleanup(t.Context(), sv)) require.NoError(t, sut.GarbageCollectionWorker.Cleanup(t.Context(), sv)) }) -} -var ( - decryptersGen = rapid.SampledFrom([]string{"svc1", "svc2", "svc3", "svc4", "svc5"}) - nameGen = rapid.SampledFrom([]string{"n1", "n2", "n3", "n4", "n5"}) - namespaceGen = rapid.SampledFrom([]string{"ns1", "ns2", "ns3", "ns4", "ns5"}) - anySecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { - return &secretv1beta1.SecureValue{ + t.Run("cleaning up secure values that use references", func(t *testing.T) { + sut := testutils.Setup(t) + + keeper, err := sut.CreateAWSKeeper(t.Context()) + require.NoError(t, err) + + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + + sv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(&secretv1beta1.SecureValue{ ObjectMeta: metav1.ObjectMeta{ - Name: nameGen.Draw(t, "name"), - Namespace: namespaceGen.Draw(t, "ns"), + Namespace: keeper.Namespace, + Name: "sv1", }, Spec: secretv1beta1.SecureValueSpec{ - Description: rapid.SampledFrom([]string{"d1", "d2", "d3", "d4", "d5"}).Draw(t, "description"), - Value: ptr.To(secretv1beta1.NewExposedSecureValue(rapid.SampledFrom([]string{"v1", "v2", "v3", "v4", "v5"}).Draw(t, "value"))), - Decrypters: rapid.SliceOfDistinct(decryptersGen, func(v string) string { return v }).Draw(t, "decrypters"), + Description: "desc1", + Ref: ptr.To("ref1"), + Decrypters: []string{"decrypter1"}, }, - Status: secretv1beta1.SecureValueStatus{}, - } + })) + require.NoError(t, err) + + _, err = sut.DeleteSv(t.Context(), sv.Namespace, sv.Name) + require.NoError(t, err) + require.NoError(t, sut.GarbageCollectionWorker.Cleanup(t.Context(), sv)) }) -) +} func TestProperty(t *testing.T) { t.Parallel() @@ -126,26 +131,59 @@ func TestProperty(t *testing.T) { rapid.Check(t, func(t *rapid.T) { sut := testutils.Setup(tt) - model := newModel() + model := testutils.NewModelGsm(nil) t.Repeat(map[string]func(*rapid.T){ "create": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") + var sv *secretv1beta1.SecureValue + if rapid.Bool().Draw(t, "withRef") { + sv = testutils.AnySecureValueWithRefGen.Draw(t, "sv") + } else { + sv = testutils.AnySecureValueGen.Draw(t, "sv") + } + svCopy := sv.DeepCopy() createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv)) - svCopy.UID = createdSv.UID - modelErr := model.create(sut.Clock.Now(), svCopy) + if err == nil { + svCopy.UID = createdSv.UID + } + _, modelErr := model.Create(sut.Clock.Now(), svCopy) require.ErrorIs(t, err, modelErr) }, + "createKeeper": func(t *rapid.T) { + input := testutils.AnyKeeperGen.Draw(t, "keeper") + modelKeeper, modelErr := model.CreateKeeper(input) + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), input, "actor-uid") + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + require.Equal(t, modelKeeper.Name, keeper.Name) + }, + "setKeeperAsActive": func(t *rapid.T) { + namespace := testutils.NamespaceGen.Draw(t, "namespace") + var keeper string + if rapid.Bool().Draw(t, "systemKeeper") { + keeper = contracts.SystemKeeperName + } else { + keeper = testutils.KeeperNameGen.Draw(t, "keeper") + } + modelErr := model.SetKeeperAsActive(namespace, keeper) + err := sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(namespace), keeper) + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + }, "delete": func(t *rapid.T) { - if len(model.items) == 0 { + if len(model.SecureValues) == 0 { return } - i := rapid.IntRange(0, len(model.items)-1).Draw(t, "index") - sv := model.items[i] - modelErr := model.delete(sv.Namespace, sv.Name) + i := rapid.IntRange(0, len(model.SecureValues)-1).Draw(t, "index") + sv := model.SecureValues[i] + _, modelErr := model.Delete(sv.Namespace, sv.Name) _, err := sut.DeleteSv(t.Context(), sv.Namespace, sv.Name) require.ErrorIs(t, err, modelErr) }, @@ -153,7 +191,7 @@ func TestProperty(t *testing.T) { // Taken from secureValueMetadataStorage.acquireLeases minAge := 300 * time.Second maxBatchSize := sut.GarbageCollectionWorker.Cfg.SecretsManagement.GCWorkerMaxBatchSize - modelDeleted, modelErr := model.cleanupInactiveSecureValues(sut.Clock.Now(), minAge, maxBatchSize) + modelDeleted, modelErr := model.CleanupInactiveSecureValues(sut.Clock.Now(), minAge, maxBatchSize) deleted, err := sut.GarbageCollectionWorker.CleanupInactiveSecureValues(t.Context()) require.ErrorIs(t, err, modelErr) @@ -174,77 +212,3 @@ func TestProperty(t *testing.T) { }) }) } - -type model struct { - items []*modelSecureValue -} - -type modelSecureValue struct { - *secretv1beta1.SecureValue - active bool - created time.Time -} - -func newModel() *model { - return &model{ - items: make([]*modelSecureValue, 0), - } -} - -func (m *model) create(now time.Time, sv *secretv1beta1.SecureValue) error { - created := now - for _, item := range m.items { - if item.active && item.Namespace == sv.Namespace && item.Name == sv.Name { - item.active = false - created = item.created - break - } - } - m.items = append(m.items, &modelSecureValue{SecureValue: sv, active: true, created: created}) - return nil -} - -func (m *model) delete(ns string, name string) error { - for _, sv := range m.items { - if sv.active && sv.Namespace == ns && sv.Name == name { - sv.active = false - return nil - } - } - - return contracts.ErrSecureValueNotFound -} - -func (m *model) cleanupInactiveSecureValues(now time.Time, minAge time.Duration, maxBatchSize uint16) ([]*modelSecureValue, error) { - // Using a slice to allow duplicates - toDelete := make([]*modelSecureValue, 0) - - // The implementation query sorts by created time ascending - slices.SortFunc(m.items, func(a, b *modelSecureValue) int { - if a.created.Before(b.created) { - return -1 - } else if a.created.After(b.created) { - return 1 - } - return 0 - }) - - for _, sv := range m.items { - if len(toDelete) >= int(maxBatchSize) { - break - } - - if !sv.active && now.Sub(sv.created) > minAge { - toDelete = append(toDelete, sv) - } - } - - // PERF: The slices are always small - m.items = slices.DeleteFunc(m.items, func(v1 *modelSecureValue) bool { - return slices.ContainsFunc(toDelete, func(v2 *modelSecureValue) bool { - return v2.UID == v1.UID - }) - }) - - return toDelete, nil -} diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go index 294eff1a7af..952c9cd9da6 100644 --- a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go +++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper.go @@ -107,6 +107,10 @@ func (s *SQLKeeper) Expose(ctx context.Context, cfg secretv1beta1.KeeperConfig, return exposedValue, nil } +func (s *SQLKeeper) RetrieveReference(ctx context.Context, cfg secretv1beta1.KeeperConfig, ref string) (secretv1beta1.ExposedSecureValue, error) { + return "", fmt.Errorf("reference is not implemented by the SQLKeeper") +} + func (s *SQLKeeper) Delete(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) error { ctx, span := s.tracer.Start(ctx, "SQLKeeper.Delete", trace.WithAttributes( attribute.String("namespace", namespace.String()), @@ -125,27 +129,3 @@ func (s *SQLKeeper) Delete(ctx context.Context, cfg secretv1beta1.KeeperConfig, return nil } - -func (s *SQLKeeper) Update(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64, exposedValueOrRef string) error { - ctx, span := s.tracer.Start(ctx, "SQLKeeper.Update", trace.WithAttributes( - attribute.String("namespace", namespace.String()), - attribute.String("name", name), - attribute.Int64("version", version), - )) - defer span.End() - - start := time.Now() - encryptedData, err := s.encryptionManager.Encrypt(ctx, namespace, []byte(exposedValueOrRef)) - if err != nil { - return fmt.Errorf("unable to encrypt value: %w", err) - } - - err = s.store.Update(ctx, namespace, name, version, encryptedData) - if err != nil { - return fmt.Errorf("failed to update encrypted value: %w", err) - } - - s.metrics.UpdateDuration.WithLabelValues(string(cfg.Type())).Observe(time.Since(start).Seconds()) - - return nil -} diff --git a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go index 34249227790..251f2b1a9f8 100644 --- a/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go +++ b/pkg/registry/apis/secret/secretkeeper/sqlkeeper/keeper_test.go @@ -26,7 +26,7 @@ func Test_SQLKeeperSetup(t *testing.T) { plaintext1 := "very secret string in namespace 1" plaintext2 := "very secret string in namespace 2" - keeperCfg := &secretv1beta1.SystemKeeperConfig{} + keeperCfg := secretv1beta1.NewNamedKeeperConfig("k1", &secretv1beta1.SystemKeeperConfig{}) t.Run("storing an encrypted value returns no error", func(t *testing.T) { sut := testutils.Setup(t) @@ -123,31 +123,6 @@ func Test_SQLKeeperSetup(t *testing.T) { require.NoError(t, err) }) - t.Run("updating an existent encrypted value returns no error", func(t *testing.T) { - sut := testutils.Setup(t) - - _, err := sut.SQLKeeper.Store(t.Context(), keeperCfg, namespace1, name1, version1, plaintext1) - require.NoError(t, err) - - err = sut.SQLKeeper.Update(t.Context(), keeperCfg, namespace1, name1, version1, plaintext2) - require.NoError(t, err) - - exposedVal, err := sut.SQLKeeper.Expose(t.Context(), keeperCfg, namespace1, name1, version1) - require.NoError(t, err) - assert.NotNil(t, exposedVal) - assert.Equal(t, plaintext2, exposedVal.DangerouslyExposeAndConsumeValue()) - }) - - t.Run("updating a non existent encrypted value returns error", func(t *testing.T) { - sut := testutils.Setup(t) - - _, err := sut.SQLKeeper.Store(t.Context(), keeperCfg, namespace1, name1, version1, plaintext1) - require.NoError(t, err) - - err = sut.SQLKeeper.Update(t.Context(), nil, namespace1, "non_existing_name", version1, plaintext2) - require.Error(t, err) - }) - t.Run("data key migration only runs if both secrets db migrations are enabled", func(t *testing.T) { t.Parallel() diff --git a/pkg/registry/apis/secret/service/secure_value.go b/pkg/registry/apis/secret/service/secure_value.go index 30b7f4a625d..c3a01293fae 100644 --- a/pkg/registry/apis/secret/service/secure_value.go +++ b/pkg/registry/apis/secret/service/secure_value.go @@ -141,7 +141,7 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv return nil, false, fmt.Errorf("fetching keeper config: namespace=%+v keeper: %q %w", newSecureValue.Namespace, currentVersion.Status.Keeper, err) } - if newSecureValue.Spec.Value == nil { + if newSecureValue.Spec.Value == nil && newSecureValue.Spec.Ref == nil { keeper, err := s.keeperService.KeeperForConfig(keeperCfg) if err != nil { return nil, false, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", newSecureValue.Namespace, newSecureValue.Status.Keeper, err) @@ -150,7 +150,7 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv secret, err := keeper.Expose(ctx, keeperCfg, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, currentVersion.Status.Version) if err != nil { - return nil, false, fmt.Errorf("reading secret value from keeper: %w", err) + return nil, false, fmt.Errorf("reading secret value from keeper: %w %w", contracts.ErrSecureValueMissingSecretAndRef, err) } newSecureValue.Spec.Value = &secret @@ -174,6 +174,10 @@ func (s *SecureValueService) createNewVersion(ctx context.Context, keeperName st return nil, contracts.NewErrValidateSecureValue(errorList) } + if sv.Spec.Ref != nil && keeperCfg.Type() == secretv1beta1.SystemKeeperType { + return nil, contracts.ErrReferenceWithSystemKeeper + } + createdSv, err := s.secureValueMetadataStorage.Create(ctx, keeperName, sv, actorUID) if err != nil { return nil, fmt.Errorf("creating secure value: %w", err) @@ -189,18 +193,28 @@ func (s *SecureValueService) createNewVersion(ctx context.Context, keeperName st return nil, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", createdSv.Namespace, keeperName, err) } logging.FromContext(ctx).Debug("retrieved keeper", "namespace", createdSv.Namespace, "type", keeperCfg.Type()) - // TODO: can we stop using external id? // TODO: store uses only the namespace and returns and id. It could be a kv instead. // TODO: check that the encrypted store works with multiple versions - externalID, err := keeper.Store(ctx, keeperCfg, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version, sv.Spec.Value.DangerouslyExposeAndConsumeValue()) - if err != nil { - return nil, fmt.Errorf("storing secure value in keeper: %w", err) - } - createdSv.Status.ExternalID = string(externalID) + switch { + case sv.Spec.Value != nil: + externalID, err := keeper.Store(ctx, keeperCfg, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version, sv.Spec.Value.DangerouslyExposeAndConsumeValue()) + if err != nil { + return nil, fmt.Errorf("storing secure value in keeper: %w", err) + } + createdSv.Status.ExternalID = string(externalID) - if err := s.secureValueMetadataStorage.SetExternalID(ctx, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version, externalID); err != nil { - return nil, fmt.Errorf("setting secure value external id: %w", err) + if err := s.secureValueMetadataStorage.SetExternalID(ctx, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version, externalID); err != nil { + return nil, fmt.Errorf("setting secure value external id: %w", err) + } + + case sv.Spec.Ref != nil: + // No-op, there's nothing to store in the keeper since the + // secret is already stored in the 3rd party secret store + // and it's being referenced. + + default: + return nil, fmt.Errorf("secure value doesn't specify either a secret value or a reference") } if err := s.secureValueMetadataStorage.SetVersionToActive(ctx, xkube.Namespace(createdSv.Namespace), createdSv.Name, createdSv.Status.Version); err != nil { @@ -366,3 +380,20 @@ func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespa return sv, nil } + +func (s *SecureValueService) SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, name string) error { + // The system keeper is not in the database, so skip checking it exists. + // TODO: should the system keeper be in the database? + if name != contracts.SystemKeeperName { + // Check keeper exists. No need to worry about time of check to time of use + // since trying to activate a just deleted keeper will result in all + // keepers being inactive and defaulting to the system keeper. + if _, err := s.keeperMetadataStorage.Read(ctx, namespace, name, contracts.ReadOpts{}); err != nil { + return fmt.Errorf("reading keeper before setting as active: %w", err) + } + } + if err := s.keeperMetadataStorage.SetAsActive(ctx, namespace, name); err != nil { + return fmt.Errorf("calling keeper metadata storage to set keeper as active: %w", err) + } + return nil +} diff --git a/pkg/registry/apis/secret/service/secure_value_test.go b/pkg/registry/apis/secret/service/secure_value_test.go index 1304e5b0ce1..40458ac5e22 100644 --- a/pkg/registry/apis/secret/service/secure_value_test.go +++ b/pkg/registry/apis/secret/service/secure_value_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" ) @@ -93,4 +94,149 @@ func TestCrud(t *testing.T) { _, err = sut.SecureValueMetadataStorage.Read(t.Context(), xkube.Namespace(sv1.Namespace), sv1.Name, contracts.ReadOpts{}) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) }) + + t.Run("secret can be referenced only when the active keeper is a 3rd party keeper", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + ref := "path-to-secret" + sv := &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sv1", + Namespace: "ns1", + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: "desc1", + Ref: &ref, + Decrypters: []string{"decrypter1"}, + }, + Status: secretv1beta1.SecureValueStatus{}, + } + + // Creating a secure value using ref with the system keeper + createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv)) + require.NotNil(t, err) + require.Nil(t, createdSv) + require.Contains(t, err.Error(), "tried to create secure value using reference with system keeper, references can only be used with 3rd party keepers") + + // Create a 3rd party keeper + keeper := &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k1", + Namespace: "ns1", + }, + Spec: secretv1beta1.KeeperSpec{ + Description: "desc", + Aws: &secretv1beta1.KeeperAWSConfig{ + Region: "us-east-1", + AssumeRole: &secretv1beta1.KeeperAWSAssumeRole{ + AssumeRoleArn: "arn", + ExternalID: "id", + }, + }, + }, + } + + // Create a 3rd party keeper + _, err = sut.KeeperMetadataStorage.Create(t.Context(), keeper, "actor-uid") + require.NoError(t, err) + + // Set the new keeper as active + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + + // Create a secure value using a ref + createdSv, err = sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + require.Equal(t, keeper.Name, createdSv.Status.Keeper) + }) + + t.Run("creating secure value with reference", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + // Create a keeper because references cannot be used with the system keeper + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + + sv, err := sut.CreateSv(t.Context()) + require.NoError(t, err) + require.NotNil(t, sv) + }) +} + +func Test_SetAsActive(t *testing.T) { + t.Parallel() + + t.Run("setting the system keeper as the active keeper", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + namespace := "ns" + + // Create a new keeper + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Description: "description", + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + + // Set the new keeper as active + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + keeperName, _, err := sut.KeeperMetadataStorage.GetActiveKeeperConfig(t.Context(), namespace) + require.NoError(t, err) + require.Equal(t, keeper.Name, keeperName) + + // Set the system keeper as active + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(namespace), contracts.SystemKeeperName)) + keeperName, _, err = sut.KeeperMetadataStorage.GetActiveKeeperConfig(t.Context(), namespace) + require.NoError(t, err) + require.Equal(t, contracts.SystemKeeperName, keeperName) + }) + + t.Run("each namespace can have one active keeper", func(t *testing.T) { + t.Parallel() + + sut := testutils.Setup(t) + + k1, err := sut.CreateKeeper(t.Context(), func(ckc *testutils.CreateKeeperConfig) { + ckc.Keeper.Namespace = "ns1" + ckc.Keeper.Name = "k1" + }) + require.NoError(t, err) + k2, err := sut.CreateKeeper(t.Context(), func(ckc *testutils.CreateKeeperConfig) { + ckc.Keeper.Namespace = "ns2" + ckc.Keeper.Name = "k2" + }) + require.NoError(t, err) + + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k1.Namespace), k1.Name)) + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(k2.Namespace), k2.Name)) + + keeperName, _, err := sut.KeeperMetadataStorage.GetActiveKeeperConfig(t.Context(), k1.Namespace) + require.NoError(t, err) + require.Equal(t, k1.Name, keeperName) + + keeperName, _, err = sut.KeeperMetadataStorage.GetActiveKeeperConfig(t.Context(), k2.Namespace) + require.NoError(t, err) + require.Equal(t, k2.Name, keeperName) + }) } diff --git a/pkg/registry/apis/secret/testutils/generators.go b/pkg/registry/apis/secret/testutils/generators.go new file mode 100644 index 00000000000..34686ccbf6e --- /dev/null +++ b/pkg/registry/apis/secret/testutils/generators.go @@ -0,0 +1,96 @@ +package testutils + +import ( + "fmt" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + "pgregory.net/rapid" +) + +var ( + DecryptersGen = rapid.SampledFrom([]string{"svc1", "svc2", "svc3", "svc4", "svc5"}) + SecureValueNameGen = rapid.SampledFrom([]string{"n1", "n2", "n3", "n4", "n5"}) + KeeperNameGen = rapid.SampledFrom([]string{"k1", "k2", "k3", "k4", "k5"}) + NamespaceGen = rapid.SampledFrom([]string{"ns1", "ns2", "ns3", "ns4", "ns5"}) + SecretsToRefGen = rapid.SampledFrom([]string{"ref1", "ref2", "ref3", "ref4", "ref5"}) + // Generator for secure values that specify a secret value + AnySecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { + return &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecureValueNameGen.Draw(t, "name"), + Namespace: NamespaceGen.Draw(t, "ns"), + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: rapid.SampledFrom([]string{"d1", "d2", "d3", "d4", "d5"}).Draw(t, "description"), + Value: ptr.To(secretv1beta1.NewExposedSecureValue(rapid.SampledFrom([]string{"v1", "v2", "v3", "v4", "v5"}).Draw(t, "value"))), + Decrypters: rapid.SliceOfDistinct(DecryptersGen, func(v string) string { return v }).Draw(t, "decrypters"), + }, + Status: secretv1beta1.SecureValueStatus{}, + } + }) + // Generator for secure values that reference values from 3rd party stores + AnySecureValueWithRefGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { + return &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: SecureValueNameGen.Draw(t, "name"), + Namespace: NamespaceGen.Draw(t, "ns"), + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: rapid.SampledFrom([]string{"d1", "d2", "d3", "d4", "d5"}).Draw(t, "description"), + Ref: ptr.To(SecretsToRefGen.Draw(t, "ref")), + Decrypters: rapid.SliceOfDistinct(DecryptersGen, func(v string) string { return v }).Draw(t, "decrypters"), + }, + Status: secretv1beta1.SecureValueStatus{}, + } + }) + UpdateSecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { + sv := AnySecureValueGen.Draw(t, "sv") + // Maybe update the secret value, maybe not + if !rapid.Bool().Draw(t, "should_update_value") { + sv.Spec.Value = nil + } + return sv + }) + DecryptGen = rapid.Custom(func(t *rapid.T) DecryptInput { + return DecryptInput{ + Namespace: NamespaceGen.Draw(t, "ns"), + Name: SecureValueNameGen.Draw(t, "name"), + Decrypter: DecryptersGen.Draw(t, "decrypter"), + } + }) + AnyKeeperGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.Keeper { + spec := secretv1beta1.KeeperSpec{ + Description: rapid.String().Draw(t, "description"), + } + + keeperType := rapid.SampledFrom([]string{"isAwsKeeper", "isAzureKeeper", "isGcpKeeper", "isVaultKeeper"}).Draw(t, "keeperType") + switch keeperType { + case "isAwsKeeper": + spec.Aws = &secretv1beta1.KeeperAWSConfig{} + case "isAzureKeeper": + spec.Azure = &secretv1beta1.KeeperAzureConfig{} + case "isGcpKeeper": + spec.Gcp = &secretv1beta1.KeeperGCPConfig{} + case "isVaultKeeper": + spec.HashiCorpVault = &secretv1beta1.KeeperHashiCorpConfig{} + default: + panic(fmt.Sprintf("unhandled keeper type '%+v', did you forget a switch case?", keeperType)) + } + + return &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: KeeperNameGen.Draw(t, "name"), + Namespace: NamespaceGen.Draw(t, "ns"), + }, + Spec: spec, + } + }) +) + +type DecryptInput struct { + Namespace string + Name string + Decrypter string +} diff --git a/pkg/registry/apis/secret/testutils/model_gsm.go b/pkg/registry/apis/secret/testutils/model_gsm.go new file mode 100644 index 00000000000..d2cefde6ee0 --- /dev/null +++ b/pkg/registry/apis/secret/testutils/model_gsm.go @@ -0,0 +1,321 @@ +package testutils + +import ( + "context" + "fmt" + "slices" + "time" + + secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/apps/secret/pkg/decrypt" + "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" +) + +type ModelSecureValue struct { + *secretv1beta1.SecureValue + active bool + created time.Time + leaseCreated time.Time +} + +type ModelKeeper struct { + namespace string + name string + active bool + keeperType secretv1beta1.KeeperType +} + +// A simplified in memoruy model of the grafana secrets manager +type ModelGsm struct { + SecureValues []*ModelSecureValue + Keepers []*ModelKeeper + modelSecretsManager *ModelAWSSecretsManager +} + +func NewModelGsm(modelSecretsManager *ModelAWSSecretsManager) *ModelGsm { + return &ModelGsm{modelSecretsManager: modelSecretsManager} +} + +func (m *ModelGsm) getNewVersionNumber(namespace, name string) int64 { + latestVersion := int64(0) + for _, sv := range m.SecureValues { + if sv.Namespace == namespace && sv.Name == name { + latestVersion = max(latestVersion, sv.Status.Version) + } + } + return latestVersion + 1 +} + +func (m *ModelGsm) SetVersionToActive(namespace, name string, version int64) { + for _, sv := range m.SecureValues { + if sv.Namespace == namespace && sv.Name == name { + sv.active = sv.Status.Version == version + } + } +} + +func (m *ModelGsm) SetVersionToInactive(namespace, name string, version int64) { + for _, sv := range m.SecureValues { + if sv.Namespace == namespace && sv.Name == name && sv.Status.Version == version { + sv.active = false + return + } + } +} + +func (m *ModelGsm) ReadActiveVersion(namespace, name string) *ModelSecureValue { + for _, sv := range m.SecureValues { + if sv.Namespace == namespace && sv.Name == name && sv.active { + return sv + } + } + + return nil +} + +func (m *ModelGsm) Create(now time.Time, sv *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, error) { + keeper := m.getActiveKeeper(sv.Namespace) + + if sv.Spec.Ref != nil && keeper.keeperType == secretv1beta1.SystemKeeperType { + return nil, contracts.ErrReferenceWithSystemKeeper + } + + sv = sv.DeepCopy() + + // Preserve the original creation time if this secure value already exists + created := now + if sv := m.ReadActiveVersion(sv.Namespace, sv.Name); sv != nil { + created = sv.created + } + + modelSv := &ModelSecureValue{SecureValue: sv, active: false, created: created} + modelSv.Status.Version = m.getNewVersionNumber(modelSv.Namespace, modelSv.Name) + modelSv.Status.ExternalID = fmt.Sprintf("%d", modelSv.Status.Version) + modelSv.Status.Keeper = keeper.name + m.SecureValues = append(m.SecureValues, modelSv) + m.SetVersionToActive(modelSv.Namespace, modelSv.Name, modelSv.Status.Version) + return modelSv.SecureValue, nil +} + +func (m *ModelGsm) getActiveKeeper(namespace string) *ModelKeeper { + for _, k := range m.Keepers { + if k.namespace == namespace && k.active { + return k + } + } + + // Default to the system keeper when there are no active keepers in the namespace + return &ModelKeeper{ + namespace: namespace, + name: contracts.SystemKeeperName, + active: true, + keeperType: secretv1beta1.SystemKeeperType, + } +} + +func (m *ModelGsm) keeperExists(namespace, name string) bool { + return m.findKeeper(namespace, name) != nil +} + +func (m *ModelGsm) findKeeper(namespace, name string) *ModelKeeper { + // The system keeper is not in the list of keepers + if name == contracts.SystemKeeperName { + return &ModelKeeper{namespace: namespace, name: contracts.SystemKeeperName, active: true, keeperType: secretv1beta1.SystemKeeperType} + } + for _, k := range m.Keepers { + if k.namespace == namespace && k.name == name { + return k + } + } + return nil +} + +func (m *ModelGsm) CreateKeeper(keeper *secretv1beta1.Keeper) (*secretv1beta1.Keeper, error) { + if m.keeperExists(keeper.Namespace, keeper.Name) { + return nil, contracts.ErrKeeperAlreadyExists + } + + var keeperType secretv1beta1.KeeperType + switch { + case keeper.Spec.Aws != nil: + keeperType = secretv1beta1.AWSKeeperType + case keeper.Spec.Gcp != nil: + keeperType = secretv1beta1.GCPKeeperType + case keeper.Spec.Azure != nil: + keeperType = secretv1beta1.AzureKeeperType + case keeper.Spec.HashiCorpVault != nil: + keeperType = secretv1beta1.HashiCorpKeeperType + default: + keeperType = secretv1beta1.SystemKeeperType + } + + m.Keepers = append(m.Keepers, &ModelKeeper{namespace: keeper.Namespace, name: keeper.Name, keeperType: keeperType}) + + return keeper.DeepCopy(), nil +} + +func (m *ModelGsm) SetKeeperAsActive(namespace, keeperName string) error { + // Set every other keeper in the namespace as inactive + for _, k := range m.Keepers { + if k.namespace == namespace { + k.active = k.name == keeperName + } + } + + return nil +} + +func (m *ModelGsm) Update(now time.Time, newSecureValue *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, bool, error) { + sv := m.ReadActiveVersion(newSecureValue.Namespace, newSecureValue.Name) + if sv == nil { + return nil, false, contracts.ErrSecureValueNotFound + } + + // If the keeper doesn't exist, return an error + if !m.keeperExists(sv.Namespace, sv.Status.Keeper) { + return nil, false, contracts.ErrKeeperNotFound + } + + // If the payload doesn't contain a value and it's not using a reference, get the value from current version + if newSecureValue.Spec.Value == nil && newSecureValue.Spec.Ref == nil { + // Tried to update a secure value without providing a new value or a ref + if sv.Spec.Value == nil { + return nil, false, contracts.ErrSecureValueMissingSecretAndRef + } + newSecureValue.Spec.Value = sv.Spec.Value + } + + createdSv, err := m.Create(now, newSecureValue) + + return createdSv, true, err +} + +func (m *ModelGsm) Delete(namespace, name string) (*secretv1beta1.SecureValue, error) { + modelSv := m.ReadActiveVersion(namespace, name) + if modelSv == nil { + return nil, contracts.ErrSecureValueNotFound + } + m.SetVersionToInactive(namespace, name, modelSv.Status.Version) + return modelSv.SecureValue, nil +} + +func (m *ModelGsm) List(namespace string) (*secretv1beta1.SecureValueList, error) { + out := make([]secretv1beta1.SecureValue, 0) + + for _, v := range m.SecureValues { + if v.Namespace == namespace && v.active { + out = append(out, *v.SecureValue) + } + } + + return &secretv1beta1.SecureValueList{Items: out}, nil +} + +func (m *ModelGsm) Decrypt(ctx context.Context, decrypter, namespace, name string) (map[string]decrypt.DecryptResult, error) { + for _, v := range m.SecureValues { + if v.Namespace == namespace && + v.Name == name && + v.active { + if slices.ContainsFunc(v.Spec.Decrypters, func(d string) bool { return d == decrypter }) { + switch { + // It's a secure value that specifies the secret + case v.Spec.Value != nil: + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultValue(v.DeepCopy().Spec.Value), + }, nil + + // It's a secure value that references a secret on a 3rd party store + case v.Spec.Ref != nil: + keeper := m.findKeeper(v.Namespace, v.Status.Keeper) + switch keeper.keeperType { + case secretv1beta1.AWSKeeperType: + exposedValue, err := m.modelSecretsManager.RetrieveReference(ctx, nil, *v.Spec.Ref) + if err != nil { + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultErr(fmt.Errorf("%w: %w", contracts.ErrDecryptFailed, err)), + }, nil + } + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultValue(&exposedValue), + }, nil + + // Other keepers are not implemented so we default to the system keeper + default: + // The system keeper doesn't implement Reference so decryption always fails + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultErr(contracts.ErrDecryptFailed), + }, nil + } + + default: + panic("bug: secure value where Spec.Value and Spec.Ref are nil") + } + } + + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultErr(contracts.ErrDecryptNotAuthorized), + }, nil + } + } + return map[string]decrypt.DecryptResult{ + name: decrypt.NewDecryptResultErr(contracts.ErrDecryptNotFound), + }, nil +} + +func (m *ModelGsm) Read(namespace, name string) (*secretv1beta1.SecureValue, error) { + modelSv := m.ReadActiveVersion(namespace, name) + if modelSv == nil { + return nil, contracts.ErrSecureValueNotFound + } + return modelSv.SecureValue, nil +} + +func (m *ModelGsm) LeaseInactiveSecureValues(now time.Time, minAge, leaseTTL time.Duration, maxBatchSize uint16) ([]*ModelSecureValue, error) { + out := make([]*ModelSecureValue, 0) + + for _, sv := range m.SecureValues { + if len(out) >= int(maxBatchSize) { + break + } + if !sv.active && now.Sub(sv.created) > minAge && now.Sub(sv.leaseCreated) > leaseTTL { + sv.leaseCreated = now + out = append(out, sv) + } + } + + return out, nil +} + +func (m *ModelGsm) CleanupInactiveSecureValues(now time.Time, minAge time.Duration, maxBatchSize uint16) ([]*ModelSecureValue, error) { + // Using a slice to allow duplicates + toDelete := make([]*ModelSecureValue, 0) + + // The implementation query sorts by created time ascending + slices.SortFunc(m.SecureValues, func(a, b *ModelSecureValue) int { + if a.created.Before(b.created) { + return -1 + } else if a.created.After(b.created) { + return 1 + } + return 0 + }) + + for _, sv := range m.SecureValues { + if len(toDelete) >= int(maxBatchSize) { + break + } + + if !sv.active && now.Sub(sv.created) > minAge { + toDelete = append(toDelete, sv) + } + } + + // PERF: The slices are always small + m.SecureValues = slices.DeleteFunc(m.SecureValues, func(v1 *ModelSecureValue) bool { + return slices.ContainsFunc(toDelete, func(v2 *ModelSecureValue) bool { + return v2.UID == v1.UID + }) + }) + + return toDelete, nil +} diff --git a/pkg/registry/apis/secret/testutils/testutils.go b/pkg/registry/apis/secret/testutils/testutils.go index 37394905b79..a3b05c3505f 100644 --- a/pkg/registry/apis/secret/testutils/testutils.go +++ b/pkg/registry/apis/secret/testutils/testutils.go @@ -2,6 +2,7 @@ package testutils import ( "context" + "fmt" "testing" "time" @@ -143,7 +144,8 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { realMigrationExecutor, err := encryptionstorage.ProvideEncryptedValueMigrationExecutor(database, tracer, encryptedValueStorage, globalEncryptedValueStorage) require.NoError(t, err) - var keeperService contracts.KeeperService = newKeeperServiceWrapper(sqlKeeper) + mockAwsKeeper := NewModelSecretsManager() + var keeperService contracts.KeeperService = newKeeperServiceWrapper(sqlKeeper, mockAwsKeeper) if setupCfg.KeeperService != nil { keeperService = setupCfg.KeeperService @@ -190,6 +192,7 @@ func Setup(t *testing.T, opts ...func(*SetupConfig)) Sut { Clock: clock, KeeperService: keeperService, KeeperMetadataStorage: keeperMetadataStorage, + ModelSecretsManager: mockAwsKeeper, } } @@ -212,6 +215,8 @@ type Sut struct { Clock *FakeClock KeeperService contracts.KeeperService KeeperMetadataStorage contracts.KeeperMetadataStorage + // A mock of AWS secrets manager that implements contracts.Keeper + ModelSecretsManager *ModelAWSSecretsManager } type CreateSvConfig struct { @@ -260,16 +265,54 @@ func (s *Sut) DeleteSv(ctx context.Context, namespace, name string) (*secretv1be return sv, err } -type keeperServiceWrapper struct { - keeper contracts.Keeper +type CreateKeeperConfig struct { + // The default keeper payload. Mutate it to change which keeper ends up being created + Keeper *secretv1beta1.Keeper } -func newKeeperServiceWrapper(keeper contracts.Keeper) *keeperServiceWrapper { - return &keeperServiceWrapper{keeper: keeper} +func (s *Sut) CreateAWSKeeper(ctx context.Context) (*secretv1beta1.Keeper, error) { + return s.CreateKeeper(ctx, func(cfg *CreateKeeperConfig) { + cfg.Keeper.Spec = secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + } + }) +} + +func (s *Sut) CreateKeeper(ctx context.Context, opts ...func(*CreateKeeperConfig)) (*secretv1beta1.Keeper, error) { + cfg := CreateKeeperConfig{ + Keeper: &secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sv1", + Namespace: "ns1", + }, + Spec: secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, + } + for _, opt := range opts { + opt(&cfg) + } + + return s.KeeperMetadataStorage.Create(ctx, cfg.Keeper, "actor-uid") +} + +type keeperServiceWrapper struct { + sqlKeeper *sqlkeeper.SQLKeeper + awsKeeper *ModelAWSSecretsManager +} + +func newKeeperServiceWrapper(sqlKeeper *sqlkeeper.SQLKeeper, awsKeeper *ModelAWSSecretsManager) *keeperServiceWrapper { + return &keeperServiceWrapper{sqlKeeper: sqlKeeper, awsKeeper: awsKeeper} } func (wrapper *keeperServiceWrapper) KeeperForConfig(cfg secretv1beta1.KeeperConfig) (contracts.Keeper, error) { - return wrapper.keeper, nil + switch cfg.(type) { + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperAWSConfig]: + return wrapper.awsKeeper, nil + default: + return wrapper.sqlKeeper, nil + } } func CreateUserAuthContext(ctx context.Context, namespace string, permissions map[string][]string) context.Context { @@ -390,3 +433,113 @@ type NoopMigrationExecutor struct { func (e *NoopMigrationExecutor) Execute(ctx context.Context) (int, error) { return 0, nil } + +// A mock of AWS secrets manager, used for testing. +type ModelAWSSecretsManager struct { + secrets map[string]entry + alreadyDeleted map[string]bool +} + +type entry struct { + exposedValueOrRef string + externalID string +} + +func NewModelSecretsManager() *ModelAWSSecretsManager { + return &ModelAWSSecretsManager{ + secrets: make(map[string]entry), + alreadyDeleted: make(map[string]bool), + } +} + +func (m *ModelAWSSecretsManager) Store(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64, exposedValueOrRef string) (externalID contracts.ExternalID, err error) { + if exposedValueOrRef == "" { + return "", fmt.Errorf("failed to satisfy constraint: Member must have length greater than or equal to 1") + } + + versionID := buildVersionID(namespace, name, version) + if e, ok := m.secrets[versionID]; ok { + // Ignore duplicated requests + if e.exposedValueOrRef == exposedValueOrRef { + return contracts.ExternalID(e.externalID), nil + } + + // Tried to create a secret that already exists + return "", fmt.Errorf("ResourceExistsException: The operation failed because the secret %+v already exists", versionID) + } + + // First time creating the secret + entry := entry{ + exposedValueOrRef: exposedValueOrRef, + externalID: "external-id", + } + m.secrets[versionID] = entry + + return contracts.ExternalID(entry.externalID), nil +} + +// Used to simulate the creation of secrets in the 3rd party secret store +func (m *ModelAWSSecretsManager) Create(name, value string) { + m.secrets[name] = entry{ + exposedValueOrRef: value, + externalID: fmt.Sprintf("external_id_%+v", value), + } +} + +func (m *ModelAWSSecretsManager) Expose(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) (exposedValue secretv1beta1.ExposedSecureValue, err error) { + versionID := buildVersionID(namespace, name, version) + + if m.deleted(versionID) { + return "", fmt.Errorf("InvalidRequestException: You can't perform this operation on the secret because it was marked for deletion") + } + + entry, ok := m.secrets[versionID] + if !ok { + return "", fmt.Errorf("ResourceNotFoundException: Secrets Manager can't find the specified secret") + } + + return secretv1beta1.ExposedSecureValue(entry.exposedValueOrRef), nil +} + +// TODO: this could be namespaced to make it more realistic +func (m *ModelAWSSecretsManager) RetrieveReference(ctx context.Context, _ secretv1beta1.KeeperConfig, ref string) (secretv1beta1.ExposedSecureValue, error) { + entry, ok := m.secrets[ref] + if !ok { + return "", fmt.Errorf("ResourceNotFoundException: Secrets Manager can't find the specified secret") + } + return secretv1beta1.ExposedSecureValue(entry.exposedValueOrRef), nil +} + +func (m *ModelAWSSecretsManager) Delete(ctx context.Context, cfg secretv1beta1.KeeperConfig, namespace xkube.Namespace, name string, version int64) (err error) { + versionID := buildVersionID(namespace, name, version) + + // Deleting a secret that existed at some point is idempotent + if m.deleted(versionID) { + return nil + } + + // If the secret is being deleted for the first time + if m.exists(versionID) { + m.delete(versionID) + } + + return nil +} + +func (m *ModelAWSSecretsManager) deleted(versionID string) bool { + return m.alreadyDeleted[versionID] +} + +func (m *ModelAWSSecretsManager) exists(versionID string) bool { + _, ok := m.secrets[versionID] + return ok +} + +func (m *ModelAWSSecretsManager) delete(versionID string) { + m.alreadyDeleted[versionID] = true + delete(m.secrets, versionID) +} + +func buildVersionID(namespace xkube.Namespace, name string, version int64) string { + return fmt.Sprintf("%s/%s/%d", namespace, name, version) +} diff --git a/pkg/registry/apis/secret/validator/keeper.go b/pkg/registry/apis/secret/validator/keeper.go index b4220b45a65..0a1700a79c2 100644 --- a/pkg/registry/apis/secret/validator/keeper.go +++ b/pkg/registry/apis/secret/validator/keeper.go @@ -1,6 +1,8 @@ package validator import ( + "context" + "fmt" "strings" "k8s.io/apimachinery/pkg/util/validation" @@ -9,14 +11,17 @@ import ( secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) -type keeperValidator struct{} +type keeperValidator struct { + features featuremgmt.FeatureToggles +} var _ contracts.KeeperValidator = &keeperValidator{} -func ProvideKeeperValidator() contracts.KeeperValidator { - return &keeperValidator{} +func ProvideKeeperValidator(features featuremgmt.FeatureToggles) contracts.KeeperValidator { + return &keeperValidator{features: features} } func (v *keeperValidator) Validate(keeper *secretv1beta1.Keeper, oldKeeper *secretv1beta1.Keeper, operation admission.Operation) field.ErrorList { @@ -57,51 +62,110 @@ func (v *keeperValidator) Validate(keeper *secretv1beta1.Keeper, oldKeeper *secr } if keeper.Spec.Aws != nil { - if err := validateCredentialValue(field.NewPath("spec", "aws", "accessKeyID"), keeper.Spec.Aws.AccessKeyID); err != nil { - errs = append(errs, err) - } - - if err := validateCredentialValue(field.NewPath("spec", "aws", "secretAccessKey"), keeper.Spec.Aws.SecretAccessKey); err != nil { - errs = append(errs, err) + //nolint + if !v.features.IsEnabled(context.Background(), featuremgmt.FlagSecretsManagementAppPlatformAwsKeeper) { + errs = append(errs, + field.Forbidden(field.NewPath("spec", "aws"), + fmt.Sprintf("enable aws keeper feature toggle to create aws keepers: %s", featuremgmt.FlagSecretsManagementAppPlatformAwsKeeper))) + } else { + errs = append(errs, validateAws(keeper.Spec.Aws)...) } } if keeper.Spec.Azure != nil { - if keeper.Spec.Azure.KeyVaultName == "" { - errs = append(errs, field.Required(field.NewPath("spec", "azure", "keyVaultName"), "a `keyVaultName` is required")) - } - - if keeper.Spec.Azure.TenantID == "" { - errs = append(errs, field.Required(field.NewPath("spec", "azure", "tenantID"), "a `tenantID` is required")) - } - - if keeper.Spec.Azure.ClientID == "" { - errs = append(errs, field.Required(field.NewPath("spec", "azure", "clientID"), "a `clientID` is required")) - } - - if err := validateCredentialValue(field.NewPath("spec", "azure", "clientSecret"), keeper.Spec.Azure.ClientSecret); err != nil { - errs = append(errs, err) - } + errs = append(errs, validateAzure(keeper.Spec.Azure)...) } if keeper.Spec.Gcp != nil { - if keeper.Spec.Gcp.ProjectID == "" { - errs = append(errs, field.Required(field.NewPath("spec", "gcp", "projectID"), "a `projectID` is required")) - } - - if keeper.Spec.Gcp.CredentialsFile == "" { - errs = append(errs, field.Required(field.NewPath("spec", "gcp", "credentialsFile"), "a `credentialsFile` is required")) - } + errs = append(errs, validateGcp(keeper.Spec.Gcp)...) } if keeper.Spec.HashiCorpVault != nil { - if keeper.Spec.HashiCorpVault.Address == "" { - errs = append(errs, field.Required(field.NewPath("spec", "hashiCorpVault", "address"), "an `address` is required")) - } + errs = append(errs, validateHashiCorpVault(keeper.Spec.HashiCorpVault)...) + } - if err := validateCredentialValue(field.NewPath("spec", "hashiCorpVault", "token"), keeper.Spec.HashiCorpVault.Token); err != nil { + return errs +} + +func validateAws(cfg *secretv1beta1.KeeperAWSConfig) field.ErrorList { + errs := make(field.ErrorList, 0) + + if cfg.Region == "" { + errs = append(errs, field.Required(field.NewPath("spec", "aws", "region"), "region must be present")) + } + + switch { + case cfg.AccessKey == nil && cfg.AssumeRole == nil: + errs = append(errs, field.Required(field.NewPath("spec", "aws"), "one of `accessKey` or `assumeRole` must be present")) + + case cfg.AccessKey != nil && cfg.AssumeRole != nil: + errs = append(errs, field.Required(field.NewPath("spec", "aws"), "only one of `accessKey` or `assumeRole` can be present")) + + case cfg.AccessKey != nil: + if err := validateCredentialValue(field.NewPath("spec", "aws", "accessKey", "accessKeyID"), cfg.AccessKey.AccessKeyID); err != nil { errs = append(errs, err) } + if err := validateCredentialValue(field.NewPath("spec", "aws", "accessKey", "secretAccessKey"), cfg.AccessKey.SecretAccessKey); err != nil { + errs = append(errs, err) + } + + case cfg.AssumeRole != nil: + if cfg.AssumeRole.AssumeRoleArn == "" { + errs = append(errs, field.Required(field.NewPath("spec", "aws", "assumeRole", "assumeRoleArn"), "arn of the role to assume must be present")) + } + if cfg.AssumeRole.ExternalID == "" { + errs = append(errs, field.Required(field.NewPath("spec", "aws", "assumeRole", "externalId"), "externalId must be present")) + } + } + + return errs +} + +func validateAzure(cfg *secretv1beta1.KeeperAzureConfig) field.ErrorList { + errs := make(field.ErrorList, 0) + + if cfg.KeyVaultName == "" { + errs = append(errs, field.Required(field.NewPath("spec", "azure", "keyVaultName"), "a `keyVaultName` is required")) + } + + if cfg.TenantID == "" { + errs = append(errs, field.Required(field.NewPath("spec", "azure", "tenantID"), "a `tenantID` is required")) + } + + if cfg.ClientID == "" { + errs = append(errs, field.Required(field.NewPath("spec", "azure", "clientID"), "a `clientID` is required")) + } + + if err := validateCredentialValue(field.NewPath("spec", "azure", "clientSecret"), cfg.ClientSecret); err != nil { + errs = append(errs, err) + } + + return errs +} + +func validateGcp(cfg *secretv1beta1.KeeperGCPConfig) field.ErrorList { + errs := make(field.ErrorList, 0) + + if cfg.ProjectID == "" { + errs = append(errs, field.Required(field.NewPath("spec", "gcp", "projectID"), "a `projectID` is required")) + } + + if cfg.CredentialsFile == "" { + errs = append(errs, field.Required(field.NewPath("spec", "gcp", "credentialsFile"), "a `credentialsFile` is required")) + } + + return errs +} + +func validateHashiCorpVault(cfg *secretv1beta1.KeeperHashiCorpConfig) field.ErrorList { + errs := make(field.ErrorList, 0) + + if cfg.Address == "" { + errs = append(errs, field.Required(field.NewPath("spec", "hashiCorpVault", "address"), "an `address` is required")) + } + + if err := validateCredentialValue(field.NewPath("spec", "hashiCorpVault", "token"), cfg.Token); err != nil { + errs = append(errs, err) } return errs diff --git a/pkg/registry/apis/secret/validator/keeper_test.go b/pkg/registry/apis/secret/validator/keeper_test.go index 99b108e480e..c26f1924733 100644 --- a/pkg/registry/apis/secret/validator/keeper_test.go +++ b/pkg/registry/apis/secret/validator/keeper_test.go @@ -10,11 +10,12 @@ import ( "k8s.io/utils/ptr" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) func TestValidateKeeper(t *testing.T) { objectMeta := metav1.ObjectMeta{Name: "test", Namespace: "test"} - validator := ProvideKeeperValidator() + validator := ProvideKeeperValidator(featuremgmt.WithFeatures(featuremgmt.FlagSecretsManagementAppPlatformAwsKeeper)) t.Run("when creating a new keeper", func(t *testing.T) { t.Run("the `description` must be present", func(t *testing.T) { @@ -22,9 +23,12 @@ func TestValidateKeeper(t *testing.T) { ObjectMeta: objectMeta, Spec: secretv1beta1.KeeperSpec{ Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ValueFromEnv: "some-value"}, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ValueFromEnv: "some-value"}, - KmsKeyID: ptr.To("kms-key-id"), + Region: "us-east-1", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ValueFromEnv: "some-value"}, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ValueFromEnv: "some-value"}, + }, + KmsKeyID: ptr.To("kms-key-id"), }, }, } @@ -41,30 +45,42 @@ func TestValidateKeeper(t *testing.T) { Spec: secretv1beta1.KeeperSpec{ Description: "description", Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "some-value", - }, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ - SecureValueName: "some-value", + Region: "us-east-1", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "some-value", + }, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ + SecureValueName: "some-value", + }, }, KmsKeyID: ptr.To("optional"), }, }, } + t.Run("aws keeper feature flag must be enabled", func(t *testing.T) { + // Validator with feature disabled + validator := ProvideKeeperValidator(featuremgmt.WithFeatures()) + errs := validator.Validate(validKeeperAWS.DeepCopy(), nil, admission.Create) + require.Len(t, errs, 1) + require.Equal(t, "spec.aws", errs[0].Field) + require.Contains(t, errs[0].Detail, "secretsManagementAppPlatformAwsKeeper") + }) + t.Run("`accessKeyID` must be present", func(t *testing.T) { t.Run("at least one of the credential value must be present", func(t *testing.T) { keeper := validKeeperAWS.DeepCopy() - keeper.Spec.Aws.AccessKeyID = secretv1beta1.KeeperCredentialValue{} + keeper.Spec.Aws.AccessKey.AccessKeyID = secretv1beta1.KeeperCredentialValue{} errs := validator.Validate(keeper, nil, admission.Create) require.Len(t, errs, 1) - require.Equal(t, "spec.aws.accessKeyID", errs[0].Field) + require.Equal(t, "spec.aws.accessKey.accessKeyID", errs[0].Field) }) t.Run("at most one of the credential value must be present", func(t *testing.T) { keeper := validKeeperAWS.DeepCopy() - keeper.Spec.Aws.AccessKeyID = secretv1beta1.KeeperCredentialValue{ + keeper.Spec.Aws.AccessKey.AccessKeyID = secretv1beta1.KeeperCredentialValue{ SecureValueName: "a", ValueFromEnv: "b", ValueFromConfig: "c", @@ -72,23 +88,23 @@ func TestValidateKeeper(t *testing.T) { errs := validator.Validate(keeper, nil, admission.Create) require.Len(t, errs, 1) - require.Equal(t, "spec.aws.accessKeyID", errs[0].Field) + require.Equal(t, "spec.aws.accessKey.accessKeyID", errs[0].Field) }) }) t.Run("`secretAccessKey` must be present", func(t *testing.T) { t.Run("at least one of the credential value must be present", func(t *testing.T) { keeper := validKeeperAWS.DeepCopy() - keeper.Spec.Aws.SecretAccessKey = secretv1beta1.KeeperCredentialValue{} + keeper.Spec.Aws.AccessKey.SecretAccessKey = secretv1beta1.KeeperCredentialValue{} errs := validator.Validate(keeper, nil, admission.Create) require.Len(t, errs, 1) - require.Equal(t, "spec.aws.secretAccessKey", errs[0].Field) + require.Equal(t, "spec.aws.accessKey.secretAccessKey", errs[0].Field) }) t.Run("at most one of the credential value must be present", func(t *testing.T) { keeper := validKeeperAWS.DeepCopy() - keeper.Spec.Aws.SecretAccessKey = secretv1beta1.KeeperCredentialValue{ + keeper.Spec.Aws.AccessKey.SecretAccessKey = secretv1beta1.KeeperCredentialValue{ SecureValueName: "a", ValueFromEnv: "b", ValueFromConfig: "c", @@ -96,7 +112,23 @@ func TestValidateKeeper(t *testing.T) { errs := validator.Validate(keeper, nil, admission.Create) require.Len(t, errs, 1) - require.Equal(t, "spec.aws.secretAccessKey", errs[0].Field) + require.Equal(t, "spec.aws.accessKey.secretAccessKey", errs[0].Field) + }) + + t.Run("only one of accessKey or assumeRole can be present", func(t *testing.T) { + keeper := validKeeperAWS.DeepCopy() + keeper.Spec.Aws.AccessKey.SecretAccessKey = secretv1beta1.KeeperCredentialValue{ + SecureValueName: "a", + } + keeper.Spec.Aws.AssumeRole = &secretv1beta1.KeeperAWSAssumeRole{ + AssumeRoleArn: "arn", + ExternalID: "id", + } + + errs := validator.Validate(keeper, nil, admission.Create) + require.Len(t, errs, 1) + require.Equal(t, "spec.aws", errs[0].Field) + require.Equal(t, "only one of `accessKey` or `assumeRole` can be present", errs[0].Detail) }) }) }) diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index d95f01bef36..296b7d25b59 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -44,6 +44,7 @@ var provisioningExtras = wire.NewSet( pullrequest.ProvidePullRequestWorker, webhooks.ProvideWebhooksWithImages, extras.ProvideFactoryFromConfig, + extras.ProvideConnectionFactoryFromConfig, extras.ProvideProvisioningExtraAPIs, extras.ProvideExtraWorkers, ) diff --git a/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go b/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go index 84c0fe9f7fd..f8f663a8853 100644 --- a/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go +++ b/pkg/registry/apps/alerting/notifications/receiver/legacy_storage.go @@ -17,7 +17,6 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" ) var ( @@ -25,7 +24,7 @@ var ( ) type ReceiverService interface { - GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error) + GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*ngmodels.Receiver, error) GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error) CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) @@ -116,22 +115,12 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption return nil, err } - name, err := legacy_storage.UidToName(uid) - if err != nil { - return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid) - } - q := ngmodels.GetReceiverQuery{ - OrgID: info.OrgID, - Name: name, - Decrypt: false, - } - user, err := identity.GetRequester(ctx) if err != nil { return nil, err } - r, err := s.service.GetReceiver(ctx, q, user) + r, err := s.service.GetReceiver(ctx, uid, false, user) if err != nil { return nil, err } diff --git a/pkg/registry/apps/apps.go b/pkg/registry/apps/apps.go index 05bec47df4b..d07e11deced 100644 --- a/pkg/registry/apps/apps.go +++ b/pkg/registry/apps/apps.go @@ -20,7 +20,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/dashvalidator" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -110,7 +109,6 @@ func ProvideBuilderRunners( registrar builder.APIRegistrar, restConfigProvider apiserver.RestConfigProvider, features featuremgmt.FeatureToggles, - investigationAppProvider *investigations.InvestigationsAppProvider, grafanaCfg *setting.Cfg, ) (*Service, error) { cfgWrapper := func(ctx context.Context) (*rest.Config, error) { @@ -130,11 +128,6 @@ func ProvideBuilderRunners( var apiGroupRunner *runner.APIGroupRunner var err error providers := []app.Provider{} - //nolint:staticcheck // not yet migrated to OpenFeature - if features.IsEnabledGlobally(featuremgmt.FlagInvestigationsBackend) { - logger.Debug("Investigations backend is enabled") - providers = append(providers, investigationAppProvider) - } apiGroupRunner, err = runner.NewAPIGroupRunner(cfg, providers...) if err != nil { diff --git a/pkg/registry/apps/investigations/authorizer.go b/pkg/registry/apps/investigations/authorizer.go deleted file mode 100644 index 3178f3d2f9c..00000000000 --- a/pkg/registry/apps/investigations/authorizer.go +++ /dev/null @@ -1,38 +0,0 @@ -package investigations - -import ( - "context" - - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/accesscontrol" - - "k8s.io/apiserver/pkg/authorization/authorizer" -) - -func GetAuthorizer() authorizer.Authorizer { - return authorizer.AuthorizerFunc(func( - ctx context.Context, attr authorizer.Attributes, - ) (authorized authorizer.Decision, reason string, err error) { - if !attr.IsResourceRequest() { - return authorizer.DecisionNoOpinion, "", nil - } - - u, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, "valid user is required", err - } - - p := u.GetPermissions() - if len(p) == 0 { - return authorizer.DecisionDeny, "no permissions", nil - } - - _, ok := p[accesscontrol.ActionDatasourcesExplore] - if !ok { - // defer to the default authorizer if datasources:explore is not present - return authorizer.DecisionNoOpinion, "", nil - } - - return authorizer.DecisionAllow, "", nil - }) -} diff --git a/pkg/registry/apps/investigations/authorizer_test.go b/pkg/registry/apps/investigations/authorizer_test.go deleted file mode 100644 index c6d870bc7e7..00000000000 --- a/pkg/registry/apps/investigations/authorizer_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package investigations - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/stretchr/testify/assert" - "k8s.io/apiserver/pkg/authorization/authorizer" -) - -func TestGetAuthorizer(t *testing.T) { - tests := []struct { - name string - ctx context.Context - attr authorizer.Attributes - expectedDecision authorizer.Decision - expectedReason string - expectedErr error - }{ - { - name: "non-resource request", - ctx: context.TODO(), - attr: &mockAttributes{resourceRequest: false}, - expectedDecision: authorizer.DecisionNoOpinion, - expectedReason: "", - expectedErr: nil, - }, - { - name: "user has datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{permissions: map[string][]string{accesscontrol.ActionDatasourcesExplore: {}}}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionAllow, - expectedReason: "", - expectedErr: nil, - }, - { - name: "user does not have datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionDeny, - expectedReason: "no permissions", - expectedErr: nil, - }, - { - name: "user does not have datasources:explore permission", - ctx: identity.WithRequester(context.TODO(), &mockUser{permissions: map[string][]string{"foo": {}}}), - attr: &mockAttributes{resourceRequest: true}, - expectedDecision: authorizer.DecisionNoOpinion, - expectedReason: "", - expectedErr: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - auth := GetAuthorizer() - decision, reason, err := auth.Authorize(tt.ctx, tt.attr) - assert.Equal(t, tt.expectedDecision, decision) - assert.Equal(t, tt.expectedReason, reason) - assert.Equal(t, tt.expectedErr, err) - }) - } -} - -type mockAttributes struct { - authorizer.Attributes - resourceRequest bool -} - -func (m *mockAttributes) IsResourceRequest() bool { - return m.resourceRequest -} - -// Implement other methods of authorizer.Attributes as needed - -type mockUser struct { - identity.Requester - permissions map[string][]string -} - -func (m *mockUser) GetPermissions() map[string][]string { - return m.permissions -} - -// Implement other methods of identity.Requester as needed diff --git a/pkg/registry/apps/investigations/register.go b/pkg/registry/apps/investigations/register.go deleted file mode 100644 index 6fbf2603491..00000000000 --- a/pkg/registry/apps/investigations/register.go +++ /dev/null @@ -1,33 +0,0 @@ -package investigations - -import ( - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana-app-sdk/simple" - "github.com/grafana/grafana/apps/investigations/pkg/apis" - investigationv0alpha1 "github.com/grafana/grafana/apps/investigations/pkg/apis/investigations/v0alpha1" - investigationapp "github.com/grafana/grafana/apps/investigations/pkg/app" - "github.com/grafana/grafana/pkg/services/apiserver/builder" - "github.com/grafana/grafana/pkg/services/apiserver/builder/runner" - "github.com/grafana/grafana/pkg/setting" -) - -type InvestigationsAppProvider struct { - app.Provider - cfg *setting.Cfg -} - -func RegisterApp( - cfg *setting.Cfg, -) *InvestigationsAppProvider { - provider := &InvestigationsAppProvider{ - cfg: cfg, - } - appCfg := &runner.AppBuilderConfig{ - OpenAPIDefGetter: investigationv0alpha1.GetOpenAPIDefinitions, - ManagedKinds: investigationapp.GetKinds(), - Authorizer: GetAuthorizer(), - AllowedV0Alpha1Resources: []string{builder.AllResourcesAllowed}, - } - provider.Provider = simple.NewAppProvider(apis.LocalManifest(), appCfg, investigationapp.New) - return provider -} diff --git a/pkg/registry/apps/plugins/register.go b/pkg/registry/apps/plugins/register.go index 68082f59713..d29da1eb6c4 100644 --- a/pkg/registry/apps/plugins/register.go +++ b/pkg/registry/apps/plugins/register.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver" "github.com/grafana/grafana/pkg/services/apiserver/appinstaller" grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" ) @@ -36,9 +37,13 @@ func ProvideAppInstaller( pluginStore pluginstore.Store, pluginAssetsService *pluginassets.Service, accessControlService accesscontrol.Service, accessClient authlib.AccessClient, + features featuremgmt.FeatureToggles, ) (*AppInstaller, error) { - if err := registerAccessControlRoles(accessControlService); err != nil { - return nil, fmt.Errorf("registering access control roles: %w", err) + //nolint:staticcheck // not yet migrated to OpenFeature + if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) { + if err := registerAccessControlRoles(accessControlService); err != nil { + return nil, fmt.Errorf("registering access control roles: %w", err) + } } localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService) diff --git a/pkg/registry/apps/wireset.go b/pkg/registry/apps/wireset.go index 675d6254ee2..fa3ce99bc31 100644 --- a/pkg/registry/apps/wireset.go +++ b/pkg/registry/apps/wireset.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/dashvalidator" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -22,7 +21,6 @@ var WireSet = wire.NewSet( ProvideAppInstallers, ProvideBuilderRunners, playlist.RegisterAppInstaller, - investigations.RegisterApp, plugins.ProvideAppInstaller, shorturl.RegisterAppInstaller, correlations.RegisterAppInstaller, diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go index 76fa96a75c7..57c7e87bbb9 100644 --- a/pkg/server/test_env.go +++ b/pkg/server/test_env.go @@ -3,6 +3,7 @@ package server import ( "github.com/stretchr/testify/mock" + githubconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/apps/secret/pkg/decrypt" "github.com/grafana/grafana/pkg/infra/db" @@ -34,24 +35,26 @@ func ProvideTestEnv( featureMgmt featuremgmt.FeatureToggles, resourceClient resource.ResourceClient, idService auth.IDService, - githubFactory *github.Factory, + githubRepoFactory *github.Factory, + githubConnectionFactory githubconnection.GithubFactory, decryptService decrypt.DecryptService, ) (*TestEnv, error) { return &TestEnv{ - TestingT: testingT, - Server: server, - SQLStore: db, - Cfg: cfg, - NotificationService: ns, - GRPCServer: grpcServer, - PluginRegistry: pluginRegistry, - HTTPClientProvider: httpClientProvider, - OAuthTokenService: oAuthTokenService, - FeatureToggles: featureMgmt, - ResourceClient: resourceClient, - IDService: idService, - GitHubFactory: githubFactory, - DecryptService: decryptService, + TestingT: testingT, + Server: server, + SQLStore: db, + Cfg: cfg, + NotificationService: ns, + GRPCServer: grpcServer, + PluginRegistry: pluginRegistry, + HTTPClientProvider: httpClientProvider, + OAuthTokenService: oAuthTokenService, + FeatureToggles: featureMgmt, + ResourceClient: resourceClient, + IDService: idService, + GithubRepoFactory: githubRepoFactory, + GithubConnectionFactory: githubConnectionFactory, + DecryptService: decryptService, }, nil } @@ -60,18 +63,19 @@ type TestEnv struct { mock.TestingT Cleanup(func()) } - Server *Server - SQLStore db.DB - Cfg *setting.Cfg - NotificationService *notifications.NotificationServiceMock - GRPCServer grpcserver.Provider - PluginRegistry registry.Service - HTTPClientProvider httpclient.Provider - OAuthTokenService *oauthtokentest.Service - RequestMiddleware web.Middleware - FeatureToggles featuremgmt.FeatureToggles - ResourceClient resource.ResourceClient - IDService auth.IDService - GitHubFactory *github.Factory - DecryptService decrypt.DecryptService + Server *Server + SQLStore db.DB + Cfg *setting.Cfg + NotificationService *notifications.NotificationServiceMock + GRPCServer grpcserver.Provider + PluginRegistry registry.Service + HTTPClientProvider httpclient.Provider + OAuthTokenService *oauthtokentest.Service + RequestMiddleware web.Middleware + FeatureToggles featuremgmt.FeatureToggles + ResourceClient resource.ResourceClient + IDService auth.IDService + GithubRepoFactory *github.Factory + GithubConnectionFactory githubconnection.GithubFactory + DecryptService decrypt.DecryptService } diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 9864bed4e12..399794e9787 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -15,6 +15,7 @@ import ( "go.opentelemetry.io/otel/trace" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + ghconnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/avatar" @@ -297,6 +298,7 @@ var wireBasicSet = wire.NewSet( notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, + ghconnection.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), @@ -330,6 +332,7 @@ var wireBasicSet = wire.NewSet( dashsnapstore.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*dashsnapsvc.ServiceImpl)), dashsnapsvc.ProvideService, + datasourceservice.ProvideDataSourceRetriever, datasourceservice.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)), datasourceservice.ProvideLegacyDataSourceLookup, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 1e81f8f79f5..0ff8d9d540a 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -10,6 +10,7 @@ import ( "github.com/google/wire" httpclient2 "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry" + github2 "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/grafana/grafana/apps/provisioning/pkg/repository/github" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/api/avatar" @@ -37,8 +38,6 @@ import ( "github.com/grafana/grafana/pkg/login/social/socialimpl" "github.com/grafana/grafana/pkg/middleware/csrf" "github.com/grafana/grafana/pkg/middleware/loggermw" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - provider2 "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" manager4 "github.com/grafana/grafana/pkg/plugins/manager" "github.com/grafana/grafana/pkg/plugins/manager/filestore" "github.com/grafana/grafana/pkg/plugins/manager/process" @@ -87,7 +86,6 @@ import ( correlations2 "github.com/grafana/grafana/pkg/registry/apps/correlations" "github.com/grafana/grafana/pkg/registry/apps/dashvalidator" "github.com/grafana/grafana/pkg/registry/apps/example" - "github.com/grafana/grafana/pkg/registry/apps/investigations" "github.com/grafana/grafana/pkg/registry/apps/logsdrilldown" "github.com/grafana/grafana/pkg/registry/apps/playlist" "github.com/grafana/grafana/pkg/registry/apps/plugins" @@ -179,6 +177,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/angulardetectorsprovider" "github.com/grafana/grafana/pkg/services/pluginsintegration/angularinspector" "github.com/grafana/grafana/pkg/services/pluginsintegration/angularpatternsstore" + "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/dashboards" "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever" @@ -558,7 +557,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api zipkinService := zipkin.ProvideService(httpclientProvider) jaegerService := jaeger.ProvideService(httpclientProvider) corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) - providerService := provider2.ProvideService(corepluginRegistry) + backendFactoryProvider := coreplugin.ProvideCoreProvider(corepluginRegistry) processService := process.ProvideService() retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService) serviceAccountPermissionsService, err := ossaccesscontrol.ProvideServiceAccountPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, retrieverService, acimplService, teamService, userService, actionSetService) @@ -574,7 +573,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api service13 := service6.ProvideService(sqlStore, secretsService) serviceregistrationService := serviceregistration.ProvideService(cfg, featureToggles, registryRegistry, service13) noop := provisionedplugins.NewNoop() - initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, providerService, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService, noop) + initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, backendFactoryProvider, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService, noop) terminate, err := pipeline.ProvideTerminationStage(pluginManagementCfg, inMemory, processService) if err != nil { return nil, err @@ -641,7 +640,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api datasourcePermissionsService := ossaccesscontrol.ProvideDatasourcePermissionsService(cfg, featureToggles, sqlStore) requestConfigProvider := pluginconfig.NewRequestConfigProvider(pluginInstanceCfg, defaultSettingsProvider) baseProvider := plugincontext.ProvideBaseService(cfg, requestConfigProvider) - service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider) + dataSourceRetriever := service9.ProvideDataSourceRetriever(sqlStore, featureToggles) + service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider, dataSourceRetriever) if err != nil { return nil, err } @@ -787,7 +787,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient) + appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles) if err != nil { return nil, err } @@ -853,8 +853,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) - investigationsAppProvider := investigations.RegisterApp(cfg) - appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) + appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, cfg) if err != nil { return nil, err } @@ -921,7 +920,13 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) + githubFactory := github2.ProvideFactory() + v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, githubFactory) + connectionFactory, err := extras.ProvideConnectionFactoryFromConfig(cfg, v7) + if err != nil { + return nil, err + } + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory, connectionFactory) if err != nil { return nil, err } @@ -1222,7 +1227,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac zipkinService := zipkin.ProvideService(httpclientProvider) jaegerService := jaeger.ProvideService(httpclientProvider) corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) - providerService := provider2.ProvideService(corepluginRegistry) + backendFactoryProvider := coreplugin.ProvideCoreProvider(corepluginRegistry) processService := process.ProvideService() retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService) serviceAccountPermissionsService, err := ossaccesscontrol.ProvideServiceAccountPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, retrieverService, acimplService, teamService, userService, actionSetService) @@ -1238,7 +1243,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac service13 := service6.ProvideService(sqlStore, secretsService) serviceregistrationService := serviceregistration.ProvideService(cfg, featureToggles, registryRegistry, service13) noop := provisionedplugins.NewNoop() - initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, providerService, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService, noop) + initialize := pipeline.ProvideInitializationStage(pluginManagementCfg, inMemory, backendFactoryProvider, processService, serviceregistrationService, acimplService, actionSetService, envVarsProvider, tracingService, noop) terminate, err := pipeline.ProvideTerminationStage(pluginManagementCfg, inMemory, processService) if err != nil { return nil, err @@ -1301,7 +1306,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac datasourcePermissionsService := ossaccesscontrol.ProvideDatasourcePermissionsService(cfg, featureToggles, sqlStore) requestConfigProvider := pluginconfig.NewRequestConfigProvider(pluginInstanceCfg, defaultSettingsProvider) baseProvider := plugincontext.ProvideBaseService(cfg, requestConfigProvider) - service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider) + dataSourceRetriever := service9.ProvideDataSourceRetriever(sqlStore, featureToggles) + service15, err := service9.ProvideService(sqlStore, secretsService, secretsKVStore, cfg, featureToggles, accessControl, datasourcePermissionsService, quotaService, pluginstoreService, middlewareHandler, baseProvider, dataSourceRetriever) if err != nil { return nil, err } @@ -1453,7 +1459,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient) + appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles) if err != nil { return nil, err } @@ -1519,8 +1525,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) - investigationsAppProvider := investigations.RegisterApp(cfg) - appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) + appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, cfg) if err != nil { return nil, err } @@ -1587,7 +1592,13 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) + githubFactory := github2.ProvideFactory() + v7 := extras.ProvideProvisioningOSSConnectionExtras(cfg, githubFactory) + connectionFactory, err := extras.ProvideConnectionFactoryFromConfig(cfg, v7) + if err != nil { + return nil, err + } + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory, connectionFactory) if err != nil { return nil, err } @@ -1621,7 +1632,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, decryptService) + testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, githubFactory, decryptService) if err != nil { return nil, err } @@ -1811,7 +1822,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, live.ProvideDashboardActivityChannel, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, github2.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideDataSourceRetriever, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, service7.ProvideDashboardAccessService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index 4d70c3f2f4c..c0534f7fff4 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -72,6 +72,7 @@ import ( var provisioningExtras = wire.NewSet( extras.ProvideProvisioningOSSRepositoryExtras, + extras.ProvideProvisioningOSSConnectionExtras, ) var configProviderExtras = wire.NewSet( diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go new file mode 100644 index 00000000000..c6128790d1a --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/basic_role_db_seed.go @@ -0,0 +1,44 @@ +package acimpl + +import ( + "context" + "time" + + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +const ( + ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder" + ossBasicRoleSeedTimeout = 2 * time.Minute +) + +// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations +func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error { + if s.sql == nil || s.seeder == nil { + return nil + } + + run := func(ctx context.Context) error { + desired := map[accesscontrol.SeedPermission]struct{}{} + for role, permissions := range rolesSnapshot { + for _, permission := range permissions { + desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{} + } + } + s.seeder.SetDesiredPermissions(desired) + return s.seeder.Seed(ctx) + } + + if s.serverLock == nil { + return run(ctx) + } + + var err error + errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) { + err = run(ctx) + }) + if errLock != nil { + return errLock + } + return err +} diff --git a/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go new file mode 100644 index 00000000000..986a32b66fc --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go @@ -0,0 +1,128 @@ +package acimpl + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + ctx := context.Background() + sql := db.InitTestDB(t) + store := database.ProvideService(sql) + + svc := ProvideOSSService( + setting.NewCfg(), + store, + &resourcepermissions.FakeActionSetSvc{}, + localcache.ProvideService(), + featuremgmt.WithFeatures(), + tracing.InitializeTracerForTest(), + sql, + permreg.ProvidePermissionRegistry(), + nil, + ) + + require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:test:role", + Permissions: []accesscontrol.Permission{ + {Action: "test:read", Scope: ""}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + })) + + require.NoError(t, svc.RegisterFixedRoles(ctx)) + + // verify permission is persisted to DB for basic:viewer + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + var count int64 + count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() + require.NoError(t, err) + require.Equal(t, int64(1), count) + return nil + })) + + // ensure RegisterFixedRoles refreshes it back to defaults + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + ts := time.Now() + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + _, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID) + require.NoError(t, err) + p := accesscontrol.Permission{ + RoleID: role.ID, + Action: "custom:keep", + Scope: "", + Created: ts, + Updated: ts, + } + p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope) + _, err = sess.Table("permission").Insert(&p) + return err + })) + + svc2 := ProvideOSSService( + setting.NewCfg(), + store, + &resourcepermissions.FakeActionSetSvc{}, + localcache.ProvideService(), + featuremgmt.WithFeatures(), + tracing.InitializeTracerForTest(), + sql, + permreg.ProvidePermissionRegistry(), + nil, + ) + require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: "fixed:test:role", + Permissions: []accesscontrol.Permission{ + {Action: "test:read", Scope: ""}, + }, + }, + Grants: []string{string(org.RoleViewer)}, + })) + require.NoError(t, svc2.RegisterFixedRoles(ctx)) + + require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error { + var role accesscontrol.Role + ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role) + require.NoError(t, err) + require.True(t, ok) + + var count int64 + count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count() + require.NoError(t, err) + require.Equal(t, int64(1), count) + + count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count() + require.NoError(t, err) + require.Equal(t, int64(0), count) + return nil + })) +} diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 1ea8bf95f77..3aab2ad1248 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -30,6 +30,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol/migrator" "github.com/grafana/grafana/pkg/services/accesscontrol/permreg" "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" + "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -96,6 +97,12 @@ func ProvideOSSService( roles: accesscontrol.BuildBasicRoleDefinitions(), store: store, permRegistry: permRegistry, + sql: db, + serverLock: lock, + } + + if backend, ok := store.(*database.AccessControlStore); ok { + s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend) } return s @@ -112,8 +119,11 @@ type Service struct { rolesMu sync.RWMutex roles map[string]*accesscontrol.RoleDTO store accesscontrol.Store + seeder *seeding.Seeder permRegistry permreg.PermissionRegistry isInitialized bool + sql db.DB + serverLock *serverlock.ServerLockService } func (s *Service) GetUsageStats(_ context.Context) map[string]any { @@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error { defer span.End() s.rolesMu.Lock() - defer s.rolesMu.Unlock() - + registrations := s.registrations.Slice() s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { s.registerRolesLocked(registration) return true }) s.isInitialized = true + + rolesSnapshot := s.getBasicRolePermissionsLocked() + s.rolesMu.Unlock() + + if s.seeder != nil { + if err := s.seeder.SeedRoles(ctx, registrations); err != nil { + return err + } + if err := s.seeder.RemoveAbsentRoles(ctx); err != nil { + return err + } + } + + if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { + return err + } + return nil } +// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the +// current registration list, using the shared seeding registration logic. +// +// it has to be called while holding the roles lock +func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission { + desired := map[accesscontrol.SeedPermission]struct{}{} + s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool { + seeding.AppendDesiredPermissions(desired, s.log, ®istration.Role, registration.Grants, registration.Exclude) + return true + }) + + out := make(map[string][]accesscontrol.Permission) + for sp := range desired { + out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{ + Action: sp.Action, + Scope: sp.Scope, + }) + } + return out +} + // registerRolesLocked processes a single role registration and adds permissions to basic roles. // Must be called with s.rolesMu locked. func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) { @@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs defer span.End() acRegs := pluginutils.ToRegistrations(ID, name, regs) + updatedBasicRoles := false for _, r := range acRegs { if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil { return err @@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs if initialized { s.rolesMu.Lock() s.registerRolesLocked(r) + updatedBasicRoles = true s.rolesMu.Unlock() s.cache.Flush() } } + if updatedBasicRoles { + s.rolesMu.RLock() + rolesSnapshot := s.getBasicRolePermissionsLocked() + s.rolesMu.RUnlock() + + // plugin roles can be declared after startup - keep DB in sync + if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil { + return err + } + } + return nil } diff --git a/pkg/services/accesscontrol/database/seeder.go b/pkg/services/accesscontrol/database/seeder.go new file mode 100644 index 00000000000..2f53d20b514 --- /dev/null +++ b/pkg/services/accesscontrol/database/seeder.go @@ -0,0 +1,623 @@ +package database + +import ( + "context" + "strings" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/seeding" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/util/xorm/core" +) + +const basicRolePermBatchSize = 500 + +// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name. +func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) { + out := map[string]*accesscontrol.RoleDTO{} + + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + type roleRow struct { + ID int64 `xorm:"id"` + OrgID int64 `xorm:"org_id"` + Version int64 `xorm:"version"` + UID string `xorm:"uid"` + Name string `xorm:"name"` + DisplayName string `xorm:"display_name"` + Description string `xorm:"description"` + Group string `xorm:"group_name"` + Hidden bool `xorm:"hidden"` + Updated time.Time `xorm:"updated"` + Created time.Time `xorm:"created"` + } + + roles := []roleRow{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%"). + Find(&roles); err != nil { + return err + } + + if len(roles) == 0 { + return nil + } + + roleIDs := make([]any, 0, len(roles)) + roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles)) + for _, r := range roles { + dto := &accesscontrol.RoleDTO{ + ID: r.ID, + OrgID: r.OrgID, + Version: r.Version, + UID: r.UID, + Name: r.Name, + DisplayName: r.DisplayName, + Description: r.Description, + Group: r.Group, + Hidden: r.Hidden, + Updated: r.Updated, + Created: r.Created, + } + out[dto.Name] = dto + roleByID[dto.ID] = dto + roleIDs = append(roleIDs, dto.ID) + } + + type permRow struct { + RoleID int64 `xorm:"role_id"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + } + perms := []permRow{} + if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil { + return err + } + + for _, p := range perms { + dto := roleByID[p.RoleID] + if dto == nil { + continue + } + dto.Permissions = append(dto.Permissions, accesscontrol.Permission{ + RoleID: p.RoleID, + Action: p.Action, + Scope: p.Scope, + }) + } + + return nil + }) + + return out, err +} + +func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { + if existingRole == nil { + return nil + } + + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { + _, err := sess.Table("role"). + Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID). + Update(map[string]any{ + "display_name": wantedRole.DisplayName, + "description": wantedRole.Description, + "group_name": wantedRole.Group, + "hidden": wantedRole.Hidden, + "updated": time.Now(), + }) + return err + }) +} + +func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error { + if existingRole == nil { + return nil + } + + type key struct{ Action, Scope string } + existing := map[key]struct{}{} + for _, p := range existingRole.Permissions { + existing[key{p.Action, p.Scope}] = struct{}{} + } + desired := map[key]struct{}{} + for _, p := range wantedRole.Permissions { + desired[key{p.Action, p.Scope}] = struct{}{} + } + + toAdd := make([]accesscontrol.Permission, 0) + toRemove := make([]accesscontrol.SeedPermission, 0) + + now := time.Now() + for k := range desired { + if _, ok := existing[k]; ok { + continue + } + perm := accesscontrol.Permission{ + RoleID: existingRole.ID, + Action: k.Action, + Scope: k.Scope, + Created: now, + Updated: now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + toAdd = append(toAdd, perm) + } + + for k := range existing { + if _, ok := desired[k]; ok { + continue + } + toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope}) + } + + if len(toAdd) == 0 && len(toRemove) == 0 { + return nil + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + if len(toRemove) > 0 { + if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil { + return err + } + } + + if len(toAdd) > 0 { + _, err := sess.InsertMulti(toAdd) + return err + } + + return nil + }) +} + +func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error { + now := time.Now() + uid := role.UID + if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) { + uid = accesscontrol.PrefixedRoleUID(role.Name) + } + r := accesscontrol.Role{ + OrgID: accesscontrol.GlobalOrgID, + Version: role.Version, + UID: uid, + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + Group: role.Group, + Hidden: role.Hidden, + Created: now, + Updated: now, + } + if r.Version == 0 { + r.Version = 1 + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + if _, err := sess.Insert(&r); err != nil { + return err + } + + if len(role.Permissions) == 0 { + return nil + } + + // De-duplicate permissions on (action, scope) to avoid unique constraint violations. + // Some role definitions may accidentally include duplicates. + type permKey struct{ Action, Scope string } + seen := make(map[permKey]struct{}, len(role.Permissions)) + + perms := make([]accesscontrol.Permission, 0, len(role.Permissions)) + for _, p := range role.Permissions { + k := permKey{Action: p.Action, Scope: p.Scope} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + + perm := accesscontrol.Permission{ + RoleID: r.ID, + Action: p.Action, + Scope: p.Scope, + Created: now, + Updated: now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + perms = append(perms, perm) + } + _, err := sess.InsertMulti(perms) + return err + }) +} + +func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error { + if len(roleUIDs) == 0 { + return nil + } + + uids := make([]any, 0, len(roleUIDs)) + for _, uid := range roleUIDs { + uids = append(uids, uid) + } + + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + type row struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + } + rows := []row{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + In("uid", uids...). + Find(&rows); err != nil { + return err + } + if len(rows) == 0 { + return nil + } + + roleIDs := make([]any, 0, len(rows)) + for _, r := range rows { + roleIDs = append(roleIDs, r.ID) + } + + // Remove permissions and assignments first to avoid FK issues (if enabled). + { + args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + { + args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...) + if _, err := sess.Exec(args...); err != nil { + return err + } + } + + args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...) + _, err := sess.Exec(args...) + return err + }) +} + +// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory. +// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table. +func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + var out map[accesscontrol.SeedPermission]struct{} + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + rows, err := LoadBasicRoleSeedPermissions(sess) + if err != nil { + return err + } + + out = make(map[accesscontrol.SeedPermission]struct{}, len(rows)) + for _, r := range rows { + r.Origin = "" + out[r] = struct{}{} + } + return nil + }) + return out, err +} + +func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { + rolesToUpgrade := seeding.RolesToUpgrade(added, removed) + + // Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction. + return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error { + defs := accesscontrol.BuildBasicRoleDefinitions() + builtinToRoleID, err := EnsureBasicRolesExist(sess, defs) + if err != nil { + return err + } + + backend := &ossBasicRoleSeedBackend{ + sess: sess, + now: time.Now(), + builtinToRoleID: builtinToRoleID, + desired: nil, + dbType: s.sql.GetDBType(), + } + if err := backend.Apply(ctx, added, removed, updated); err != nil { + return err + } + + return BumpBasicRoleVersions(sess, rolesToUpgrade) + }) +} + +// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role. +// It returns a mapping from builtin role name (for example "Admin") to role ID. +func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) { + uidToBuiltin := make(map[string]string, len(defs)) + uids := make([]any, 0, len(defs)) + for builtin, def := range defs { + uidToBuiltin[def.UID] = builtin + uids = append(uids, def.UID) + } + + type roleRow struct { + ID int64 `xorm:"id"` + UID string `xorm:"uid"` + } + + rows := []roleRow{} + if err := sess.Table("role"). + Where("org_id = ?", accesscontrol.GlobalOrgID). + In("uid", uids...). + Find(&rows); err != nil { + return nil, err + } + + ts := time.Now() + + builtinToRoleID := make(map[string]int64, len(defs)) + for _, r := range rows { + br, ok := uidToBuiltin[r.UID] + if !ok { + continue + } + builtinToRoleID[br] = r.ID + } + + for builtin, def := range defs { + roleID, ok := builtinToRoleID[builtin] + if !ok { + role := accesscontrol.Role{ + OrgID: def.OrgID, + Version: def.Version, + UID: def.UID, + Name: def.Name, + DisplayName: def.DisplayName, + Description: def.Description, + Group: def.Group, + Hidden: def.Hidden, + Created: ts, + Updated: ts, + } + if _, err := sess.Insert(&role); err != nil { + return nil, err + } + roleID = role.ID + builtinToRoleID[builtin] = roleID + } + + has, err := sess.Table("builtin_role"). + Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID). + Exist() + if err != nil { + return nil, err + } + if !has { + br := accesscontrol.BuiltinRole{ + RoleID: roleID, + OrgID: accesscontrol.GlobalOrgID, + Role: builtin, + Created: ts, + Updated: ts, + } + if _, err := sess.Table("builtin_role").Insert(&br); err != nil { + return nil, err + } + } + } + + return builtinToRoleID, nil +} + +// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs. +// +// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back +// to a WHERE ... OR ... form for MSSQL. +func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error { + if len(perms) == 0 { + return nil + } + + if dbType == migrator.MSSQL { + // MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors. + where := make([]string, 0, len(perms)) + args := make([]any, 0, 1+len(perms)*2) + args = append(args, roleID) + for _, p := range perms { + where = append(where, "(action = ? AND scope = ?)") + args = append(args, p.Action, p.Scope) + } + _, err := sess.Exec( + append([]any{ + "DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")", + }, args...)..., + ) + return err + } + + args := make([]any, 0, 1+len(perms)*2) + args = append(args, roleID) + for _, p := range perms { + args = append(args, p.Action, p.Scope) + } + sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" + + strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))" + _, err := sess.Exec(append([]any{sql}, args...)...) + return err +} + +type ossBasicRoleSeedBackend struct { + sess *db.Session + now time.Time + builtinToRoleID map[string]int64 + desired map[accesscontrol.SeedPermission]struct{} + dbType core.DbType +} + +func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + rows, err := LoadBasicRoleSeedPermissions(b.sess) + if err != nil { + return nil, err + } + + out := make(map[accesscontrol.SeedPermission]struct{}, len(rows)) + for _, r := range rows { + // Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh). + r.Origin = "" + out[r] = struct{}{} + } + return out, nil +} + +func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) { + return b.desired, nil +} + +func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error { + // Delete removed permissions (this includes user-defined permissions that aren't in desired). + if len(removed) > 0 { + permsByRoleID := map[int64][]accesscontrol.SeedPermission{} + for _, p := range removed { + roleID, ok := b.builtinToRoleID[p.BuiltInRole] + if !ok { + continue + } + permsByRoleID[roleID] = append(permsByRoleID[roleID], p) + } + + for roleID, perms := range permsByRoleID { + // Chunk to keep statement sizes and parameter counts bounded. + if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error { + return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end]) + }); err != nil { + return err + } + } + } + + // Insert added permissions and updated-target permissions. + toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated)) + toInsertSeed = append(toInsertSeed, added...) + for _, v := range updated { + toInsertSeed = append(toInsertSeed, v) + } + if len(toInsertSeed) == 0 { + return nil + } + + // De-duplicate on (role_id, action, scope). This avoids unique constraint violations when: + // - the same permission appears in both added and updated + // - multiple plugin origins grant the same permission (Origin is not persisted in permission table) + type permKey struct { + RoleID int64 + Action string + Scope string + } + seen := make(map[permKey]struct{}, len(toInsertSeed)) + + toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed)) + for _, p := range toInsertSeed { + roleID, ok := b.builtinToRoleID[p.BuiltInRole] + if !ok { + continue + } + k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + + perm := accesscontrol.Permission{ + RoleID: roleID, + Action: p.Action, + Scope: p.Scope, + Created: b.now, + Updated: b.now, + } + perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope) + toInsert = append(toInsert, perm) + } + + return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error { + // MySQL: ignore conflicts to make seeding idempotent under retries/concurrency. + // Conflicts can happen if the same permission already exists (unique on role_id, action, scope). + if b.dbType == migrator.MySQL { + args := make([]any, 0, (end-start)*8) + for i := start; i < end; i++ { + p := toInsert[i] + args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created) + } + sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` + + strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...) + _, err := b.sess.Exec(sql...) + return err + } + + _, err := b.sess.InsertMulti(toInsert[start:end]) + return err + }) +} + +func batch(count, size int, eachFn func(start, end int) error) error { + for i := 0; i < count; { + end := i + size + if end > count { + end = count + } + if err := eachFn(i, end); err != nil { + return err + } + i = end + } + return nil +} + +// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin). +// Unknown role names are ignored. +func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error { + if len(basicRoles) == 0 { + return nil + } + + defs := accesscontrol.BuildBasicRoleDefinitions() + uids := make([]any, 0, len(basicRoles)) + for _, br := range basicRoles { + def, ok := defs[br] + if !ok { + continue + } + uids = append(uids, def.UID) + } + if len(uids) == 0 { + return nil + } + + sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")" + _, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...) + return err +} + +// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles. +// It sets Origin to empty. +func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) { + rows := []accesscontrol.SeedPermission{} + err := sess.SQL( + `SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin + FROM role INNER JOIN permission AS p ON p.role_id = role.id + WHERE role.org_id = ? AND role.name LIKE 'basic:%'`, + accesscontrol.GlobalOrgID, + ).Find(&rows) + return rows, err +} diff --git a/pkg/services/accesscontrol/dualwrite/collectors.go b/pkg/services/accesscontrol/dualwrite/collectors.go index 28ebd1edb02..87d00f0224b 100644 --- a/pkg/services/accesscontrol/dualwrite/collectors.go +++ b/pkg/services/accesscontrol/dualwrite/collectors.go @@ -4,6 +4,8 @@ import ( "context" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" @@ -435,6 +437,11 @@ func anonymousRoleBindingsCollector(cfg *setting.Cfg, store db.DB) legacyTupleCo func zanzanaCollector(relations []string) zanzanaTupleCollector { return func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.zanzanaTupleCollector", + trace.WithAttributes(attribute.String("namespace", namespace)), + ) + defer span.End() + // list will use continuation token to collect all tuples for object and relation list := func(relation string) ([]*openfgav1.Tuple, error) { first, err := client.Read(ctx, &authzextv1.ReadRequest{ diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index a0f2f47b77d..ff6637219a4 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/serverlock" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" @@ -130,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error { // Reconcile schedules as job that will run and reconcile resources between // legacy access control and zanzana. func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { + // Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions. + // This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service. + r.waitForBasicRolesSeeded(ctx) r.reconcile(ctx) // FIXME: @@ -145,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error { } } +func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool { + var count int64 + // Basic role permissions are stored on "basic:%" roles in the global org (0). + // In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs. + type row struct { + Count int64 `xorm:"count"` + } + _ = r.store.WithDbSession(ctx, func(sess *db.Session) error { + var rr row + _, err := sess.SQL( + `SELECT COUNT(*) AS count + FROM role INNER JOIN permission AS p ON p.role_id = role.id + WHERE role.org_id = ? AND role.name LIKE ?`, + accesscontrol.GlobalOrgID, + accesscontrol.BasicRolePrefix+"%", + ).Get(&rr) + if err != nil { + return err + } + count = rr.Count + return nil + }) + return count > 0 +} + +func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) { + // Best-effort: don't block forever. If we can't observe basic roles, proceed anyway. + const ( + maxWait = 15 * time.Second + interval = 1 * time.Second + ) + + deadline := time.NewTimer(maxWait) + defer deadline.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + if r.hasBasicRolePermissions(ctx) { + return + } + select { + case <-ctx.Done(): + return + case <-deadline.C: + return + case <-ticker.C: + } + } +} + func (r *ZanzanaReconciler) reconcile(ctx context.Context) { run := func(ctx context.Context, namespace string) (ok bool) { now := time.Now() diff --git a/pkg/services/accesscontrol/dualwrite/reconciler_test.go b/pkg/services/accesscontrol/dualwrite/reconciler_test.go new file mode 100644 index 00000000000..0defea011a0 --- /dev/null +++ b/pkg/services/accesscontrol/dualwrite/reconciler_test.go @@ -0,0 +1,67 @@ +package dualwrite + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) { + env := setupTestEnv(t) + + r := &ZanzanaReconciler{ + store: env.db, + } + + ctx := context.Background() + require.False(t, r.hasBasicRolePermissions(ctx)) + + err := env.db.WithDbSession(ctx, func(sess *db.Session) error { + now := time.Now() + + _, err := sess.Exec( + `INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + accesscontrol.GlobalOrgID, + "basic_viewer_uid_test", + accesscontrol.BasicRolePrefix+"viewer", + "Viewer", + "Basic", + "Viewer role", + false, + 1, + now, + now, + ) + if err != nil { + return err + } + + var roleID int64 + if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil { + return err + } + + _, err = sess.Exec( + `INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + roleID, + "dashboards:read", + "dashboards:*", + "", + "", + "", + now, + now, + ) + return err + }) + require.NoError(t, err) + + require.True(t, r.hasBasicRolePermissions(ctx)) +} diff --git a/pkg/services/accesscontrol/dualwrite/resource_reconciler.go b/pkg/services/accesscontrol/dualwrite/resource_reconciler.go index 63a4f8b25eb..c51e6a771c7 100644 --- a/pkg/services/accesscontrol/dualwrite/resource_reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/resource_reconciler.go @@ -3,11 +3,15 @@ package dualwrite import ( "context" "fmt" + "strings" openfgav1 "github.com/openfga/api/proto/openfga/v1" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" claims "github.com/grafana/authlib/types" + dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" "github.com/grafana/grafana/pkg/services/authz/zanzana" ) @@ -19,24 +23,60 @@ type legacyTupleCollector func(ctx context.Context, orgID int64) (map[string]map type zanzanaTupleCollector func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) type resourceReconciler struct { - name string - legacy legacyTupleCollector - zanzana zanzanaTupleCollector - client zanzana.Client + name string + legacy legacyTupleCollector + zanzana zanzanaTupleCollector + client zanzana.Client + orphanObjectPrefix string + orphanRelations []string } -func newResourceReconciler(name string, legacy legacyTupleCollector, zanzana zanzanaTupleCollector, client zanzana.Client) resourceReconciler { - return resourceReconciler{name, legacy, zanzana, client} +func newResourceReconciler(name string, legacy legacyTupleCollector, zanzanaCollector zanzanaTupleCollector, client zanzana.Client) resourceReconciler { + r := resourceReconciler{name: name, legacy: legacy, zanzana: zanzanaCollector, client: client} + + // we only need to worry about orphaned tuples for reconcilers that use the managed permissions collector (i.e. dashboards & folders) + switch name { + case "managed folder permissions": + // prefix for folders is `folder:` + r.orphanObjectPrefix = zanzana.NewObjectEntry(zanzana.TypeFolder, "", "", "", "") + r.orphanRelations = append([]string{}, zanzana.RelationsFolder...) + case "managed dashboard permissions": + // prefix for dashboards will be `resource:dashboard.grafana.app/dashboards/` + r.orphanObjectPrefix = fmt.Sprintf("%s/", zanzana.NewObjectEntry(zanzana.TypeResource, dashboardV1.APIGroup, dashboardV1.DASHBOARD_RESOURCE, "", "")) + r.orphanRelations = append([]string{}, zanzana.RelationsResouce...) + } + + return r } func (r resourceReconciler) reconcile(ctx context.Context, namespace string) error { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.reconcile", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + defer span.End() + info, err := claims.ParseNamespace(namespace) if err != nil { return err } + // 0. Fetch all tuples currently stored in Zanzana. This will be used later on + // to cleanup orphaned tuples. + // This order needs to be kept (fetching from Zanzana first) to avoid accidentally + // cleaning up new tuples that were added after the legacy tuples were fetched. + allTuplesInZanzana, err := r.readAllTuples(ctx, namespace) + if err != nil { + return fmt.Errorf("failed to read all tuples from zanzana for %s: %w", r.name, err) + } + // 1. Fetch grafana resources stored in grafana db. - res, err := r.legacy(ctx, info.OrgID) + legacyCtx, legacySpan := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.legacyCollector", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + res, err := r.legacy(legacyCtx, info.OrgID) + legacySpan.End() if err != nil { return fmt.Errorf("failed to collect legacy tuples for %s: %w", r.name, err) } @@ -87,6 +127,14 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err } } + // when the last managed permission for a resource is removed, the legacy results will no + // longer contain any tuples for that resource. this process cleans it up when applicable. + orphans, err := r.collectOrphanDeletes(ctx, namespace, allTuplesInZanzana, res) + if err != nil { + return fmt.Errorf("failed to collect orphan deletes (%s): %w", r.name, err) + } + deletes = append(deletes, orphans...) + if len(writes) == 0 && len(deletes) == 0 { return nil } @@ -119,3 +167,85 @@ func (r resourceReconciler) reconcile(ctx context.Context, namespace string) err return nil } + +// collectOrphanDeletes collects tuples that are no longer present in the legacy results +// but still are present in zanzana. when that is the case, we need to delete the tuple from +// zanzana. this will happen when the last managed permission for a resource is removed. +// this is only used for dashboards and folders, as those are the only resources that use the managed permissions collector. +func (r resourceReconciler) collectOrphanDeletes( + ctx context.Context, + namespace string, + allTuplesInZanzana []*authzextv1.Tuple, + legacyReturnedTuples map[string]map[string]*openfgav1.TupleKey, +) ([]*openfgav1.TupleKeyWithoutCondition, error) { + if r.orphanObjectPrefix == "" || len(r.orphanRelations) == 0 { + return []*openfgav1.TupleKeyWithoutCondition{}, nil + } + + seen := map[string]struct{}{} + out := []*openfgav1.TupleKeyWithoutCondition{} + + // what relation types we are interested in cleaning up + relationsToCleanup := map[string]struct{}{} + for _, rel := range r.orphanRelations { + relationsToCleanup[rel] = struct{}{} + } + + for _, tuple := range allTuplesInZanzana { + if tuple == nil || tuple.Key == nil { + continue + } + // only cleanup the particular relation types we are interested in + if _, ok := relationsToCleanup[tuple.Key.Relation]; !ok { + continue + } + // only cleanup the particular object types we are interested in (either dashboards or folders) + if !strings.HasPrefix(tuple.Key.Object, r.orphanObjectPrefix) { + continue + } + // if legacy returned this object, it's not orphaned + if _, ok := legacyReturnedTuples[tuple.Key.Object]; ok { + continue + } + // keep track of the tuples we have already seen and marked for deletion + key := fmt.Sprintf("%s|%s|%s", tuple.Key.User, tuple.Key.Relation, tuple.Key.Object) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, &openfgav1.TupleKeyWithoutCondition{ + User: tuple.Key.User, + Relation: tuple.Key.Relation, + Object: tuple.Key.Object, + }) + } + + return out, nil +} + +func (r resourceReconciler) readAllTuples(ctx context.Context, namespace string) ([]*authzextv1.Tuple, error) { + ctx, span := tracer.Start(ctx, "accesscontrol.dualwrite.resourceReconciler.zanzana.readAllTuples", + trace.WithAttributes(attribute.String("namespace", namespace)), + trace.WithAttributes(attribute.String("reconciler", r.name)), + ) + defer span.End() + + var ( + out []*authzextv1.Tuple + continueToken string + ) + for { + res, err := r.client.Read(ctx, &authzextv1.ReadRequest{ + Namespace: namespace, + ContinuationToken: continueToken, + }) + if err != nil { + return nil, err + } + out = append(out, res.Tuples...) + continueToken = res.ContinuationToken + if continueToken == "" { + return out, nil + } + } +} diff --git a/pkg/services/accesscontrol/dualwrite/resource_reconciler_orphan_test.go b/pkg/services/accesscontrol/dualwrite/resource_reconciler_orphan_test.go new file mode 100644 index 00000000000..8daf06a8a77 --- /dev/null +++ b/pkg/services/accesscontrol/dualwrite/resource_reconciler_orphan_test.go @@ -0,0 +1,110 @@ +package dualwrite + +import ( + "context" + "testing" + + authlib "github.com/grafana/authlib/types" + openfgav1 "github.com/openfga/api/proto/openfga/v1" + "github.com/stretchr/testify/require" + + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/authz/zanzana" +) + +type fakeZanzanaClient struct { + readTuples []*authzextv1.Tuple + writeReqs []*authzextv1.WriteRequest +} + +func (f *fakeZanzanaClient) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error) { + return &authzextv1.ReadResponse{ + Tuples: f.readTuples, + ContinuationToken: "", + }, nil +} + +func (f *fakeZanzanaClient) Write(ctx context.Context, req *authzextv1.WriteRequest) error { + f.writeReqs = append(f.writeReqs, req) + return nil +} + +func (f *fakeZanzanaClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) { + return &authzextv1.BatchCheckResponse{}, nil +} + +func (f *fakeZanzanaClient) Mutate(ctx context.Context, req *authzextv1.MutateRequest) error { + return nil +} + +func (f *fakeZanzanaClient) Query(ctx context.Context, req *authzextv1.QueryRequest) (*authzextv1.QueryResponse, error) { + return &authzextv1.QueryResponse{}, nil +} + +func (f *fakeZanzanaClient) Check(ctx context.Context, info authlib.AuthInfo, req authlib.CheckRequest, folder string) (authlib.CheckResponse, error) { + return authlib.CheckResponse{Allowed: true}, nil +} + +func (f *fakeZanzanaClient) Compile(ctx context.Context, info authlib.AuthInfo, req authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) { + return func(name, folder string) bool { return true }, authlib.NoopZookie{}, nil +} + +func TestResourceReconciler_OrphanedManagedDashboardTuplesAreDeleted(t *testing.T) { + legacy := func(ctx context.Context, orgID int64) (map[string]map[string]*openfgav1.TupleKey, error) { + return map[string]map[string]*openfgav1.TupleKey{}, nil + } + zCollector := func(ctx context.Context, client zanzana.Client, object string, namespace string) (map[string]*openfgav1.TupleKey, error) { + return map[string]*openfgav1.TupleKey{}, nil + } + + fake := &fakeZanzanaClient{} + r := newResourceReconciler("managed dashboard permissions", legacy, zCollector, fake) + + require.NotEmpty(t, r.orphanObjectPrefix) + require.NotEmpty(t, r.orphanRelations) + + relAllowed := r.orphanRelations[0] + objAllowed := r.orphanObjectPrefix + "dash-uid-1" + + fake.readTuples = []*authzextv1.Tuple{ + // should be removed + { + Key: &authzextv1.TupleKey{ + User: "user:1", + Relation: relAllowed, + Object: objAllowed, + }, + }, + + // same relation but different object type/prefix - should stay + { + Key: &authzextv1.TupleKey{ + User: "user:1", + Relation: relAllowed, + Object: "folder:some-folder", + }, + }, + // same prefix but different relation - should stay + { + Key: &authzextv1.TupleKey{ + User: "user:1", + Relation: zanzana.RelationParent, + Object: objAllowed, + }, + }, + } + + err := r.reconcile(context.Background(), authlib.OrgNamespaceFormatter(1)) + require.NoError(t, err) + + require.Len(t, fake.writeReqs, 1) + wr := fake.writeReqs[0] + require.NotNil(t, wr.Deletes) + require.Nil(t, wr.Writes) + + require.Len(t, wr.Deletes.TupleKeys, 1) + del := wr.Deletes.TupleKeys[0] + require.Equal(t, "user:1", del.User) + require.Equal(t, relAllowed, del.Relation) + require.Equal(t, objAllowed, del.Object) +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index b18fb4134f3..85df44750d2 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -1,6 +1,7 @@ package accesscontrol import ( + "context" "encoding/json" "errors" "fmt" @@ -594,3 +595,18 @@ type QueryWithOrg struct { OrgId *int64 `json:"orgId"` Global bool `json:"global"` } + +type SeedPermission struct { + BuiltInRole string `xorm:"builtin_role"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + Origin string `xorm:"origin"` +} + +type RoleStore interface { + LoadRoles(ctx context.Context) (map[string]*RoleDTO, error) + SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error + SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error + CreateRole(ctx context.Context, role RoleDTO) error + DeleteRoles(ctx context.Context, roleUIDs []string) error +} diff --git a/pkg/services/accesscontrol/seeding/seeder.go b/pkg/services/accesscontrol/seeding/seeder.go new file mode 100644 index 00000000000..121be48dbb0 --- /dev/null +++ b/pkg/services/accesscontrol/seeding/seeder.go @@ -0,0 +1,452 @@ +package seeding + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils" + "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" +) + +type Seeder struct { + log log.Logger + roleStore accesscontrol.RoleStore + backend SeedingBackend + builtinsPermissions map[accesscontrol.SeedPermission]struct{} + seededFixedRoles map[string]bool + seededPluginRoles map[string]bool + seededPlugins map[string]bool + hasSeededAlready bool +} + +// SeedingBackend provides the seed-set specific operations needed to seed. +type SeedingBackend interface { + // LoadPrevious returns the currently stored permissions for previously seeded roles. + LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) + + // Apply updates the database to match the desired permissions. + Apply(ctx context.Context, + added, removed []accesscontrol.SeedPermission, + updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, + ) error +} + +func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder { + return &Seeder{ + log: log, + roleStore: roleStore, + backend: backend, + builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{}, + seededFixedRoles: map[string]bool{}, + seededPluginRoles: map[string]bool{}, + seededPlugins: map[string]bool{}, + hasSeededAlready: false, + } +} + +// SetDesiredPermissions replaces the in-memory desired permission set used by Seed(). +func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) { + if desired == nil { + s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{} + return + } + s.builtinsPermissions = desired +} + +// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions. +func (s *Seeder) Seed(ctx context.Context) error { + previous, err := s.backend.LoadPrevious(ctx) + if err != nil { + return err + } + + // - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins). + // - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default). + if len(previous) > 0 { + filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous)) + for p := range previous { + // Legacy plugin app access permissions (Origin set) are granted by default and managed outside seeding. + // Keep them out of the diff so seeding doesn't try to remove or "re-add" them on every run. + if p.Action == pluginaccesscontrol.ActionAppAccess && p.Origin != "" { + continue + } + if p.Origin != "" && !s.seededPlugins[p.Origin] { + continue + } + filtered[p] = struct{}{} + } + previous = filtered + } + + added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions) + + if err := s.backend.Apply(ctx, added, removed, updated); err != nil { + return err + } + return nil +} + +// SeedRoles populates the database with the roles and their assignments +// It will create roles that do not exist and update roles that have changed +// Do not use for provisioning. Validation is not enforced. +func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error { + roleMap, err := s.roleStore.LoadRoles(ctx) + if err != nil { + return err + } + + missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList)) + + // Diff existing roles with the ones we want to seed. + // If a role is missing, we add it to the missingRoles list + for _, registration := range registrationList { + registration := registration + role, ok := roleMap[registration.Role.Name] + switch { + case registration.Role.IsFixed(): + s.seededFixedRoles[registration.Role.Name] = true + case registration.Role.IsPlugin(): + s.seededPluginRoles[registration.Role.Name] = true + // To be resilient to failed plugin loadings, we remember the plugins that have registered, + // later we'll ignore permissions and roles of other plugins + s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true + } + + s.rememberPermissionAssignments(®istration.Role, registration.Grants, registration.Exclude) + + if !ok { + missingRoles = append(missingRoles, registration) + continue + } + + if needsRoleUpdate(role, registration.Role) { + if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil { + return err + } + } + + if needsPermissionsUpdate(role, registration.Role) { + if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil { + return err + } + } + } + + for _, registration := range missingRoles { + if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil { + return err + } + } + + return nil +} + +func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { + if existingRole == nil { + return true + } + + if len(existingRole.Permissions) != len(wantedRole.Permissions) { + return true + } + + for _, p := range wantedRole.Permissions { + found := false + for _, ep := range existingRole.Permissions { + if ep.Action == p.Action && ep.Scope == p.Scope { + found = true + break + } + } + if !found { + return true + } + } + + return false +} + +func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool { + if existingRole == nil { + return true + } + + if existingRole.Name != wantedRole.Name { + return false + } + + if existingRole.DisplayName != wantedRole.DisplayName { + return true + } + + if existingRole.Description != wantedRole.Description { + return true + } + + if existingRole.Group != wantedRole.Group { + return true + } + + if existingRole.Hidden != wantedRole.Hidden { + return true + } + + return false +} + +// Deprecated: SeedRole is deprecated and should not be used. +// SeedRoles only does boot up seeding and should not be used for runtime seeding. +func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error { + addedPermissions := make(map[string]struct{}, len(role.Permissions)) + permissions := make([]accesscontrol.Permission, 0, len(role.Permissions)) + for _, p := range role.Permissions { + key := fmt.Sprintf("%s:%s", p.Action, p.Scope) + if _, ok := addedPermissions[key]; !ok { + addedPermissions[key] = struct{}{} + permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope}) + } + } + + wantedRole := accesscontrol.RoleDTO{ + OrgID: accesscontrol.GlobalOrgID, + Version: role.Version, + UID: role.UID, + Name: role.Name, + DisplayName: role.DisplayName, + Description: role.Description, + Group: role.Group, + Permissions: permissions, + Hidden: role.Hidden, + } + roleMap, err := s.roleStore.LoadRoles(ctx) + if err != nil { + return err + } + + existingRole := roleMap[wantedRole.Name] + if existingRole == nil { + if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil { + return err + } + } else { + if needsRoleUpdate(existingRole, wantedRole) { + if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil { + return err + } + } + if needsPermissionsUpdate(existingRole, wantedRole) { + if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil { + return err + } + } + } + + // Remember seeded roles + if wantedRole.IsFixed() { + s.seededFixedRoles[wantedRole.Name] = true + } + isPluginRole := wantedRole.IsPlugin() + if isPluginRole { + s.seededPluginRoles[wantedRole.Name] = true + + // To be resilient to failed plugin loadings, we remember the plugins that have registered, + // later we'll ignore permissions and roles of other plugins + s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true + } + + s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{}) + return nil +} + +func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) { + AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles) +} + +// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin). +// - It expands parents via accesscontrol.BuiltInRolesWithParents. +// - It can optionally ignore plugin app access permissions (which are granted by default). +func AppendDesiredPermissions( + out map[accesscontrol.SeedPermission]struct{}, + logger log.Logger, + role *accesscontrol.RoleDTO, + builtInRoles []string, + excludedRoles []string, +) { + if out == nil || role == nil { + return + } + + for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) { + // Skip excluded grants + if slices.Contains(excludedRoles, builtInRole) { + continue + } + + for _, perm := range role.Permissions { + if role.IsPlugin() && perm.Action == pluginaccesscontrol.ActionAppAccess { + logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored", + "role", role.Name, "permission", perm.Action, "scope", perm.Scope) + continue + } + + sp := accesscontrol.SeedPermission{ + BuiltInRole: builtInRole, + Action: perm.Action, + Scope: perm.Scope, + } + + if role.IsPlugin() { + sp.Origin = pluginutils.PluginIDFromName(role.Name) + } + + out[sp] = struct{}{} + } + } +} + +// permissionDiff returns: +// - added: present in desired permissions, not in previous permissions +// - removed: present in previous permissions, not in desired permissions +// - updated: same role + action, but scope changed +func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) { + addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0) + for n := range desired { + if _, already := previous[n]; !already { + addedSet[n] = struct{}{} + } else { + delete(previous, n) + } + } + + // Check if any of the new permissions is actually an old permission with an updated scope + updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0) + for n := range addedSet { + for p := range previous { + if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action { + updated[p] = n + delete(addedSet, n) + } + } + } + + for p := range addedSet { + added = append(added, p) + } + + for p := range previous { + if p.Action == pluginaccesscontrol.ActionAppAccess && + p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() { + // Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added + s.log.Info("This permission already existed so it will not be removed", + "role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope) + continue + } + + removed = append(removed, p) + } + + return added, removed, updated +} + +func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) { + removable := []accesscontrol.SeedPermission{} + + for key := range s.builtinsPermissions { + if matchPermissionByPluginID(key, ID) { + removable = append(removable, key) + } + } + + for _, perm := range removable { + delete(s.builtinsPermissions, perm) + } +} + +func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool { + if perm.Origin != pluginID { + return false + } + actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID)) + scopeTemplate := fmt.Sprintf(":%s", pluginID) + return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate) +} + +// RolesToUpgrade returns the unique basic roles that should have their version incremented. +func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string { + set := map[string]struct{}{} + for _, p := range added { + set[p.BuiltInRole] = struct{}{} + } + for _, p := range removed { + set[p.BuiltInRole] = struct{}{} + } + out := make([]string, 0, len(set)) + for r := range set { + out = append(out, r) + } + return out +} + +func (s *Seeder) ClearPluginRoles(ID string) { + expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID) + + for roleName := range s.seededPluginRoles { + if strings.HasPrefix(roleName, expectedPrefix) { + delete(s.seededPluginRoles, roleName) + } + } +} + +func (s *Seeder) MarkSeededAlready() { + s.hasSeededAlready = true +} + +func (s *Seeder) HasSeededAlready() bool { + return s.hasSeededAlready +} + +func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error { + roleMap, errGet := s.roleStore.LoadRoles(ctx) + if errGet != nil { + s.log.Error("failed to get fixed roles from store", "err", errGet) + return errGet + } + + toRemove := []string{} + for _, r := range roleMap { + if r == nil { + continue + } + if r.IsFixed() { + if !s.seededFixedRoles[r.Name] { + s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) + toRemove = append(toRemove, r.UID) + } + continue + } + + if r.IsPlugin() { + if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] { + // To be resilient to failed plugin loadings + // ignore stored roles related to plugins that have not registered this time + s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name) + continue + } + if !s.seededPluginRoles[r.Name] { + s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name) + toRemove = append(toRemove, r.UID) + } + } + } + + if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil { + s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete) + return errDelete + } + return nil +} diff --git a/pkg/services/apiserver/auth/authorizer/authorizer.go b/pkg/services/apiserver/auth/authorizer/authorizer.go index dab8167deb0..b0dcbbc7872 100644 --- a/pkg/services/apiserver/auth/authorizer/authorizer.go +++ b/pkg/services/apiserver/auth/authorizer/authorizer.go @@ -3,7 +3,6 @@ package authorizer import ( "context" - "github.com/grafana/grafana/pkg/setting" "k8s.io/apimachinery/pkg/runtime/schema" k8suser "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" @@ -29,9 +28,9 @@ type GrafanaAuthorizer struct { // 4. We check authorizer that is configured speficially for an api. // 5. As a last fallback we check Role, this will only happen if an api have not configured // an authorizer or return authorizer.DecisionNoOpinion -func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer { +func NewGrafanaBuiltInSTAuthorizer() *GrafanaAuthorizer { authorizers := []authorizer.Authorizer{ - newImpersonationAuthorizer(), + NewImpersonationAuthorizer(), authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup), newNamespaceAuthorizer(), } diff --git a/pkg/services/apiserver/auth/authorizer/impersonation.go b/pkg/services/apiserver/auth/authorizer/impersonation.go index c736173bd5f..067d364ef56 100644 --- a/pkg/services/apiserver/auth/authorizer/impersonation.go +++ b/pkg/services/apiserver/auth/authorizer/impersonation.go @@ -8,7 +8,7 @@ import ( var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil) -func newImpersonationAuthorizer() *impersonationAuthorizer { +func NewImpersonationAuthorizer() *impersonationAuthorizer { return &impersonationAuthorizer{} } diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index c535443a91e..774f194e2a8 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -76,19 +76,7 @@ var PathRewriters = []filters.PathRewriter{ func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc { return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler { - requestHandler, err := GetCustomRoutesHandler( - delegateHandler, - c.LoopbackClientConfig, - builders, - reg, - c.MergedResourceConfig, - ) - if err != nil { - panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error())) - } - - // Needs to run last in request chain to function as expected, hence we register it first. - handler := filters.WithTracingHTTPLoggingAttributes(requestHandler) + handler := filters.WithTracingHTTPLoggingAttributes(delegateHandler) // filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context handler = filters.WithRequester(handler) diff --git a/pkg/services/apiserver/builder/request_handler.go b/pkg/services/apiserver/builder/request_handler.go index 50761f1d42c..4e5a355f453 100644 --- a/pkg/services/apiserver/builder/request_handler.go +++ b/pkg/services/apiserver/builder/request_handler.go @@ -3,146 +3,306 @@ package builder import ( "fmt" "net/http" + "strings" + "github.com/emicklei/go-restful/v3" "github.com/gorilla/mux" "github.com/prometheus/client_golang/prometheus" serverstorage "k8s.io/apiserver/pkg/server/storage" - restclient "k8s.io/client-go/rest" klog "k8s.io/klog/v2" "k8s.io/kube-openapi/pkg/spec3" ) -type requestHandler struct { - router *mux.Router +// convertHandlerToRouteFunction converts an http.HandlerFunc to a restful.RouteFunction +// It extracts path parameters from restful.Request and populates them in the request context +// so that mux.Vars can read them (for backward compatibility with handlers that use mux.Vars) +func convertHandlerToRouteFunction(handler http.HandlerFunc) restful.RouteFunction { + return func(req *restful.Request, resp *restful.Response) { + // Extract path parameters from restful.Request and populate mux.Vars + // This is needed for backward compatibility with handlers that use mux.Vars(r) + vars := make(map[string]string) + + // Get all path parameters from the restful.Request + // The restful.Request has PathParameters() method that returns a map + pathParams := req.PathParameters() + for key, value := range pathParams { + vars[key] = value + } + + // Set the vars in the request context using mux.SetURLVars + // This makes mux.Vars(r) work correctly + if len(vars) > 0 { + req.Request = mux.SetURLVars(req.Request, vars) + } + + handler(resp.ResponseWriter, req.Request) + } } -func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) { - useful := false // only true if any routes exist anywhere - router := mux.NewRouter() +// AugmentWebServicesWithCustomRoutes adds custom routes from builders to existing WebServices +// in the container. +func AugmentWebServicesWithCustomRoutes( + container *restful.Container, + builders []APIGroupBuilder, + metricsRegistry prometheus.Registerer, + apiResourceConfig *serverstorage.ResourceConfig, +) error { + if container == nil { + return fmt.Errorf("container cannot be nil") + } metrics := NewCustomRouteMetrics(metricsRegistry) - for _, builder := range builders { - provider, ok := builder.(APIGroupRouteProvider) + // Build a map of existing WebServices by root path + existingWebServices := make(map[string]*restful.WebService) + for _, ws := range container.RegisteredWebServices() { + existingWebServices[ws.RootPath()] = ws + } + + for _, b := range builders { + provider, ok := b.(APIGroupRouteProvider) if !ok || provider == nil { continue } - for _, gv := range GetGroupVersions(builder) { - // filter out api groups that are disabled in APIEnablementOptions + for _, gv := range GetGroupVersions(b) { + // Filter out disabled API groups gvr := gv.WithResource("") if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) { - klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String()) + klog.InfoS("Skipping custom routes for disabled group version", "gv", gv.String()) continue } + routes := provider.GetAPIRoutes(gv) if routes == nil { continue } - prefix := "/apis/" + gv.String() - - // Root handlers - var sub *mux.Router - for _, route := range routes.Root { - if sub == nil { - sub = router.PathPrefix(prefix).Subrouter() - sub.MethodNotAllowedHandler = &methodNotAllowedHandler{} - } - - useful = true - methods, err := methodsFromSpec(route.Path, route.Spec) - if err != nil { - return nil, err - } - - instrumentedHandler := metrics.InstrumentHandler( - gv.Group, - gv.Version, - route.Path, // Use path as resource identifier - route.Handler, - ) - - sub.HandleFunc("/"+route.Path, instrumentedHandler). - Methods(methods...) + // Find or create WebService for this group version + rootPath := "/apis/" + gv.String() + ws, exists := existingWebServices[rootPath] + if !exists { + // Create a new WebService if one doesn't exist + ws = new(restful.WebService) + ws.Path(rootPath) + container.Add(ws) + existingWebServices[rootPath] = ws } - // Namespace handlers - sub = nil - prefix += "/namespaces/{namespace}" - for _, route := range routes.Namespace { - if sub == nil { - sub = router.PathPrefix(prefix).Subrouter() - sub.MethodNotAllowedHandler = &methodNotAllowedHandler{} - } - - useful = true - methods, err := methodsFromSpec(route.Path, route.Spec) - if err != nil { - return nil, err - } - + // Add root handlers using OpenAPI specs + for _, route := range routes.Root { instrumentedHandler := metrics.InstrumentHandler( gv.Group, gv.Version, - route.Path, // Use path as resource identifier + route.Path, route.Handler, ) + routeFunction := convertHandlerToRouteFunction(instrumentedHandler) - sub.HandleFunc("/"+route.Path, instrumentedHandler). - Methods(methods...) + // Use OpenAPI spec to configure routes properly + if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, false); err != nil { + return fmt.Errorf("failed to add root route %s: %w", route.Path, err) + } + } + + // Add namespace handlers using OpenAPI specs + for _, route := range routes.Namespace { + instrumentedHandler := metrics.InstrumentHandler( + gv.Group, + gv.Version, + route.Path, + route.Handler, + ) + routeFunction := convertHandlerToRouteFunction(instrumentedHandler) + + // Use OpenAPI spec to configure routes properly + if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, true); err != nil { + return fmt.Errorf("failed to add namespace route %s: %w", route.Path, err) + } } } } - if !useful { - return delegateHandler, nil - } - - // Per Gorilla Mux issue here: https://github.com/gorilla/mux/issues/616#issuecomment-798807509 - // default handler must come last - router.PathPrefix("/").Handler(delegateHandler) - - return &requestHandler{ - router: router, - }, nil + return nil } -func (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - h.router.ServeHTTP(w, req) +// addRouteFromSpec adds routes to a WebService using OpenAPI specs +func addRouteFromSpec(ws *restful.WebService, routePath string, pathProps *spec3.PathProps, handler restful.RouteFunction, isNamespaced bool) error { + if pathProps == nil { + return fmt.Errorf("pathProps cannot be nil for route %s", routePath) + } + + // Build the full path (relative to WebService root) + var fullPath string + if isNamespaced { + fullPath = "/namespaces/{namespace}/" + routePath + } else { + fullPath = "/" + routePath + } + + // Add routes for each HTTP method defined in the OpenAPI spec + operations := map[string]*spec3.Operation{ + "GET": pathProps.Get, + "POST": pathProps.Post, + "PUT": pathProps.Put, + "PATCH": pathProps.Patch, + "DELETE": pathProps.Delete, + } + + for method, operation := range operations { + if operation == nil { + continue + } + + // Create route builder for this method + var routeBuilder *restful.RouteBuilder + switch method { + case "GET": + routeBuilder = ws.GET(fullPath) + case "POST": + routeBuilder = ws.POST(fullPath) + case "PUT": + routeBuilder = ws.PUT(fullPath) + case "PATCH": + routeBuilder = ws.PATCH(fullPath) + case "DELETE": + routeBuilder = ws.DELETE(fullPath) + } + + // Set operation ID from OpenAPI spec (with K8s verb prefix if needed) + operationID := operation.OperationId + if operationID == "" { + // Generate from path if not specified + operationID = generateOperationNameFromPath(routePath) + } + operationID = prefixRouteIDWithK8sVerbIfNotPresent(operationID, method) + routeBuilder = routeBuilder.Operation(operationID) + + // Add description from OpenAPI spec + if operation.Description != "" { + routeBuilder = routeBuilder.Doc(operation.Description) + } + + // Check if namespace parameter is already in the OpenAPI spec + hasNamespaceParam := false + if operation.Parameters != nil { + for _, param := range operation.Parameters { + if param.Name == "namespace" && param.In == "path" { + hasNamespaceParam = true + break + } + } + } + + // Add namespace parameter for namespaced routes if not already in spec + if isNamespaced && !hasNamespaceParam { + routeBuilder = routeBuilder.Param(restful.PathParameter("namespace", "object name and auth scope, such as for teams and projects")) + } + + // Add parameters from OpenAPI spec + if operation.Parameters != nil { + for _, param := range operation.Parameters { + switch param.In { + case "path": + routeBuilder = routeBuilder.Param(restful.PathParameter(param.Name, param.Description)) + case "query": + routeBuilder = routeBuilder.Param(restful.QueryParameter(param.Name, param.Description)) + case "header": + routeBuilder = routeBuilder.Param(restful.HeaderParameter(param.Name, param.Description)) + } + } + } + + // Note: Request/response schemas are already defined in the OpenAPI spec from builders + // and will be added to the OpenAPI document via addBuilderRoutes in openapi.go. + // We don't duplicate that information here since restful uses the route metadata + // for OpenAPI generation, which is handled separately in this codebase. + + // Register the route with handler + ws.Route(routeBuilder.To(handler)) + } + + return nil } -func methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) { - if props == nil { - return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil +func prefixRouteIDWithK8sVerbIfNotPresent(operationID string, method string) string { + for _, verb := range allowedK8sVerbs { + if len(operationID) > len(verb) && operationID[:len(verb)] == verb { + return operationID + } } - - methods := make([]string, 0) - if props.Get != nil { - methods = append(methods, "GET") - } - if props.Post != nil { - methods = append(methods, "POST") - } - if props.Put != nil { - methods = append(methods, "PUT") - } - if props.Patch != nil { - methods = append(methods, "PATCH") - } - if props.Delete != nil { - methods = append(methods, "DELETE") - } - - if len(methods) == 0 { - return nil, fmt.Errorf("invalid OpenAPI Spec for slug=%s without any methods in PathProps", slug) - } - - return methods, nil + return fmt.Sprintf("%s%s", httpMethodToK8sVerb[strings.ToUpper(method)], operationID) } -type methodNotAllowedHandler struct{} +var allowedK8sVerbs = []string{ + "get", "log", "read", "replace", "patch", "delete", "deletecollection", "watch", "connect", "proxy", "list", "create", "patch", +} -func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(405) // method not allowed +var httpMethodToK8sVerb = map[string]string{ + http.MethodGet: "get", + http.MethodPost: "create", + http.MethodPut: "replace", + http.MethodPatch: "patch", + http.MethodDelete: "delete", + http.MethodConnect: "connect", + http.MethodOptions: "connect", // No real equivalent to options and head + http.MethodHead: "connect", +} + +// generateOperationNameFromPath creates an operation name from a route path. +// The operation name is used by the OpenAPI generator and should be descriptive. +// It uses meaningful path segments to create readable yet unique operation names. +// Examples: +// - "/search" -> "Search" +// - "/snapshots/create" -> "SnapshotsCreate" +// - "ofrep/v1/evaluate/flags" -> "OfrepEvaluateFlags" +// - "ofrep/v1/evaluate/flags/{flagKey}" -> "OfrepEvaluateFlagsFlagKey" +func generateOperationNameFromPath(routePath string) string { + // Remove leading slash and split by path segments + parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/") + + // Filter to keep meaningful segments and path parameters + var nameParts []string + skipPrefixes := map[string]bool{ + "namespaces": true, + "apis": true, + } + + for _, part := range parts { + if part == "" { + continue + } + + // Extract parameter name from {paramName} format + if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") { + paramName := part[1 : len(part)-1] + // Skip generic parameters like {namespace}, but keep specific ones like {flagKey} + if paramName != "namespace" && paramName != "name" { + nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:]) + } + continue + } + + // Skip common prefixes + if skipPrefixes[strings.ToLower(part)] { + continue + } + + // Skip version segments like v1, v0alpha1, v2beta1, etc. + if strings.HasPrefix(strings.ToLower(part), "v") && + (len(part) <= 3 || strings.Contains(strings.ToLower(part), "alpha") || strings.Contains(strings.ToLower(part), "beta")) { + continue + } + + // Capitalize first letter and add to parts + if len(part) > 0 { + nameParts = append(nameParts, strings.ToUpper(part[:1])+part[1:]) + } + } + + if len(nameParts) == 0 { + return "Route" + } + + return strings.Join(nameParts, "") } diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go index 499cdb4df6f..a0a9f007bcd 100644 --- a/pkg/services/apiserver/config.go +++ b/pkg/services/apiserver/config.go @@ -5,7 +5,6 @@ import ( "net" "path/filepath" "strconv" - "strings" "github.com/grafana/grafana/pkg/services/apiserver/options" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -41,15 +40,6 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver") runtimeConfig := apiserverCfg.Key("runtime_config").String() - runtimeConfigSplit := strings.Split(runtimeConfig, ",") - - // TODO: temporary fix to allow disabling local features service and still being able to use its authz handler - if !cfg.OpenFeature.APIEnabled { - runtimeConfigSplit = append(runtimeConfigSplit, "features.grafana.app/v0alpha1=false") - } - - runtimeConfig = strings.Join(runtimeConfigSplit, ",") - if runtimeConfig != "" { if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil { return fmt.Errorf("failed to set runtime config: %w", err) diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 6c92350ec2a..7fd5e51a96b 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -155,7 +155,7 @@ func ProvideService( features: features, rr: rr, builders: []builder.APIGroupBuilder{}, - authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(cfg), + authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(), tracing: tracing, db: db, // For Unified storage metrics: reg, @@ -443,6 +443,19 @@ func (s *service) start(ctx context.Context) error { return err } + // Augment existing WebServices with custom routes from builders + // This directly adds routes to existing WebServices using the OpenAPI specs from builders + if server.Handler != nil && server.Handler.GoRestfulContainer != nil { + if err := builder.AugmentWebServicesWithCustomRoutes( + server.Handler.GoRestfulContainer, + builders, + s.metrics, + serverConfig.MergedResourceConfig, + ); err != nil { + return fmt.Errorf("failed to augment web services with custom routes: %w", err) + } + } + // stash the options for later use s.options = o diff --git a/pkg/services/authz/rbac/mapper.go b/pkg/services/authz/rbac/mapper.go index dcf2432fb2c..fd0b4f78b42 100644 --- a/pkg/services/authz/rbac/mapper.go +++ b/pkg/services/authz/rbac/mapper.go @@ -182,25 +182,6 @@ func newFolderTranslation() translation { return folderTranslation } -func newExternalGroupMappingTranslation() translation { - return translation{ - resource: "teams.permissions", - attribute: "uid", - verbMapping: map[string]string{ - utils.VerbGet: "teams.permissions:read", - utils.VerbList: "teams.permissions:read", - utils.VerbWatch: "teams.permissions:read", - utils.VerbCreate: "teams.permissions:write", - utils.VerbUpdate: "teams.permissions:write", - utils.VerbPatch: "teams.permissions:write", - utils.VerbDelete: "teams.permissions:write", - utils.VerbGetPermissions: "teams.permissions:write", - utils.VerbSetPermissions: "teams.permissions:write", - }, - folderSupport: false, - } -} - func NewMapperRegistry() MapperRegistry { skipScopeOnAllVerbs := map[string]bool{ utils.VerbCreate: true, @@ -229,8 +210,6 @@ func NewMapperRegistry() MapperRegistry { "serviceaccounts": newResourceTranslation("serviceaccounts", "uid", false, map[string]bool{utils.VerbCreate: true}), // Teams is a special case. We translate user permissions from id to uid based. "teams": newResourceTranslation("teams", "uid", false, map[string]bool{utils.VerbCreate: true}), - // ExternalGroupMappings is a special case. We translate team permissions from id to uid based. - "externalgroupmappings": newExternalGroupMappingTranslation(), "coreroles": translation{ resource: "roles", attribute: "uid", diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 79eb19a8b5b..b1d49d9ee17 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -90,7 +90,7 @@ func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, fea authzv1.RegisterAuthzServiceServer(channel, srv) authzextv1.RegisterAuthzExtentionServiceServer(channel, srv) - client, err := zClient.New(channel) + client, err := zClient.New(channel, reg) if err != nil { return nil, fmt.Errorf("failed to initialize zanzana client: %w", err) } @@ -169,7 +169,7 @@ func NewRemoteZanzanaClient(cfg ZanzanaClientConfig, reg prometheus.Registerer) return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err) } - client, err := zClient.New(conn) + client, err := zClient.New(conn, reg) if err != nil { return nil, fmt.Errorf("failed to initialize zanzana client: %w", err) } diff --git a/pkg/services/authz/zanzana/client/client.go b/pkg/services/authz/zanzana/client/client.go index 3c51d707561..ac19d9b3fdc 100644 --- a/pkg/services/authz/zanzana/client/client.go +++ b/pkg/services/authz/zanzana/client/client.go @@ -9,6 +9,7 @@ import ( authzlib "github.com/grafana/authlib/authz" authzv1 "github.com/grafana/authlib/authz/proto/v1" authlib "github.com/grafana/authlib/types" + "github.com/prometheus/client_golang/prometheus" "github.com/grafana/grafana/pkg/infra/log" authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" @@ -25,15 +26,17 @@ type Client struct { authz authzv1.AuthzServiceClient authzext authzextv1.AuthzExtentionServiceClient authzlibclient *authzlib.ClientImpl + metrics *clientMetrics } -func New(cc grpc.ClientConnInterface) (*Client, error) { +func New(cc grpc.ClientConnInterface, reg prometheus.Registerer) (*Client, error) { authzlibclient := authzlib.NewClient(cc, authzlib.WithTracerClientOption(tracer)) c := &Client{ authzlibclient: authzlibclient, authz: authzv1.NewAuthzServiceClient(cc), authzext: authzextv1.NewAuthzExtentionServiceClient(cc), logger: log.New("zanzana.client"), + metrics: newClientMetrics(reg), } return c, nil @@ -43,6 +46,9 @@ func (c *Client) Check(ctx context.Context, id authlib.AuthInfo, req authlib.Che ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Check", req.Namespace)) + defer timer.ObserveDuration() + return c.authzlibclient.Check(ctx, id, req, folder) } @@ -50,6 +56,9 @@ func (c *Client) Compile(ctx context.Context, id authlib.AuthInfo, req authlib.L ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Compile") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Compile", req.Namespace)) + defer timer.ObserveDuration() + return c.authzlibclient.Compile(ctx, id, req) } @@ -64,6 +73,9 @@ func (c *Client) Write(ctx context.Context, req *authzextv1.WriteRequest) error ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Write") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Write", req.Namespace)) + defer timer.ObserveDuration() + _, err := c.authzext.Write(ctx, req) return err } @@ -72,6 +84,9 @@ func (c *Client) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckReque ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Check") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("BatchCheck", req.Namespace)) + defer timer.ObserveDuration() + return c.authzext.BatchCheck(ctx, req) } @@ -87,6 +102,9 @@ func (c *Client) Mutate(ctx context.Context, req *authzextv1.MutateRequest) erro ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Mutate") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Mutate", req.Namespace)) + defer timer.ObserveDuration() + _, err := c.authzext.Mutate(ctx, req) return err } @@ -95,5 +113,8 @@ func (c *Client) Query(ctx context.Context, req *authzextv1.QueryRequest) (*auth ctx, span := tracer.Start(ctx, "authlib.zanzana.client.Query") defer span.End() + timer := prometheus.NewTimer(c.metrics.requestDurationSeconds.WithLabelValues("Query", req.Namespace)) + defer timer.ObserveDuration() + return c.authzext.Query(ctx, req) } diff --git a/pkg/services/authz/zanzana/client/metrics.go b/pkg/services/authz/zanzana/client/metrics.go index 3fe7b1dd590..070f1cecf2c 100644 --- a/pkg/services/authz/zanzana/client/metrics.go +++ b/pkg/services/authz/zanzana/client/metrics.go @@ -7,10 +7,10 @@ import ( const ( metricsNamespace = "iam" - metricsSubSystem = "authz_zanzana" + metricsSubSystem = "authz_zanzana_client" ) -type metrics struct { +type shadowClientMetrics struct { // evaluationsSeconds is a summary for evaluating access for a specific engine (RBAC and zanzana) evaluationsSeconds *prometheus.HistogramVec // compileSeconds is a summary for compiling item checker for a specific engine (RBAC and zanzana) @@ -19,8 +19,13 @@ type metrics struct { evaluationStatusTotal *prometheus.CounterVec } -func newShadowClientMetrics(reg prometheus.Registerer) *metrics { - return &metrics{ +type clientMetrics struct { + // requestDurationSeconds is a summary for zanzana client request duration + requestDurationSeconds *prometheus.HistogramVec +} + +func newShadowClientMetrics(reg prometheus.Registerer) *shadowClientMetrics { + return &shadowClientMetrics{ evaluationsSeconds: promauto.With(reg).NewHistogramVec( prometheus.HistogramOpts{ Name: "engine_evaluations_seconds", @@ -52,3 +57,18 @@ func newShadowClientMetrics(reg prometheus.Registerer) *metrics { ), } } + +func newClientMetrics(reg prometheus.Registerer) *clientMetrics { + return &clientMetrics{ + requestDurationSeconds: promauto.With(reg).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "request_duration_seconds", + Help: "Histogram for zanzana client request duration", + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + Buckets: prometheus.ExponentialBuckets(0.00001, 4, 10), + }, + []string{"method", "request_namespace"}, + ), + } +} diff --git a/pkg/services/authz/zanzana/client/shadow_client.go b/pkg/services/authz/zanzana/client/shadow_client.go index 6f6e6dc6836..1e23c9b20f2 100644 --- a/pkg/services/authz/zanzana/client/shadow_client.go +++ b/pkg/services/authz/zanzana/client/shadow_client.go @@ -20,7 +20,7 @@ type ShadowClient struct { logger log.Logger accessClient authlib.AccessClient zanzanaClient authlib.AccessClient - metrics *metrics + metrics *shadowClientMetrics } // WithShadowClient returns a new access client that runs zanzana checks in the background. diff --git a/pkg/services/authz/zanzana/server/openfga_server.go b/pkg/services/authz/zanzana/server/openfga_server.go index 9baaafc017f..8c8d3b7a4cc 100644 --- a/pkg/services/authz/zanzana/server/openfga_server.go +++ b/pkg/services/authz/zanzana/server/openfga_server.go @@ -32,6 +32,8 @@ func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store storage.OpenFGADa opts := []server.OpenFGAServiceV1Option{ server.WithDatastore(store), server.WithLogger(zlogger.New(logger)), + + // Cache settings server.WithCheckCacheLimit(cfg.CacheSettings.CheckCacheLimit), server.WithCacheControllerEnabled(cfg.CacheSettings.CacheControllerEnabled), server.WithCacheControllerTTL(cfg.CacheSettings.CacheControllerTTL), @@ -40,16 +42,25 @@ func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store storage.OpenFGADa server.WithCheckIteratorCacheEnabled(cfg.CacheSettings.CheckIteratorCacheEnabled), server.WithCheckIteratorCacheMaxResults(cfg.CacheSettings.CheckIteratorCacheMaxResults), server.WithCheckIteratorCacheTTL(cfg.CacheSettings.CheckIteratorCacheTTL), + + // ListObjects settings server.WithListObjectsMaxResults(cfg.ListObjectsMaxResults), server.WithListObjectsIteratorCacheEnabled(cfg.CacheSettings.ListObjectsIteratorCacheEnabled), server.WithListObjectsIteratorCacheMaxResults(cfg.CacheSettings.ListObjectsIteratorCacheMaxResults), server.WithListObjectsIteratorCacheTTL(cfg.CacheSettings.ListObjectsIteratorCacheTTL), + server.WithListObjectsDeadline(cfg.ListObjectsDeadline), + + // Shared iterator settings server.WithSharedIteratorEnabled(cfg.CacheSettings.SharedIteratorEnabled), server.WithSharedIteratorLimit(cfg.CacheSettings.SharedIteratorLimit), server.WithSharedIteratorTTL(cfg.CacheSettings.SharedIteratorTTL), - server.WithListObjectsDeadline(cfg.ListObjectsDeadline), + + server.WithContextPropagationToDatastore(true), } + openfgaOpts := withOpenFGAOptions(cfg) + opts = append(opts, openfgaOpts...) + srv, err := server.NewServerWithOpts(opts...) if err != nil { return nil, err @@ -58,6 +69,129 @@ func NewOpenFGAServer(cfg setting.ZanzanaServerSettings, store storage.OpenFGADa return srv, nil } +func withOpenFGAOptions(cfg setting.ZanzanaServerSettings) []server.OpenFGAServiceV1Option { + opts := make([]server.OpenFGAServiceV1Option, 0) + + listOpts := withListOptions(cfg) + opts = append(opts, listOpts...) + + // Check settings + if cfg.OpenFgaServerSettings.MaxConcurrentReadsForCheck != 0 { + opts = append(opts, server.WithMaxConcurrentReadsForCheck(cfg.OpenFgaServerSettings.MaxConcurrentReadsForCheck)) + } + if cfg.OpenFgaServerSettings.CheckDatabaseThrottleThreshold != 0 || cfg.OpenFgaServerSettings.CheckDatabaseThrottleDuration != 0 { + opts = append(opts, server.WithCheckDatabaseThrottle(cfg.OpenFgaServerSettings.CheckDatabaseThrottleThreshold, cfg.OpenFgaServerSettings.CheckDatabaseThrottleDuration)) + } + + // Batch check settings + if cfg.OpenFgaServerSettings.MaxConcurrentChecksPerBatchCheck != 0 { + opts = append(opts, server.WithMaxConcurrentChecksPerBatchCheck(cfg.OpenFgaServerSettings.MaxConcurrentChecksPerBatchCheck)) + } + if cfg.OpenFgaServerSettings.MaxChecksPerBatchCheck != 0 { + opts = append(opts, server.WithMaxChecksPerBatchCheck(cfg.OpenFgaServerSettings.MaxChecksPerBatchCheck)) + } + + // Resolve node settings + if cfg.OpenFgaServerSettings.ResolveNodeLimit != 0 { + opts = append(opts, server.WithResolveNodeLimit(cfg.OpenFgaServerSettings.ResolveNodeLimit)) + } + if cfg.OpenFgaServerSettings.ResolveNodeBreadthLimit != 0 { + opts = append(opts, server.WithResolveNodeBreadthLimit(cfg.OpenFgaServerSettings.ResolveNodeBreadthLimit)) + } + + // Dispatch throttling settings + if cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverEnabled { + opts = append(opts, server.WithDispatchThrottlingCheckResolverEnabled(cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverEnabled)) + } + if cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverFrequency != 0 { + opts = append(opts, server.WithDispatchThrottlingCheckResolverFrequency(cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverFrequency)) + } + if cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverThreshold != 0 { + opts = append(opts, server.WithDispatchThrottlingCheckResolverThreshold(cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverThreshold)) + } + if cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverMaxThreshold != 0 { + opts = append(opts, server.WithDispatchThrottlingCheckResolverMaxThreshold(cfg.OpenFgaServerSettings.DispatchThrottlingCheckResolverMaxThreshold)) + } + + // Shadow check/query settings + if cfg.OpenFgaServerSettings.ShadowCheckResolverTimeout != 0 { + opts = append(opts, server.WithShadowCheckResolverTimeout(cfg.OpenFgaServerSettings.ShadowCheckResolverTimeout)) + } + if cfg.OpenFgaServerSettings.ShadowListObjectsQueryTimeout != 0 { + opts = append(opts, server.WithShadowListObjectsQueryTimeout(cfg.OpenFgaServerSettings.ShadowListObjectsQueryTimeout)) + } + if cfg.OpenFgaServerSettings.ShadowListObjectsQueryMaxDeltaItems != 0 { + opts = append(opts, server.WithShadowListObjectsQueryMaxDeltaItems(cfg.OpenFgaServerSettings.ShadowListObjectsQueryMaxDeltaItems)) + } + + if cfg.OpenFgaServerSettings.RequestTimeout != 0 { + opts = append(opts, server.WithRequestTimeout(cfg.OpenFgaServerSettings.RequestTimeout)) + } + if cfg.OpenFgaServerSettings.MaxAuthorizationModelSizeInBytes != 0 { + opts = append(opts, server.WithMaxAuthorizationModelSizeInBytes(cfg.OpenFgaServerSettings.MaxAuthorizationModelSizeInBytes)) + } + if cfg.OpenFgaServerSettings.AuthorizationModelCacheSize != 0 { + opts = append(opts, server.WithAuthorizationModelCacheSize(cfg.OpenFgaServerSettings.AuthorizationModelCacheSize)) + } + if cfg.OpenFgaServerSettings.ChangelogHorizonOffset != 0 { + opts = append(opts, server.WithChangelogHorizonOffset(cfg.OpenFgaServerSettings.ChangelogHorizonOffset)) + } + + return opts +} + +func withListOptions(cfg setting.ZanzanaServerSettings) []server.OpenFGAServiceV1Option { + opts := make([]server.OpenFGAServiceV1Option, 0) + + // ListObjects settings + if cfg.OpenFgaServerSettings.MaxConcurrentReadsForListObjects != 0 { + opts = append(opts, server.WithMaxConcurrentReadsForListObjects(cfg.OpenFgaServerSettings.MaxConcurrentReadsForListObjects)) + } + if cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingEnabled { + opts = append(opts, server.WithListObjectsDispatchThrottlingEnabled(cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingEnabled)) + } + if cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingFrequency != 0 { + opts = append(opts, server.WithListObjectsDispatchThrottlingFrequency(cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingFrequency)) + } + if cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingThreshold != 0 { + opts = append(opts, server.WithListObjectsDispatchThrottlingThreshold(cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingThreshold)) + } + if cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingMaxThreshold != 0 { + opts = append(opts, server.WithListObjectsDispatchThrottlingMaxThreshold(cfg.OpenFgaServerSettings.ListObjectsDispatchThrottlingMaxThreshold)) + } + if cfg.OpenFgaServerSettings.ListObjectsDatabaseThrottleThreshold != 0 || cfg.OpenFgaServerSettings.ListObjectsDatabaseThrottleDuration != 0 { + opts = append(opts, server.WithListObjectsDatabaseThrottle(cfg.OpenFgaServerSettings.ListObjectsDatabaseThrottleThreshold, cfg.OpenFgaServerSettings.ListObjectsDatabaseThrottleDuration)) + } + + // ListUsers settings + if cfg.OpenFgaServerSettings.ListUsersDeadline != 0 { + opts = append(opts, server.WithListUsersDeadline(cfg.OpenFgaServerSettings.ListUsersDeadline)) + } + if cfg.OpenFgaServerSettings.ListUsersMaxResults != 0 { + opts = append(opts, server.WithListUsersMaxResults(cfg.OpenFgaServerSettings.ListUsersMaxResults)) + } + if cfg.OpenFgaServerSettings.MaxConcurrentReadsForListUsers != 0 { + opts = append(opts, server.WithMaxConcurrentReadsForListUsers(cfg.OpenFgaServerSettings.MaxConcurrentReadsForListUsers)) + } + if cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingEnabled { + opts = append(opts, server.WithListUsersDispatchThrottlingEnabled(cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingEnabled)) + } + if cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingFrequency != 0 { + opts = append(opts, server.WithListUsersDispatchThrottlingFrequency(cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingFrequency)) + } + if cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingThreshold != 0 { + opts = append(opts, server.WithListUsersDispatchThrottlingThreshold(cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingThreshold)) + } + if cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingMaxThreshold != 0 { + opts = append(opts, server.WithListUsersDispatchThrottlingMaxThreshold(cfg.OpenFgaServerSettings.ListUsersDispatchThrottlingMaxThreshold)) + } + if cfg.OpenFgaServerSettings.ListUsersDatabaseThrottleThreshold != 0 || cfg.OpenFgaServerSettings.ListUsersDatabaseThrottleDuration != 0 { + opts = append(opts, server.WithListUsersDatabaseThrottle(cfg.OpenFgaServerSettings.ListUsersDatabaseThrottleThreshold, cfg.OpenFgaServerSettings.ListUsersDatabaseThrottleDuration)) + } + + return opts +} + func NewOpenFGAHttpServer(cfg setting.ZanzanaServerSettings, srv grpcserver.Provider) (*http.Server, error) { dialOpts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index ea37867d8c0..43fb5ce9a53 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -542,6 +542,9 @@ func (d *dashboardStore) saveDashboard(ctx context.Context, sess *db.Session, cm tags := dash.GetTags() if len(tags) > 0 { for _, tag := range tags { + if len(tag) > 50 { + return nil, dashboards.ErrDashboardTagTooLong + } if _, err := sess.Insert(dashboardTag{DashboardId: dash.ID, Term: tag, OrgID: dash.OrgID, DashboardUID: dash.UID}); err != nil { return nil, err } diff --git a/pkg/services/dashboards/errors.go b/pkg/services/dashboards/errors.go index e39b1ed4b21..b41d2d0bb23 100644 --- a/pkg/services/dashboards/errors.go +++ b/pkg/services/dashboards/errors.go @@ -79,6 +79,11 @@ var ( Reason: "message too long, max 500 characters", StatusCode: 400, } + ErrDashboardTagTooLong = dashboardaccess.DashboardErr{ + Reason: "dashboard tag too long, max 50 characters", + StatusCode: 400, + Status: "tag-too-long", + } ErrDashboardCannotSaveProvisionedDashboard = dashboardaccess.DashboardErr{ Reason: "Cannot save provisioned dashboard", StatusCode: 400, diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index 84a3e14141c..3d9c451553a 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -51,6 +51,7 @@ type Service struct { pluginStore pluginstore.Store pluginClient plugins.Client basePluginContextProvider plugincontext.BasePluginContextProvider + retriever DataSourceRetriever ptc proxyTransportCache } @@ -70,6 +71,7 @@ func ProvideService( features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService, quotaService quota.Service, pluginStore pluginstore.Store, pluginClient plugins.Client, basePluginContextProvider plugincontext.BasePluginContextProvider, + retriever DataSourceRetriever, ) (*Service, error) { dslogger := log.New("datasources") store := &SqlStore{db: db, logger: dslogger, features: features} @@ -89,6 +91,7 @@ func ProvideService( pluginStore: pluginStore, pluginClient: pluginClient, basePluginContextProvider: basePluginContextProvider, + retriever: retriever, } ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store)) @@ -175,11 +178,11 @@ func NewIDScopeResolver(db DataSourceRetriever) (string, accesscontrol.ScopeAttr } func (s *Service) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) { - return s.SQLStore.GetDataSource(ctx, query) + return s.retriever.GetDataSource(ctx, query) } func (s *Service) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) { - return s.SQLStore.GetDataSourceInNamespace(ctx, namespace, name, group) + return s.retriever.GetDataSourceInNamespace(ctx, namespace, name, group) } func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) { diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index 1aeca6af087..797fe6d049c 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -832,8 +832,9 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) { quotaService := quotatest.New(false, nil) permissionSvc := acmock.NewMockedPermissionsService() permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() - - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) cmd := &datasources.DeleteDataSourceCommand{ @@ -857,7 +858,9 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) { permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() cfg := &setting.Cfg{} enableRBACManagedPermissions(t, cfg) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) // First add the datasource @@ -1124,7 +1127,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider) @@ -1161,7 +1166,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1212,7 +1219,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1260,7 +1269,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1316,7 +1327,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1351,7 +1364,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1420,7 +1435,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1499,7 +1516,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) ds := datasources.DataSource{ @@ -1522,7 +1541,9 @@ func TestIntegrationService_getProxySettings(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) t.Run("Should default to disabled", func(t *testing.T) { @@ -1620,7 +1641,9 @@ func TestIntegrationService_getTimeout(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) for _, tc := range testCases { @@ -1645,7 +1668,9 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) jsonData := map[string]string{ @@ -1673,7 +1698,9 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) jsonData := map[string]string{ @@ -1699,7 +1726,9 @@ func TestIntegrationDataSource_CustomHeaders(t *testing.T) { secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) quotaService := quotatest.New(false, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever) require.NoError(t, err) dsService.cfg = setting.NewCfg() @@ -1788,7 +1817,9 @@ func initDSService(t *testing.T) *Service { quotaService := quotatest.New(false, nil) mockPermission := acmock.NewMockedPermissionsService() mockPermission.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{ + features := featuremgmt.WithFeatures() + dsRetriever := ProvideDataSourceRetriever(sqlStore, features) + dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{{ JSONData: plugins.JSONData{ ID: "test", @@ -1808,7 +1839,7 @@ func initDSService(t *testing.T) *Service { ObjectBytes: req.ObjectBytes, }, nil }, - }, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + }, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) return dsService diff --git a/pkg/services/datasources/service/datasourceretriever.go b/pkg/services/datasources/service/datasourceretriever.go new file mode 100644 index 00000000000..8448b49b8f5 --- /dev/null +++ b/pkg/services/datasources/service/datasourceretriever.go @@ -0,0 +1,34 @@ +package service + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +// DataSourceRetrieverImpl implements DataSourceRetriever by delegating to a Store. +type DataSourceRetrieverImpl struct { + store Store +} + +var _ DataSourceRetriever = (*DataSourceRetrieverImpl)(nil) + +// ProvideDataSourceRetriever creates a DataSourceRetriever for wire injection. +func ProvideDataSourceRetriever(db db.DB, features featuremgmt.FeatureToggles) DataSourceRetriever { + dslogger := log.New("datasources-retriever") + store := &SqlStore{db: db, logger: dslogger, features: features} + return &DataSourceRetrieverImpl{store: store} +} + +// GetDataSource gets a datasource. +func (r *DataSourceRetrieverImpl) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) { + return r.store.GetDataSource(ctx, query) +} + +// GetDataSourceInNamespace gets a datasource by namespace, name (datasource uid), and group (datasource type). +func (r *DataSourceRetrieverImpl) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) { + return r.store.GetDataSourceInNamespace(ctx, namespace, name, group) +} diff --git a/pkg/services/featuremgmt/goff_provider.go b/pkg/services/featuremgmt/features_service_provider.go similarity index 80% rename from pkg/services/featuremgmt/goff_provider.go rename to pkg/services/featuremgmt/features_service_provider.go index 9aafe320145..2e4f682b3bc 100644 --- a/pkg/services/featuremgmt/goff_provider.go +++ b/pkg/services/featuremgmt/features_service_provider.go @@ -7,7 +7,7 @@ import ( "github.com/open-feature/go-sdk/openfeature" ) -func newGOFFProvider(url string, client *http.Client) (openfeature.FeatureProvider, error) { +func newFeaturesServiceProvider(url string, client *http.Client) (openfeature.FeatureProvider, error) { options := gofeatureflag.ProviderOptions{ Endpoint: url, // consider using github.com/grafana/grafana/pkg/infra/httpclient/provider.go diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index d59dff63c37..72f9d5ffc5b 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -133,7 +133,11 @@ type FeatureFlag struct { Stage FeatureFlagStage `json:"stage,omitempty"` Owner codeowner `json:"-"` // Owner person or team that owns this feature flag - // CEL-GO expression. Using the value "true" will mean this is on by default + // Expression defined by the feature_toggles configuration. + // Supports multiple types including boolean, string, integer, float, + // and structured values following the OpenFeature specification. + // Using the value "true" means the feature flag is enabled by default, + // Using the value "1.0" means the default value of the feature flag is 1.0 Expression string `json:"expression,omitempty"` // Special behavior properties diff --git a/pkg/services/featuremgmt/ofrep_provider.go b/pkg/services/featuremgmt/ofrep_provider.go new file mode 100644 index 00000000000..a5197603db4 --- /dev/null +++ b/pkg/services/featuremgmt/ofrep_provider.go @@ -0,0 +1,17 @@ +package featuremgmt + +import ( + "net/http" + + ofrep "github.com/open-feature/go-sdk-contrib/providers/ofrep" + "github.com/open-feature/go-sdk/openfeature" +) + +func newOFREPProvider(url string, client *http.Client) (openfeature.FeatureProvider, error) { + options := []ofrep.Option{} + if client != nil { + options = append(options, ofrep.WithClient(client)) + } + + return ofrep.NewProvider(url, options...), nil +} diff --git a/pkg/services/featuremgmt/openfeature.go b/pkg/services/featuremgmt/openfeature.go index 234739ee59f..22017b8de03 100644 --- a/pkg/services/featuremgmt/openfeature.go +++ b/pkg/services/featuremgmt/openfeature.go @@ -8,6 +8,7 @@ import ( clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware" "github.com/grafana/grafana/pkg/setting" + "github.com/open-feature/go-sdk/openfeature/memprovider" sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" "github.com/open-feature/go-sdk/openfeature" @@ -19,14 +20,14 @@ const ( // OpenFeatureConfig holds configuration for initializing OpenFeature type OpenFeatureConfig struct { - // ProviderType is either "static" or "goff" + // ProviderType is either "static", "features-service", or "ofrep" ProviderType string - // URL is the GOFF service URL (required for GOFF provider) + // URL is the remote provider's URL (required for features-service + OFREP providers) URL *url.URL - // HTTPClient is a pre-configured HTTP client (optional, used for GOFF provider) + // HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers) HTTPClient *http.Client // StaticFlags are the feature flags to use with static provider - StaticFlags map[string]bool + StaticFlags map[string]memprovider.InMemoryFlag // TargetingKey is used for evaluation context TargetingKey string // ContextAttrs are additional attributes for evaluation context @@ -35,9 +36,9 @@ type OpenFeatureConfig struct { // InitOpenFeature initializes OpenFeature with the provided configuration func InitOpenFeature(config OpenFeatureConfig) error { - // For GOFF provider, ensure we have a URL - if config.ProviderType == setting.GOFFProviderType && (config.URL == nil || config.URL.String() == "") { - return fmt.Errorf("URL is required for GOFF provider") + // For remote providers, ensure we have a URL + if (config.ProviderType == setting.FeaturesServiceProviderType || config.ProviderType == setting.OFREPProviderType) && (config.URL == nil || config.URL.String() == "") { + return fmt.Errorf("URL is required for remote providers") } p, err := createProvider(config.ProviderType, config.URL, config.StaticFlags, config.HTTPClient) @@ -66,13 +67,17 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error { } var httpcli *http.Client - if cfg.OpenFeature.ProviderType == setting.GOFFProviderType { - m, err := clientauthmiddleware.NewTokenExchangeMiddleware(cfg) - if err != nil { - return fmt.Errorf("failed to create token exchange middleware: %w", err) + if cfg.OpenFeature.ProviderType == setting.FeaturesServiceProviderType || cfg.OpenFeature.ProviderType == setting.OFREPProviderType { + var m *clientauthmiddleware.TokenExchangeMiddleware + + if cfg.OpenFeature.ProviderType == setting.FeaturesServiceProviderType { + m, err = clientauthmiddleware.NewTokenExchangeMiddleware(cfg) + if err != nil { + return fmt.Errorf("failed to create token exchange middleware: %w", err) + } } - httpcli, err = goffHTTPClient(m) + httpcli, err = createHTTPClient(m) if err != nil { return err } @@ -96,31 +101,38 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error { func createProvider( providerType string, u *url.URL, - staticFlags map[string]bool, + staticFlags map[string]memprovider.InMemoryFlag, httpClient *http.Client, ) (openfeature.FeatureProvider, error) { - if providerType != setting.GOFFProviderType { - return newStaticProvider(staticFlags) + if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType { + if u == nil || u.String() == "" { + return nil, fmt.Errorf("feature provider url is required for FeaturesServiceProviderType + OFREPProviderType") + } + + if providerType == setting.FeaturesServiceProviderType { + return newFeaturesServiceProvider(u.String(), httpClient) + } + + if providerType == setting.OFREPProviderType { + return newOFREPProvider(u.String(), httpClient) + } } - if u == nil || u.String() == "" { - return nil, fmt.Errorf("feature provider url is required for GOFFProviderType") - } - - return newGOFFProvider(u.String(), httpClient) + return newStaticProvider(staticFlags, standardFeatureFlags) } -func goffHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) { - httpcli, err := sdkhttpclient.NewProvider().New(sdkhttpclient.Options{ +func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) { + options := sdkhttpclient.Options{ TLS: &sdkhttpclient.TLSOptions{InsecureSkipVerify: true}, Timeouts: &sdkhttpclient.TimeoutOptions{ Timeout: 10 * time.Second, }, - Middlewares: []sdkhttpclient.Middleware{ - m.New([]string{featuresProviderAudience}), - }, - }) + } + if m != nil { + options.Middlewares = append(options.Middlewares, m.New([]string{featuresProviderAudience})) + } + httpcli, err := sdkhttpclient.NewProvider().New(options) if err != nil { return nil, fmt.Errorf("failed to create http client for openfeature: %w", err) } diff --git a/pkg/services/featuremgmt/openfeature_test.go b/pkg/services/featuremgmt/openfeature_test.go index 788b53531d4..c1ef9a91bed 100644 --- a/pkg/services/featuremgmt/openfeature_test.go +++ b/pkg/services/featuremgmt/openfeature_test.go @@ -7,6 +7,7 @@ import ( "testing" gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" + ofrep "github.com/open-feature/go-sdk-contrib/providers/ofrep" "github.com/open-feature/go-sdk/openfeature" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -34,9 +35,9 @@ func TestCreateProvider(t *testing.T) { expectedProvider: setting.StaticProviderType, }, { - name: "goff provider", + name: "features-service provider", cfg: setting.OpenFeatureSettings{ - ProviderType: setting.GOFFProviderType, + ProviderType: setting.FeaturesServiceProviderType, URL: u, TargetingKey: "grafana", }, @@ -44,12 +45,12 @@ func TestCreateProvider(t *testing.T) { Namespace: "*", Audiences: []string{"features.grafana.app"}, }, - expectedProvider: setting.GOFFProviderType, + expectedProvider: setting.FeaturesServiceProviderType, }, { - name: "goff provider with failing token exchange", + name: "features-service provider with failing token exchange", cfg: setting.OpenFeatureSettings{ - ProviderType: setting.GOFFProviderType, + ProviderType: setting.FeaturesServiceProviderType, URL: u, TargetingKey: "grafana", }, @@ -57,9 +58,18 @@ func TestCreateProvider(t *testing.T) { Namespace: "*", Audiences: []string{"features.grafana.app"}, }, - expectedProvider: setting.GOFFProviderType, + expectedProvider: setting.FeaturesServiceProviderType, failSigning: true, }, + { + name: "ofrep provider", + cfg: setting.OpenFeatureSettings{ + ProviderType: setting.OFREPProviderType, + URL: u, + TargetingKey: "grafana", + }, + expectedProvider: setting.OFREPProviderType, + }, { name: "invalid provider", cfg: setting.OpenFeatureSettings{ @@ -96,20 +106,24 @@ func TestCreateProvider(t *testing.T) { } tokenExchangeMiddleware := middleware.TestingTokenExchangeMiddleware(tokenExchangeClient) - goffClient, err := goffHTTPClient(tokenExchangeMiddleware) - require.NoError(t, err, "failed to create goff http client") - provider, err := createProvider(tc.cfg.ProviderType, tc.cfg.URL, nil, goffClient) + httpClient, err := createHTTPClient(tokenExchangeMiddleware) + require.NoError(t, err, "failed to create features-service http client") + provider, err := createProvider(tc.cfg.ProviderType, tc.cfg.URL, nil, httpClient) require.NoError(t, err) err = openfeature.SetProviderAndWait(provider) require.NoError(t, err, "failed to set provider") - if tc.expectedProvider == setting.GOFFProviderType { + switch tc.expectedProvider { + case setting.FeaturesServiceProviderType: _, ok := provider.(*gofeatureflag.Provider) assert.True(t, ok, "expected provider to be of type goff.Provider") testGoFFProvider(t, tc.failSigning) - } else { + case setting.OFREPProviderType: + _, ok := provider.(*ofrep.Provider) + assert.True(t, ok, "expected provider to be of type ofrep.Provider") + default: _, ok := provider.(*inMemoryBulkProvider) assert.True(t, ok, "expected provider to be of type memprovider.InMemoryProvider") } @@ -127,10 +141,10 @@ func testGoFFProvider(t *testing.T, failSigning bool) { _, err := openfeature.NewDefaultClient().BooleanValueDetails(ctx, "test", false, openfeature.NewEvaluationContext("test", map[string]interface{}{"test": "test"})) // Error related to the token exchange should be returned if signing fails - // otherwise, it should return a connection refused error since the goff URL is not set + // otherwise, it should return a connection refused error since the features-service URL is not set if failSigning { assert.ErrorContains(t, err, "failed to exchange token: error signing token", "should return an error when signing fails") } else { - assert.ErrorContains(t, err, "connect: connection refused", "should return an error when goff url is not set") + assert.ErrorContains(t, err, "connect: connection refused", "should return an error when features-service url is not set") } } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 2933551d1ad..c9f2ca03184 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -322,6 +322,13 @@ var ( Owner: grafanaOperatorExperienceSquad, RequiresRestart: true, }, + { + Name: "reportingCsvEncodingOptions", + Description: "Enables CSV encoding options in the reporting feature", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaOperatorExperienceSquad, + }, { Name: "sseGroupByDatasource", Description: "Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch.", @@ -650,13 +657,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaDatavizSquad, }, - { - Name: "kubernetesFeatureToggles", - Description: "Use the kubernetes API for feature toggle management in the frontend", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaOperatorExperienceSquad, - }, { Name: "cloudRBACRoles", Description: "Enabled grafana cloud specific RBAC roles", @@ -872,13 +872,6 @@ var ( Owner: grafanaSharingSquad, FrontendOnly: false, }, - { - Name: "logsExploreTableDefaultVisualization", - Description: "Sets the logs table as default visualisation in logs explore", - Stage: FeatureStageExperimental, - Owner: grafanaObservabilityLogsSquad, - FrontendOnly: true, - }, { Name: "alertingListViewV2", Description: "Enables the new alert list view design", @@ -1038,13 +1031,6 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, }, - { - Name: "exploreLogsLimitedTimeRange", - Description: "Used in Logs Drilldown to limit the time range", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaObservabilityLogsSquad, - }, { Name: "appPlatformGrpcClientAuth", Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens", @@ -1087,13 +1073,6 @@ var ( Stage: FeatureStageExperimental, Owner: identityAccessTeam, }, - { - Name: "unifiedStorageSearch", - Description: "Enable unified storage search", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromDocs: true, - }, { Name: "unifiedStorageSearchSprinkles", Description: "Enable sprinkles on unified storage search", @@ -1162,14 +1141,6 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, - { - Name: "exploreMetricsRelatedLogs", - Description: "Display Related Logs in Grafana Metrics Drilldown", - Stage: FeatureStageExperimental, - Owner: grafanaObservabilityMetricsSquad, - FrontendOnly: true, - HideFromDocs: false, - }, { Name: "prometheusSpecialCharsInLabelValues", Description: "Adds support for quotes and special characters in label values for Prometheus queries", @@ -1290,13 +1261,6 @@ var ( Owner: grafanaPartnerPluginsSquad, Expression: "false", }, - { - Name: "unifiedHistory", - Description: "Displays the navigation history so the user can navigate back to previous pages", - Stage: FeatureStageExperimental, - Owner: grafanaFrontendSearchNavOrganise, - FrontendOnly: true, - }, { // Remove this flag once Loki v4 is released and the min supported version is v3.0+, // since users on v2.9 need it to disable the feature, as it doesn't work for them. @@ -1306,13 +1270,6 @@ var ( Owner: grafanaObservabilityLogsSquad, Expression: "true", }, - { - Name: "investigationsBackend", - Description: "Enable the investigations backend API", - Stage: FeatureStageExperimental, - Owner: grafanaAppPlatformSquad, - Expression: "false", - }, { Name: "k8SFolderCounts", Description: "Enable folder's api server counts", @@ -1599,8 +1556,8 @@ var ( }, { Name: "kubernetesAuthzApis", - Description: "Registers AuthZ /apis endpoint", - Stage: FeatureStageExperimental, + Description: "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead", + Stage: FeatureStageDeprecated, Owner: identityAccessTeam, HideFromDocs: true, }, @@ -1625,6 +1582,27 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, + { + Name: "kubernetesAuthzCoreRolesApi", + Description: "Registers AuthZ Core Roles /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, + { + Name: "kubernetesAuthzRolesApi", + Description: "Registers AuthZ Roles /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, + { + Name: "kubernetesAuthzRoleBindingsApi", + Description: "Registers AuthZ Role Bindings /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + }, { Name: "kubernetesAuthnMutation", Description: "Enables create, delete, and update mutations for resources owned by IAM identity", @@ -1873,14 +1851,6 @@ var ( Expression: "false", RequiresRestart: true, }, - { - Name: "tempoSearchBackendMigration", - Description: "Run search queries through the tempo backend", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOSSBigTent, - Expression: "false", - RequiresRestart: true, - }, { Name: "cdnPluginsLoadFirst", Description: "Prioritize loading plugins from the CDN before other sources", @@ -2082,6 +2052,21 @@ var ( FrontendOnly: true, Owner: grafanaDataProSquad, }, + { + Name: "secretsManagementAppPlatformAwsKeeper", + Description: "Enables the creation of keepers that manage secrets stored on AWS secrets manager", + Stage: FeatureStageExperimental, + HideFromDocs: true, + FrontendOnly: false, + Owner: grafanaOperatorExperienceSquad, + }, + { + Name: "profilesExemplars", + Description: "Enables profiles exemplars support in profiles drilldown", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityTracesAndProfilingSquad, + FrontendOnly: false, + }, } ) diff --git a/pkg/services/featuremgmt/service.go b/pkg/services/featuremgmt/service.go index 2769a75d788..2c97666d9e9 100644 --- a/pkg/services/featuremgmt/service.go +++ b/pkg/services/featuremgmt/service.go @@ -47,7 +47,8 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) { } mgmt.warnings[key] = "unknown flag in config" } - mgmt.startup[key] = val + + mgmt.startup[key] = val.Variants[val.DefaultVariant] == true } // update the values diff --git a/pkg/services/featuremgmt/static_evaluator.go b/pkg/services/featuremgmt/static_evaluator.go index c3d46837d28..fdeef7a5858 100644 --- a/pkg/services/featuremgmt/static_evaluator.go +++ b/pkg/services/featuremgmt/static_evaluator.go @@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) { return nil, fmt.Errorf("failed to read feature flags from config: %w", err) } - staticProvider, err := newStaticProvider(staticFlags) + staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags) if err != nil { return nil, fmt.Errorf("failed to create static provider: %w", err) } diff --git a/pkg/services/featuremgmt/static_provider.go b/pkg/services/featuremgmt/static_provider.go index f384bd00de1..f6fe14d7de9 100644 --- a/pkg/services/featuremgmt/static_provider.go +++ b/pkg/services/featuremgmt/static_provider.go @@ -1,8 +1,13 @@ package featuremgmt import ( + "fmt" + "maps" + "github.com/open-feature/go-sdk/openfeature" "github.com/open-feature/go-sdk/openfeature/memprovider" + + "github.com/grafana/grafana/pkg/setting" ) // inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that @@ -28,37 +33,21 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) { return keys, nil } -func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) { - flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags)) +func newStaticProvider(confFlags map[string]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) { + flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags)) + + // Parse and add standard flags + for _, flag := range standardFlags { + inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression) + if err != nil { + return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err) + } + + flags[flag.Name] = inMemFlag + } // Add flags from config.ini file - for name, value := range confFlags { - flags[name] = createInMemoryFlag(name, value) - } - - // Add standard flags - for _, flag := range standardFeatureFlags { - if _, exists := flags[flag.Name]; !exists { - enabled := flag.Expression == "true" - flags[flag.Name] = createInMemoryFlag(flag.Name, enabled) - } - } + maps.Copy(flags, confFlags) return newInMemoryBulkProvider(flags), nil } - -func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag { - variant := "disabled" - if enabled { - variant = "enabled" - } - - return memprovider.InMemoryFlag{ - Key: name, - DefaultVariant: variant, - Variants: map[string]interface{}{ - "enabled": true, - "disabled": false, - }, - } -} diff --git a/pkg/services/featuremgmt/static_provider_test.go b/pkg/services/featuremgmt/static_provider_test.go index 29610baa8c4..c3245f7dd97 100644 --- a/pkg/services/featuremgmt/static_provider_test.go +++ b/pkg/services/featuremgmt/static_provider_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/setting" + "github.com/open-feature/go-sdk/openfeature/memprovider" "github.com/open-feature/go-sdk/openfeature" "github.com/stretchr/testify/assert" @@ -93,3 +94,144 @@ ABCD = true enabledFeatureManager := mgr.GetEnabled(ctx) assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager) } + +func Test_StaticProvider_TypedFlags(t *testing.T) { + tests := []struct { + flags FeatureFlag + defaultValue any + expectedValue any + }{ + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "true", + }, + defaultValue: false, + expectedValue: true, + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "1.0", + }, + defaultValue: 0.0, + expectedValue: 1.0, + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "blue", + }, + defaultValue: "red", + expectedValue: "blue", + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: "1", + }, + defaultValue: int64(0), + expectedValue: int64(1), + }, + { + flags: FeatureFlag{ + Name: "Flag", + Expression: `{ "foo": "bar" }`, + }, + expectedValue: map[string]any{"foo": "bar"}, + }, + } + + for _, tt := range tests { + provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags}) + assert.NoError(t, err) + + var result any + switch tt.expectedValue.(type) { + case bool: + result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value + case float64: + result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value + case string: + result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value + case int64: + result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value + case map[string]any: + result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value + } + + assert.Equal(t, tt.expectedValue, result) + } +} +func Test_StaticProvider_ConfigOverride(t *testing.T) { + tests := []struct { + name string + originalValue string + configValue any + }{ + { + name: "bool", + originalValue: "false", + configValue: true, + }, + { + name: "int", + originalValue: "0", + configValue: int64(1), + }, + { + name: "float", + originalValue: "0.0", + configValue: 1.0, + }, + { + name: "string", + originalValue: "foo", + configValue: "bar", + }, + { + name: "structure", + originalValue: "{}", + configValue: make(map[string]any), + }, + } + + for _, tt := range tests { + configFlags, standardFlags := makeFlags(tt) + provider, err := newStaticProvider(configFlags, standardFlags) + assert.NoError(t, err) + + var result any + switch tt.configValue.(type) { + case bool: + result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value + case float64: + result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value + case string: + result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value + case int64: + result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value + case map[string]any: + result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value + } + + assert.Equal(t, tt.configValue, result) + } +} + +func makeFlags(tt struct { + name string + originalValue string + configValue any +}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) { + orig := FeatureFlag{ + Name: tt.name, + Expression: tt.originalValue, + } + + config := map[string]memprovider.InMemoryFlag{ + tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue), + } + + return config, []FeatureFlag{orig} +} diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index e2d15a8466b..7557323e43b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -43,6 +43,7 @@ configurableSchedulerTick,experimental,@grafana/alerting-squad,false,true,false dashgpt,GA,@grafana/dashboards-squad,false,false,true aiGeneratedDashboardChanges,experimental,@grafana/dashboards-squad,false,false,true reportingRetries,preview,@grafana/grafana-operator-experience-squad,false,true,false +reportingCsvEncodingOptions,experimental,@grafana/grafana-operator-experience-squad,false,false,false sseGroupByDatasource,experimental,@grafana/grafana-datasources-core-services,false,false,false lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false externalServiceAccounts,preview,@grafana/identity-access-team,false,false,false @@ -90,7 +91,6 @@ pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true timeComparison,experimental,@grafana/dataviz-squad,false,false,true tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true -kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false @@ -120,7 +120,6 @@ queryLibrary,preview,@grafana/sharing-squad,false,false,false dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false dashboardTemplates,preview,@grafana/sharing-squad,false,false,false -logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false @@ -143,14 +142,12 @@ vizActionsAuth,preview,@grafana/dataviz-squad,false,false,true alertingPrometheusRulesPrimary,experimental,@grafana/alerting-squad,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true -exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true appPlatformGrpcClientAuth,experimental,@grafana/identity-access-team,false,false,false groupAttributeSync,privatePreview,@grafana/identity-access-team,false,false,false alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true improvedExternalSessionHandling,GA,@grafana/identity-access-team,false,false,false useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false -unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false managedDualWriter,experimental,@grafana/search-and-storage,false,false,false pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false @@ -161,7 +158,6 @@ newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false -exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false enableSCIM,preview,@grafana/identity-access-team,false,false,false @@ -178,9 +174,7 @@ alertingAIAnalyzeCentralStateHistory,experimental,@grafana/alerting-squad,false, alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false -unifiedHistory,experimental,@grafana/grafana-search-navigate-organise,false,false,true lokiLabelNamesQueryApi,GA,@grafana/observability-logs,false,false,false -investigationsBackend,experimental,@grafana/grafana-app-platform-squad,false,false,false k8SFolderCounts,experimental,@grafana/search-and-storage,false,false,false k8SFolderMove,experimental,@grafana/search-and-storage,false,false,false improvedExternalSessionHandlingSAML,GA,@grafana/identity-access-team,false,false,false @@ -219,10 +213,13 @@ pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,fal alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false alertingBulkActionsInUI,GA,@grafana/alerting-squad,false,false,true -kubernetesAuthzApis,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzApis,deprecated,@grafana/identity-access-team,false,false,false kubernetesAuthZHandlerRedirect,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzResourcePermissionApis,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthzZanzanaSync,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzCoreRolesApi,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzRolesApi,experimental,@grafana/identity-access-team,false,false,false +kubernetesAuthzRoleBindingsApi,experimental,@grafana/identity-access-team,false,false,false kubernetesAuthnMutation,experimental,@grafana/identity-access-team,false,false,false kubernetesExternalGroupMapping,experimental,@grafana/identity-access-team,false,false,false restoreDashboards,experimental,@grafana/grafana-search-navigate-organise,false,false,false @@ -255,7 +252,6 @@ graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,fals azureResourcePickerUpdates,GA,@grafana/partner-datasources,false,false,true prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,false -tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false pluginInstallAPISync,experimental,@grafana/plugins-platform-backend,false,false,false @@ -282,3 +278,5 @@ kubernetesAlertingHistorian,experimental,@grafana/alerting-squad,false,true,fals useMTPlugins,experimental,@grafana/plugins-platform-backend,false,false,true multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true smoothingTransformation,experimental,@grafana/datapro,false,false,true +secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false +profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 5de71954e2f..97ecf868a64 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -135,6 +135,10 @@ const ( // Enables rendering retries for the reporting feature FlagReportingRetries = "reportingRetries" + // FlagReportingCsvEncodingOptions + // Enables CSV encoding options in the reporting feature + FlagReportingCsvEncodingOptions = "reportingCsvEncodingOptions" + // FlagSseGroupByDatasource // Send query to the same datasource in a single request when using server side expressions. The `cloudWatchBatchQueries` feature toggle should be enabled if this used with CloudWatch. FlagSseGroupByDatasource = "sseGroupByDatasource" @@ -451,10 +455,6 @@ const ( // Enables the new role picker drawer design FlagRolePickerDrawer = "rolePickerDrawer" - // FlagUnifiedStorageSearch - // Enable unified storage search - FlagUnifiedStorageSearch = "unifiedStorageSearch" - // FlagUnifiedStorageSearchSprinkles // Enable sprinkles on unified storage search FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles" @@ -539,10 +539,6 @@ const ( // Defaults to using the Loki `/labels` API instead of `/series` FlagLokiLabelNamesQueryApi = "lokiLabelNamesQueryApi" - // FlagInvestigationsBackend - // Enable the investigations backend API - FlagInvestigationsBackend = "investigationsBackend" - // FlagK8SFolderCounts // Enable folder's api server counts FlagK8SFolderCounts = "k8SFolderCounts" @@ -631,7 +627,7 @@ const ( FlagAlertRuleUseFiredAtForStartsAt = "alertRuleUseFiredAtForStartsAt" // FlagKubernetesAuthzApis - // Registers AuthZ /apis endpoint + // Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead FlagKubernetesAuthzApis = "kubernetesAuthzApis" // FlagKubernetesAuthZHandlerRedirect @@ -646,6 +642,18 @@ const ( // Enable sync of Zanzana authorization store on AuthZ CRD mutations FlagKubernetesAuthzZanzanaSync = "kubernetesAuthzZanzanaSync" + // FlagKubernetesAuthzCoreRolesApi + // Registers AuthZ Core Roles /apis endpoint + FlagKubernetesAuthzCoreRolesApi = "kubernetesAuthzCoreRolesApi" + + // FlagKubernetesAuthzRolesApi + // Registers AuthZ Roles /apis endpoint + FlagKubernetesAuthzRolesApi = "kubernetesAuthzRolesApi" + + // FlagKubernetesAuthzRoleBindingsApi + // Registers AuthZ Role Bindings /apis endpoint + FlagKubernetesAuthzRoleBindingsApi = "kubernetesAuthzRoleBindingsApi" + // FlagKubernetesAuthnMutation // Enables create, delete, and update mutations for resources owned by IAM identity FlagKubernetesAuthnMutation = "kubernetesAuthnMutation" @@ -730,10 +738,6 @@ const ( // Enables running plugins in containers FlagPluginContainers = "pluginContainers" - // FlagTempoSearchBackendMigration - // Run search queries through the tempo backend - FlagTempoSearchBackendMigration = "tempoSearchBackendMigration" - // FlagCdnPluginsLoadFirst // Prioritize loading plugins from the CDN before other sources FlagCdnPluginsLoadFirst = "cdnPluginsLoadFirst" @@ -781,4 +785,12 @@ const ( // FlagKubernetesAlertingHistorian // Adds support for Kubernetes alerting historian APIs FlagKubernetesAlertingHistorian = "kubernetesAlertingHistorian" + + // FlagSecretsManagementAppPlatformAwsKeeper + // Enables the creation of keepers that manage secrets stored on AWS secrets manager + FlagSecretsManagementAppPlatformAwsKeeper = "secretsManagementAppPlatformAwsKeeper" + + // FlagProfilesExemplars + // Enables profiles exemplars support in profiles drilldown + FlagProfilesExemplars = "profilesExemplars" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 66910dc9d1c..49cdbd86374 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1382,7 +1382,8 @@ "metadata": { "name": "exploreLogsLimitedTimeRange", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-08-29T13:55:59Z" + "creationTimestamp": "2024-08-29T13:55:59Z", + "deletionTimestamp": "2026-01-12T22:18:14Z" }, "spec": { "description": "Used in Logs Drilldown to limit the time range", @@ -1408,7 +1409,8 @@ "metadata": { "name": "exploreMetricsRelatedLogs", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-11-05T16:28:43Z" + "creationTimestamp": "2024-11-05T16:28:43Z", + "deletionTimestamp": "2026-01-09T22:14:53Z" }, "spec": { "description": "Display Related Logs in Grafana Metrics Drilldown", @@ -1793,7 +1795,8 @@ "metadata": { "name": "investigationsBackend", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-12-18T08:31:03Z" + "creationTimestamp": "2024-12-18T08:31:03Z", + "deletionTimestamp": "2025-12-16T16:06:24Z" }, "spec": { "description": "Enable the investigations backend API", @@ -1950,11 +1953,27 @@ { "metadata": { "name": "kubernetesAuthzApis", - "resourceVersion": "1764664939750", - "creationTimestamp": "2025-06-18T07:43:01Z" + "resourceVersion": "1767954559317", + "creationTimestamp": "2025-06-18T07:43:01Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-09 10:29:19.317164 +0000 UTC" + } }, "spec": { - "description": "Registers AuthZ /apis endpoint", + "description": "Deprecated: Use kubernetesAuthzCoreRolesApi, kubernetesAuthzRolesApi, and kubernetesAuthzRoleBindingsApi instead", + "stage": "deprecated", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesAuthzCoreRolesApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Core Roles /apis endpoint", "stage": "experimental", "codeowner": "@grafana/identity-access-team", "hideFromDocs": true @@ -1974,6 +1993,32 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "kubernetesAuthzRoleBindingsApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Role Bindings /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, + { + "metadata": { + "name": "kubernetesAuthzRolesApi", + "resourceVersion": "1767954459090", + "creationTimestamp": "2026-01-09T10:27:39Z" + }, + "spec": { + "description": "Registers AuthZ Roles /apis endpoint", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromDocs": true + } + }, { "metadata": { "name": "kubernetesAuthzZanzanaSync", @@ -2044,7 +2089,8 @@ "metadata": { "name": "kubernetesFeatureToggles", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-01-18T05:32:44Z" + "creationTimestamp": "2024-01-18T05:32:44Z", + "deletionTimestamp": "2026-01-07T12:02:51Z" }, "spec": { "description": "Use the kubernetes API for feature toggle management in the frontend", @@ -2202,7 +2248,8 @@ "metadata": { "name": "logsExploreTableDefaultVisualization", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-05-02T15:28:15Z" + "creationTimestamp": "2024-05-02T15:28:15Z", + "deletionTimestamp": "2026-01-12T14:11:46Z" }, "spec": { "description": "Sets the logs table as default visualisation in logs explore", @@ -2866,6 +2913,18 @@ "expression": "true" } }, + { + "metadata": { + "name": "profilesExemplars", + "resourceVersion": "1767777507980", + "creationTimestamp": "2026-01-07T09:18:27Z" + }, + "spec": { + "description": "Enables profiles exemplars support in profiles drilldown", + "stage": "experimental", + "codeowner": "@grafana/observability-traces-and-profiling" + } + }, { "metadata": { "name": "prometheusAzureOverrideAudience", @@ -3123,6 +3182,18 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "reportingCsvEncodingOptions", + "resourceVersion": "1766080709938", + "creationTimestamp": "2025-12-18T17:58:29Z" + }, + "spec": { + "description": "Enables CSV encoding options in the reporting feature", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad" + } + }, { "metadata": { "name": "reportingRetries", @@ -3254,6 +3325,22 @@ "codeowner": "@grafana/grafana-operator-experience-squad" } }, + { + "metadata": { + "name": "secretsManagementAppPlatformAwsKeeper", + "resourceVersion": "1767706420889", + "creationTimestamp": "2026-01-06T12:55:50Z", + "annotations": { + "grafana.app/updatedTimestamp": "2026-01-06 13:33:40.889447 +0000 UTC" + } + }, + "spec": { + "description": "Enables the creation of keepers that manage secrets stored on AWS secrets manager", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "hideFromDocs": true + } + }, { "metadata": { "name": "secretsManagementAppPlatformUI", @@ -3542,8 +3629,12 @@ { "metadata": { "name": "unifiedHistory", - "resourceVersion": "1764664939750", - "creationTimestamp": "2024-12-13T10:41:18Z" + "resourceVersion": "1762958248290", + "creationTimestamp": "2024-12-13T10:41:18Z", + "deletionTimestamp": "2025-11-13T16:25:53Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-12 14:37:28.29086 +0000 UTC" + } }, "spec": { "description": "Displays the navigation history so the user can navigate back to previous pages", @@ -3609,7 +3700,8 @@ "metadata": { "name": "unifiedStorageSearch", "resourceVersion": "1764664939750", - "creationTimestamp": "2024-09-30T19:46:14Z" + "creationTimestamp": "2024-09-30T19:46:14Z", + "deletionTimestamp": "2026-01-12T10:02:12Z" }, "spec": { "description": "Enable unified storage search", diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index 57e308de4ec..dbfa4af0c1c 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -190,9 +190,6 @@ func verifyFlagsConfiguration(t *testing.T) { if flag.Stage == FeatureStageGeneralAvailability && flag.Expression == "" { t.Errorf("GA features must be explicitly enabled or disabled, please add the `Expression` property for %s", flag.Name) } - if flag.Expression != "" && flag.Expression != "true" && flag.Expression != "false" { - t.Errorf("the `Expression` property for %s is incorrect. valid values are: `true`, `false` or empty string for default", flag.Name) - } // Check camel case names if flag.Name != strcase.ToLowerCamel(flag.Name) && !legacyNames[flag.Name] { invalidNames = append(invalidNames, flag.Name) diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go index df51717756e..5a00eca7401 100644 --- a/pkg/services/libraryelements/api.go +++ b/pkg/services/libraryelements/api.go @@ -501,9 +501,15 @@ type GetLibraryElementsParams struct { // required:false ExcludeUID string `json:"excludeUid"` // A comma separated list of folder ID(s) to filter the elements by. + // Deprecated: Use FolderFilterUIDs instead. // in:query // required:false + // deprecated:true FolderFilter string `json:"folderFilter"` + // A comma separated list of folder UID(s) to filter the elements by. + // in:query + // required:false + FolderFilterUIDs string `json:"folderFilterUIDs"` // The number of results per page. // in:query // required:false diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 5b9eb985bd8..a1ef1aab1ef 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -440,7 +440,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) { if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { alertChildNavs = append(alertChildNavs, &navtree.NavLink{ - Text: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true, + Text: "Alert activity", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true, }) } } diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index af37efc58d7..5e4bc821447 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -647,12 +647,6 @@ }, "BacktestConfig": { "properties": { - "annotations": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, "condition": { "type": "string" }, @@ -662,8 +656,16 @@ }, "type": "array" }, + "exec_err_state": { + "enum": [ + "OK", + "Alerting", + "Error" + ], + "type": "string" + }, "for": { - "$ref": "#/definitions/Duration" + "type": "string" }, "from": { "format": "date-time", @@ -672,12 +674,22 @@ "interval": { "$ref": "#/definitions/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "additionalProperties": { "type": "string" }, "type": "object" }, + "missing_series_evals_to_resolve": { + "format": "int64", + "type": "integer" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "enum": [ "Alerting", @@ -686,12 +698,18 @@ ], "type": "string" }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "format": "date-time", "type": "string" + }, + "uid": { + "type": "string" } }, "type": "object" @@ -1813,6 +1831,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -1823,6 +1847,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/GettableExtendedRuleNode" @@ -3142,6 +3172,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -3152,6 +3188,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/PostableExtendedRuleNode" @@ -3817,6 +3859,14 @@ }, "type": "object" }, + "RemoteWriteConfig": { + "properties": { + "url": { + "type": "string" + } + }, + "type": "object" + }, "ResponseDetails": { "properties": { "msg": { @@ -4093,6 +4143,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -4103,6 +4159,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/GettableExtendedRuleNode" diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 15cdb16d185..6b123aae306 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -284,11 +284,20 @@ type PostableRuleGroupConfig struct { // fields below are used by Mimir/Loki rulers - SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"` - EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"` - QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"` - AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"` - Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` + SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"` + EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"` + QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"` + AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"` + Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` + + // GEM Ruler. + + RWConfigs []RemoteWriteConfig `yaml:"remote_write,omitempty" json:"remote_write,omitempty"` +} + +type RemoteWriteConfig struct { + URL string `yaml:"url,omitempty" json:"url,omitempty"` } func (c *PostableRuleGroupConfig) UnmarshalJSON(b []byte) error { @@ -328,8 +337,8 @@ func (c *PostableRuleGroupConfig) validate() error { return fmt.Errorf("cannot mix Grafana & Prometheus style rules") } - if hasGrafRules && (len(c.SourceTenants) > 0 || c.EvaluationDelay != nil || c.QueryOffset != nil || c.AlignEvaluationTimeOnInterval || c.Limit > 0) { - return fmt.Errorf("fields source_tenants, evaluation_delay, query_offset, align_evaluation_time_on_interval and limit are not supported for Grafana rules") + if hasGrafRules && (len(c.SourceTenants) > 0 || c.EvaluationDelay != nil || c.QueryOffset != nil || c.AlignEvaluationTimeOnInterval || c.Limit > 0 || len(c.Labels) > 0 || len(c.RWConfigs) > 0) { + return fmt.Errorf("fields source_tenants, evaluation_delay, query_offset, align_evaluation_time_on_interval, limit, labels, and remote_write are not supported for Grafana rules") } return nil } @@ -345,11 +354,16 @@ type GettableRuleGroupConfig struct { // fields below are used by Mimir/Loki rulers - SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"` - EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"` - QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"` - AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"` - Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` + SourceTenants []string `yaml:"source_tenants,omitempty" json:"source_tenants,omitempty"` + EvaluationDelay *model.Duration `yaml:"evaluation_delay,omitempty" json:"evaluation_delay,omitempty"` + QueryOffset *model.Duration `yaml:"query_offset,omitempty" json:"query_offset,omitempty"` + AlignEvaluationTimeOnInterval bool `yaml:"align_evaluation_time_on_interval,omitempty" json:"align_evaluation_time_on_interval,omitempty"` + Limit int `yaml:"limit,omitempty" json:"limit,omitempty"` + Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` + + // GEM Ruler. + + RWConfigs []RemoteWriteConfig `yaml:"remote_write,omitempty" json:"remote_write,omitempty"` } func (c *GettableRuleGroupConfig) UnmarshalJSON(b []byte) error { diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 1243007dfcf..cd6ce130cf3 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -647,12 +647,6 @@ }, "BacktestConfig": { "properties": { - "annotations": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, "condition": { "type": "string" }, @@ -662,8 +656,16 @@ }, "type": "array" }, + "exec_err_state": { + "enum": [ + "OK", + "Alerting", + "Error" + ], + "type": "string" + }, "for": { - "$ref": "#/definitions/Duration" + "type": "string" }, "from": { "format": "date-time", @@ -672,12 +674,22 @@ "interval": { "$ref": "#/definitions/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "additionalProperties": { "type": "string" }, "type": "object" }, + "missing_series_evals_to_resolve": { + "format": "int64", + "type": "integer" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "enum": [ "Alerting", @@ -686,12 +698,18 @@ ], "type": "string" }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "format": "date-time", "type": "string" + }, + "uid": { + "type": "string" } }, "type": "object" @@ -1813,6 +1831,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -1823,6 +1847,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/GettableExtendedRuleNode" @@ -3142,6 +3172,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -3152,6 +3188,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/PostableExtendedRuleNode" @@ -3817,6 +3859,14 @@ }, "type": "object" }, + "RemoteWriteConfig": { + "properties": { + "url": { + "type": "string" + } + }, + "type": "object" + }, "ResponseDetails": { "properties": { "msg": { @@ -4093,6 +4143,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, "limit": { "format": "int64", "type": "integer" @@ -4103,6 +4159,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + }, + "type": "array" + }, "rules": { "items": { "$ref": "#/definitions/GettableExtendedRuleNode" diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 81e0aa894c9..b7331faea7c 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -5072,12 +5072,6 @@ "BacktestConfig": { "type": "object", "properties": { - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "condition": { "type": "string" }, @@ -5087,8 +5081,16 @@ "$ref": "#/definitions/AlertQuery" } }, + "exec_err_state": { + "type": "string", + "enum": [ + "OK", + "Alerting", + "Error" + ] + }, "for": { - "$ref": "#/definitions/Duration" + "type": "string" }, "from": { "type": "string", @@ -5097,12 +5099,22 @@ "interval": { "$ref": "#/definitions/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "type": "object", "additionalProperties": { "type": "string" } }, + "missing_series_evals_to_resolve": { + "type": "integer", + "format": "int64" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "type": "string", "enum": [ @@ -5111,12 +5123,18 @@ "OK" ] }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "type": "string", "format": "date-time" + }, + "uid": { + "type": "string" } } }, @@ -6239,6 +6257,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -6249,6 +6273,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -7569,6 +7599,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -7579,6 +7615,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -8243,6 +8285,14 @@ } } }, + "RemoteWriteConfig": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, "ResponseDetails": { "type": "object", "properties": { @@ -8520,6 +8570,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -8530,6 +8586,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index 1601ddc94e5..106203e4e33 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -16,13 +16,6 @@ import ( "github.com/grafana/alerting/receivers/schema" ) -// GetReceiverQuery represents a query for a single receiver. -type GetReceiverQuery struct { - OrgID int64 - Name string - Decrypt bool -} - // GetReceiversQuery represents a query for receiver groups. type GetReceiversQuery struct { OrgID int64 diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go index 246cd03d64d..04a651a174f 100644 --- a/pkg/services/ngalert/notifier/crypto.go +++ b/pkg/services/ngalert/notifier/crypto.go @@ -378,3 +378,29 @@ func EncryptedReceivers(receivers []*definitions.PostableApiReceiver, encryptFn } return encrypted, nil } + +// DecryptIntegrationSettings returns a function to decrypt integration settings. +func DecryptIntegrationSettings(ctx context.Context, ss secretService) models.DecryptFn { + return func(value string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + decrypted, err := ss.Decrypt(ctx, decoded) + if err != nil { + return "", err + } + return string(decrypted), nil + } +} + +// EncryptIntegrationSettings returns a function to encrypt integration settings. +func EncryptIntegrationSettings(ctx context.Context, ss secretService) models.EncryptFn { + return func(payload string) (string, error) { + encrypted, err := ss.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(encrypted), nil + } +} diff --git a/pkg/services/ngalert/notifier/errors.go b/pkg/services/ngalert/notifier/errors.go index 337dfa7cb3b..08f4c967049 100644 --- a/pkg/services/ngalert/notifier/errors.go +++ b/pkg/services/ngalert/notifier/errors.go @@ -1,9 +1,37 @@ package notifier -import "github.com/grafana/grafana/pkg/apimachinery/errutil" +import ( + "errors" + "slices" + + "github.com/grafana/alerting/receivers/schema" + + "github.com/grafana/grafana/pkg/apimachinery/errutil" +) // WithPublicError sets the public message of an errutil error to the error message. func WithPublicError(err errutil.Error) error { err.PublicMessage = err.Error() return err } + +// If provided error is errutil.Error it appends fields that caused the error to public payload +func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { + var authzErr errutil.Error + if !errors.As(err, &authzErr) { + return err + } + if authzErr.PublicPayload == nil { + authzErr.PublicPayload = map[string]interface{}{} + } + fields := make(map[string][]string, len(diff)) + for field, paths := range diff { + fields[field] = make([]string, len(paths)) + for i, path := range paths { + fields[field][i] = path.String() + } + slices.Sort(fields[field]) + } + authzErr.PublicPayload["changed_protected_fields"] = fields + return authzErr +} diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index e05b12a2920..dc3e9d4b8eb 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -2,7 +2,6 @@ package notifier import ( "context" - "encoding/base64" "errors" "fmt" "strings" @@ -133,30 +132,33 @@ func (rs *ReceiverService) loadProvenances(ctx context.Context, orgID int64) (ma return rs.provisioningStore.GetProvenances(ctx, orgID, (&models.Integration{}).ResourceType()) } -// GetReceiver returns a receiver by name. +// GetReceiver returns a receiver by its UID. // The receiver's secure settings are decrypted if requested and the user has access to do so. -func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) { +func (rs *ReceiverService) GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*models.Receiver, error) { + if user == nil { + return nil, errors.New("user is required") + } ctx, span := rs.tracer.Start(ctx, "alerting.receivers.get", trace.WithAttributes( - attribute.Int64("query_org_id", q.OrgID), - attribute.String("query_name", q.Name), - attribute.Bool("query_decrypt", q.Decrypt), + attribute.Int64("query_org_id", user.GetOrgID()), + attribute.String("query_uid", uid), + attribute.Bool("query_decrypt", decrypt), )) defer span.End() - revision, err := rs.cfgStore.Get(ctx, q.OrgID) + revision, err := rs.cfgStore.Get(ctx, user.GetOrgID()) if err != nil { return nil, err } - prov, err := rs.loadProvenances(ctx, q.OrgID) + prov, err := rs.loadProvenances(ctx, user.GetOrgID()) if err != nil { return nil, err } - rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov) + rcv, err := revision.GetReceiver(uid, prov) if err != nil { if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported { - imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision) + imported := rs.getImportedReceivers(ctx, span, []string{uid}, revision) if len(imported) > 0 { rcv = imported[0] } @@ -171,14 +173,14 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver )) auth := rs.authz.AuthorizeReadDecrypted - if !q.Decrypt { + if !decrypt { auth = rs.authz.AuthorizeRead } if err := auth(ctx, user, rcv); err != nil { return nil, err } - if q.Decrypt { + if decrypt { err := rcv.Decrypt(rs.decryptor(ctx)) if err != nil { rs.log.FromContext(ctx).Warn("Failed to decrypt secure settings", "name", rcv.Name, "error", err) @@ -684,28 +686,12 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i // decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used. func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn { - return func(value string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return "", err - } - decrypted, err := rs.encryptionService.Decrypt(ctx, decoded) - if err != nil { - return "", err - } - return string(decrypted), nil - } + return DecryptIntegrationSettings(ctx, rs.encryptionService) } // encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result. func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn { - return func(payload string) (string, error) { - s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(s), nil - } + return EncryptIntegrationSettings(ctx, rs.encryptionService) } // checkOptimisticConcurrency checks if the existing receiver's version matches the desired version. diff --git a/pkg/services/ngalert/notifier/receiver_svc_err.go b/pkg/services/ngalert/notifier/receiver_svc_err.go deleted file mode 100644 index 06b27602f75..00000000000 --- a/pkg/services/ngalert/notifier/receiver_svc_err.go +++ /dev/null @@ -1,30 +0,0 @@ -package notifier - -import ( - "errors" - "slices" - - "github.com/grafana/alerting/receivers/schema" - - "github.com/grafana/grafana/pkg/apimachinery/errutil" -) - -func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { - var authzErr errutil.Error - if !errors.As(err, &authzErr) { - return err - } - if authzErr.PublicPayload == nil { - authzErr.PublicPayload = map[string]interface{}{} - } - fields := make(map[string][]string, len(diff)) - for field, paths := range diff { - fields[field] = make([]string, len(paths)) - for i, path := range paths { - fields[field][i] = path.String() - } - slices.Sort(fields[field]) - } - authzErr.PublicPayload["changed_protected_fields"] = fields - return authzErr -} diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 10e84f33b62..08c66411f16 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -50,7 +50,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service gets receiver from AM config", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser) require.NoError(t, err) require.Equal(t, "slack receiver", recv.Name) require.Len(t, recv.Integrations, 1) @@ -60,7 +60,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("service returns error when receiver does not exist", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + _, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) }) @@ -68,7 +68,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("gets imported receivers", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, withImportedIncluded) - recv, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.NoError(t, err) assert.Equal(t, models.ResourceOriginImported, recv.Origin) assert.Equal(t, "receiver1", recv.Name) @@ -81,9 +81,9 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) { t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig) - _, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser) + _, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, redactedUser) require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) - _, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) + _, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser) require.NoError(t, err) }) }) @@ -187,12 +187,6 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { user: readUser, err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", }, - { - name: "service returns error if user is nil and decrypt is true", - decrypt: true, - user: nil, - err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", - }, { name: "service decrypts receivers with permission", decrypt: true, @@ -224,18 +218,16 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { }, } - for _, o := range origin { - for _, method := range getMethods { - t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + for _, method := range getMethods { + t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + for _, o := range origin { t.Run(fmt.Sprintf("%s %s (%s)", tc.name, method, o.origin), func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService, o.opts...) var res *models.Receiver var err error if method == "single" { - q := singleQ(1, o.receiver) - q.Decrypt = tc.decrypt - res, err = sut.GetReceiver(context.Background(), q, tc.user) + res, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(o.receiver), tc.decrypt, tc.user) } else { q := multiQ(1, o.receiver) q.Decrypt = tc.decrypt @@ -267,8 +259,8 @@ func TestIntegrationReceiverService_DecryptRedact(t *testing.T) { require.NotEqual(t, o.decryptedSettingValue, res.Integrations[0].SecureSettings[o.secureSettingKey]) } }) - }) - } + } + }) } } } @@ -412,8 +404,7 @@ func TestReceiverService_Delete(t *testing.T) { // Ensure receiver saved to store is correct. name, err := legacy_storage.UidToName(tc.deleteUID) require.NoError(t, err) - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: name} - _, err = sut.GetReceiver(context.Background(), q, writer) + _, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(name), false, writer) assert.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType()) @@ -626,8 +617,7 @@ func TestReceiverService_Create(t *testing.T) { assert.Equal(t, tc.expectedCreate, *created) // Ensure receiver saved to store is correct. - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} - stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser) require.NoError(t, err) decrypted := models.CopyReceiverWith(tc.expectedCreate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) decrypted.Version = tc.expectedCreate.Version // Version is calculated before decryption. @@ -931,8 +921,7 @@ func TestReceiverService_Update(t *testing.T) { assert.Equal(t, tc.expectedUpdate, *updated) // Ensure receiver saved to store is correct. - q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} - stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser) require.NoError(t, err) decrypted := models.CopyReceiverWith(tc.expectedUpdate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) decrypted.Version = tc.expectedUpdate.Version // Version is calculated before decryption. @@ -1054,7 +1043,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { sut.ruleNotificationsStore = ruleStore newReceiverName = "receiver1" - actual, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + actual, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer) require.NoError(t, err) require.Equal(t, models.ResourceOriginImported, actual.Origin) require.Equal(t, newReceiverName, actual.Name) @@ -1067,7 +1056,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) { require.NotEqual(t, actual, recv) require.Equal(t, models.ResourceOriginGrafana, recv.Origin) - actual, err = sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: writer.GetOrgID(), Name: newReceiverName}, writer) + actual, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(newReceiverName), false, writer) require.NoError(t, err) require.Equal(t, recv.Name, actual.Name) }) @@ -1185,7 +1174,7 @@ func TestReceiverServiceAC_Read(t *testing.T) { return false } for _, recv := range allReceivers() { - response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr) if isVisible(recv.UID) { require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) assert.NotNil(t, response) @@ -1207,7 +1196,7 @@ func TestReceiverServiceAC_Read(t *testing.T) { } sut.authz = ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true) for _, recv := range allReceivers() { - response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr) if isVisibleInProvisioning(recv.UID) { require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) assert.NotNil(t, response) @@ -1766,7 +1755,7 @@ func TestReceiverService_AccessControlMetadata(t *testing.T) { }, }} - r, err := sut.GetReceiver(context.Background(), models.GetReceiverQuery{OrgID: 1, Name: "receiver1"}, admin) + r, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), false, admin) require.NoError(t, err) t.Run("should override metadata for imported receivers", func(t *testing.T) { @@ -1842,13 +1831,6 @@ func createEncryptedConfig(t *testing.T, secretService secretService, extraConfi return string(bytes) } -func singleQ(orgID int64, name string) models.GetReceiverQuery { - return models.GetReceiverQuery{ - OrgID: orgID, - Name: name, - } -} - func multiQ(orgID int64, names ...string) models.GetReceiversQuery { return models.GetReceiversQuery{ OrgID: orgID, diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index a7c99f8518c..60740d935af 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -40,6 +40,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client" "github.com/grafana/grafana/pkg/services/ngalert/sender" + "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/util/cmputil" ) @@ -57,6 +58,7 @@ func NoopAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *apimodels.Postab } type Crypto interface { + Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error) Decrypt(ctx context.Context, payload []byte) ([]byte, error) DecryptExtraConfigs(ctx context.Context, config *apimodels.PostableUserConfig) error } @@ -289,20 +291,6 @@ func (am *Alertmanager) isDefaultConfiguration(configHash string) bool { return configHash == am.defaultConfigHash } -func decrypter(ctx context.Context, crypto Crypto) models.DecryptFn { - return func(value string) (string, error) { - decoded, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return "", err - } - decrypted, err := crypto.Decrypt(ctx, decoded) - if err != nil { - return "", err - } - return string(decrypted), nil - } -} - // buildConfiguration takes a raw Alertmanager configuration and returns a config that the remote Alertmanager can use. // It parses the initial configuration, adds auto-generated routes, decrypts receivers, and merges the extra configs. func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, createdAtEpoch int64, autogenInvalidReceiverAction notifier.InvalidReceiversAction) (remoteClient.UserGrafanaConfig, error) { @@ -317,7 +305,7 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea } // Decrypt the receivers in the configuration. - decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, decrypter(ctx, am.crypto)) + decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto)) if err != nil { return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err) } @@ -619,7 +607,7 @@ func (am *Alertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver, } func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) { - decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, decrypter(ctx, am.crypto)) + decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto)) if err != nil { return nil, 0, fmt.Errorf("failed to decrypt receivers: %w", err) } diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index d6f66756454..1a1d3943324 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -43,7 +43,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/remote/client" ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" - "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager" @@ -298,13 +297,7 @@ func TestIntegrationApplyConfig(t *testing.T) { var c apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &c)) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t))) - encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) c.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) @@ -462,13 +455,7 @@ func TestCompareAndSendConfiguration(t *testing.T) { // Create a config with correctly encrypted and encoded secrets. var inputCfg apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg)) - encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) testGrafanaConfigWithEncryptedSecret, err := json.Marshal(inputCfg) @@ -663,13 +650,7 @@ func Test_TestReceiversDecryptsSecureSettings(t *testing.T) { var inputCfg apimodels.PostableUserConfig require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg)) - encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) @@ -1037,13 +1018,7 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) { { postableCfg, err := notifier.Load([]byte(testGrafanaConfigWithSecret)) require.NoError(t, err) - encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) { - encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope()) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(encrypted), nil - }) + encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService)) postableCfg.AlertmanagerConfig.Receivers = encryptedReceivers require.NoError(t, err) diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go index 1e6eb2b04e6..694189ffbc7 100644 --- a/pkg/services/ngalert/store/alert_rule_labels_test.go +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -73,21 +73,21 @@ func TestBuildLabelMatcherJSON(t *testing.T) { name: "MySQL MatchEqual with non-empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", + wantSQL: `JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) = ?`, wantArgs: []any{"team", "alerting"}, }, { name: "MySQL MatchEqual with empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ? OR JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL)", + wantSQL: `(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) = ? OR JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NULL)`, wantArgs: []any{"team", "", "team"}, }, { name: "MySQL MatchNotEqual", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", + wantSQL: `(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) != ?)`, wantArgs: []any{"team", "team", "alerting"}, }, { @@ -149,7 +149,7 @@ func TestBuildLabelKeyExistsCondition(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "__grafana_origin", - wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NOT NULL", + wantSQL: `JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NOT NULL`, wantArgs: []any{"__grafana_origin"}, }, { @@ -194,7 +194,7 @@ func TestBuildLabelKeyMissingCondition(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "__grafana_origin", - wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", + wantSQL: `JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NULL`, wantArgs: []any{"__grafana_origin"}, }, { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index e38a7052dff..cef60939924 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2454,7 +2454,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { ruleGen.WithLabels(map[string]string{"glob": "*[?]"}), ruleGen.WithTitle("rule_glob"))) ruleSpecialChars := createRule(t, store, ruleGen.With( - ruleGen.WithLabels(map[string]string{"json": "line1\nline2\\end\"quote"}), + ruleGen.WithLabels(map[string]string{"label-with-hyphen": "line1\nline2\\end\"quote"}), ruleGen.WithTitle("rule_special_chars"))) ruleEmpty := createRule(t, store, ruleGen.With( ruleGen.WithLabels(map[string]string{"empty": ""}), @@ -2531,7 +2531,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { name: "JSON escape characters are handled correctly", labelMatchers: labels.Matchers{ func() *labels.Matcher { - m, _ := labels.NewMatcher(labels.MatchEqual, "json", "line1\nline2\\end\"quote") + m, _ := labels.NewMatcher(labels.MatchEqual, "label-with-hyphen", "line1\nline2\\end\"quote") return m }(), }, diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go index ac38d2f4ca5..88e118b50d0 100644 --- a/pkg/services/ngalert/store/json.go +++ b/pkg/services/ngalert/store/json.go @@ -13,7 +13,7 @@ import ( func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?))) = ?", column), []any{key, value} + return fmt.Sprintf(`JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$."', ?, '"'))) = ?`, column), []any{key, value} case migrator.Postgres: return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) = ?", column), []any{key, value} default: @@ -25,7 +25,7 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, var jx string switch dialect.DriverName() { case migrator.MySQL: - jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)))", column) + jx = fmt.Sprintf(`JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$."', ?, '"')))`, column) case migrator.Postgres: jx = fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?)", column) default: @@ -49,7 +49,7 @@ func jsonKeyCondition(dialect migrator.Dialect, column, key string, exists bool) } switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) %s", column, nullCheck), []any{key}, nil + return fmt.Sprintf(`JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$."', ?, '"')) %s`, column, nullCheck), []any{key}, nil case migrator.Postgres: return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) %s", column, nullCheck), []any{key}, nil default: diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go index 93ca1531f61..3a790f81a3a 100644 --- a/pkg/services/ngalert/store/json_test.go +++ b/pkg/services/ngalert/store/json_test.go @@ -23,7 +23,7 @@ func TestJsonEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", + wantSQL: `JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) = ?`, wantArgs: []any{"team", "alerting"}, }, { @@ -62,7 +62,7 @@ func TestJsonNotEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", + wantSQL: `(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"'))) != ?)`, wantArgs: []any{"team", "team", "alerting"}, }, { @@ -99,7 +99,7 @@ func TestJsonKeyMissing(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "team", - wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", + wantSQL: `JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NULL`, wantArgs: []any{"team"}, }, { @@ -136,7 +136,7 @@ func TestJsonKeyExists(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "__grafana_origin", - wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NOT NULL", + wantSQL: `JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$."', ?, '"')) IS NOT NULL`, wantArgs: []any{"__grafana_origin"}, }, { diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index ac0268e051c..fa11375d778 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -151,7 +151,7 @@ type OrgUserDTO struct { Role string `json:"role"` LastSeenAt time.Time `json:"lastSeenAt"` Updated time.Time `json:"-"` - Created time.Time `json:"-"` + Created time.Time `json:"created"` LastSeenAtAge string `json:"lastSeenAtAge"` AccessControl map[string]bool `json:"accessControl,omitempty"` IsDisabled bool `json:"isDisabled"` diff --git a/pkg/plugins/backendplugin/coreplugin/registry.go b/pkg/services/pluginsintegration/coreplugin/coreplugins.go similarity index 96% rename from pkg/plugins/backendplugin/coreplugin/registry.go rename to pkg/services/pluginsintegration/coreplugin/coreplugins.go index 47c59216646..4c4a8b851e2 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry.go +++ b/pkg/services/pluginsintegration/coreplugin/coreplugins.go @@ -14,6 +14,8 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" + "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" cloudmonitoring "github.com/grafana/grafana/pkg/tsdb/cloud-monitoring" @@ -92,6 +94,10 @@ func NewRegistry(store map[string]backendplugin.PluginFactoryFunc) *Registry { } } +func ProvideCoreProvider(coreRegistry *Registry) plugins.BackendFactoryProvider { + return provider.New(coreRegistry.BackendFactoryProvider(), provider.DefaultProvider) +} + func ProvideCoreRegistry(tracer trace.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service, es *elasticsearch.Service, grap *graphite.Service, idb *influxdb.Service, lk *loki.Service, otsdb *opentsdb.Service, pr *prometheus.Service, t *tempo.Service, td *testdatasource.Service, pg *postgres.Service, my *mysql.Service, @@ -156,7 +162,7 @@ func asBackendPlugin(svc any) backendplugin.PluginFactoryFunc { if opts.QueryDataHandler != nil || opts.CallResourceHandler != nil || opts.CheckHealthHandler != nil || opts.StreamHandler != nil { - return New(opts) + return coreplugin.New(opts) } return nil diff --git a/pkg/plugins/backendplugin/coreplugin/registry_test.go b/pkg/services/pluginsintegration/coreplugin/coreplugins_test.go similarity index 100% rename from pkg/plugins/backendplugin/coreplugin/registry_test.go rename to pkg/services/pluginsintegration/coreplugin/coreplugins_test.go diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go index f3f22b2c5c5..f377e9eb867 100644 --- a/pkg/services/pluginsintegration/pipeline/pipeline.go +++ b/pkg/services/pluginsintegration/pipeline/pipeline.go @@ -6,7 +6,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/auth" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/envvars" "github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angularinspector" @@ -19,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/provisionedplugins" ) diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go index fbfa3379afd..c0b3a2547cb 100644 --- a/pkg/services/pluginsintegration/pluginsintegration.go +++ b/pkg/services/pluginsintegration/pluginsintegration.go @@ -10,8 +10,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/auth" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" "github.com/grafana/grafana/pkg/plugins/envvars" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/client" @@ -39,6 +37,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/angularinspector" "github.com/grafana/grafana/pkg/services/pluginsintegration/angularpatternsstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/clientmiddleware" + "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/installsync" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever" "github.com/grafana/grafana/pkg/services/pluginsintegration/keyretriever/dynamic" @@ -55,6 +54,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings/service" + _ "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginslog" // Initialize plugin logger "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsso" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" @@ -145,8 +145,7 @@ var WireSet = wire.NewSet( // WireExtensionSet provides a wire.ProviderSet of plugin providers that can be // extended. var WireExtensionSet = wire.NewSet( - provider.ProvideService, - wire.Bind(new(plugins.BackendFactoryProvider), new(*provider.Service)), + coreplugin.ProvideCoreProvider, signature.ProvideOSSAuthorizer, wire.Bind(new(plugins.PluginLoaderAuthorizer), new(*signature.UnsignedPluginAuthorizer)), ProvideClientWithMiddlewares, diff --git a/pkg/services/pluginsintegration/pluginslog/pluginslog.go b/pkg/services/pluginsintegration/pluginslog/pluginslog.go new file mode 100644 index 00000000000..ddce31874f0 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginslog/pluginslog.go @@ -0,0 +1,59 @@ +package pluginslog + +import ( + "context" + + "github.com/grafana/grafana/pkg/infra/log" + pluginslog "github.com/grafana/grafana/pkg/plugins/log" +) + +func init() { + // Register Grafana's logger implementation for pkg/plugins + pluginslog.SetLoggerFactory(func(name string) pluginslog.Logger { + return &grafanaInfraLogWrapper{ + l: log.New(name), + } + }) +} + +type grafanaInfraLogWrapper struct { + l *log.ConcreteLogger +} + +func (d *grafanaInfraLogWrapper) New(ctx ...any) pluginslog.Logger { + if len(ctx) == 0 { + return &grafanaInfraLogWrapper{ + l: d.l.New(), + } + } + + return &grafanaInfraLogWrapper{ + l: d.l.New(ctx...), + } +} + +func (d *grafanaInfraLogWrapper) Debug(msg string, ctx ...any) { + d.l.Debug(msg, ctx...) +} + +func (d *grafanaInfraLogWrapper) Info(msg string, ctx ...any) { + d.l.Info(msg, ctx...) +} + +func (d *grafanaInfraLogWrapper) Warn(msg string, ctx ...any) { + d.l.Warn(msg, ctx...) +} + +func (d *grafanaInfraLogWrapper) Error(msg string, ctx ...any) { + d.l.Error(msg, ctx...) +} + +func (d *grafanaInfraLogWrapper) FromContext(ctx context.Context) pluginslog.Logger { + concreteInfraLogger, ok := d.l.FromContext(ctx).(*log.ConcreteLogger) + if !ok { + return d.New() + } + return &grafanaInfraLogWrapper{ + l: concreteInfraLogger, + } +} diff --git a/pkg/services/pluginsintegration/plugintest/plugins_test.go b/pkg/services/pluginsintegration/plugintest/plugins_test.go index de1afb67710..f30f8475f9a 100644 --- a/pkg/services/pluginsintegration/plugintest/plugins_test.go +++ b/pkg/services/pluginsintegration/plugintest/plugins_test.go @@ -19,10 +19,10 @@ import ( "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration" + "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/searchV2" "github.com/grafana/grafana/pkg/services/sqlstore" diff --git a/pkg/services/pluginsintegration/test_helper.go b/pkg/services/pluginsintegration/test_helper.go index d6cff58d313..9daad43e3e2 100644 --- a/pkg/services/pluginsintegration/test_helper.go +++ b/pkg/services/pluginsintegration/test_helper.go @@ -8,8 +8,6 @@ import ( "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" - "github.com/grafana/grafana/pkg/plugins/backendplugin/coreplugin" - "github.com/grafana/grafana/pkg/plugins/backendplugin/provider" pluginsCfg "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/manager/client" "github.com/grafana/grafana/pkg/plugins/manager/loader" @@ -27,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/pluginassets" "github.com/grafana/grafana/pkg/plugins/pluginerrs" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/pluginsintegration/coreplugin" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsources" @@ -52,7 +51,7 @@ func CreateIntegrationTestCtx(t *testing.T, cfg *setting.Cfg, coreRegistry *core disc := pipeline.ProvideDiscoveryStage(pCfg, reg) boot := pipeline.ProvideBootstrapStage(pCfg, signature.ProvideService(pCfg, statickey.New()), pluginassets.NewLocalProvider()) valid := pipeline.ProvideValidationStage(pCfg, signature.NewValidator(signature.NewUnsignedAuthorizer(pCfg)), angularInspector) - init := pipeline.ProvideInitializationStage(pCfg, reg, provider.ProvideService(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()) + init := pipeline.ProvideInitializationStage(pCfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), proc, &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()) term, err := pipeline.ProvideTerminationStage(pCfg, reg, proc) require.NoError(t, err) @@ -98,7 +97,7 @@ func CreateTestLoader(t *testing.T, cfg *pluginsCfg.PluginManagementCfg, opts Lo if opts.Initializer == nil { reg := registry.ProvideService() coreRegistry := coreplugin.NewRegistry(make(map[string]backendplugin.PluginFactoryFunc)) - opts.Initializer = pipeline.ProvideInitializationStage(cfg, reg, provider.ProvideService(coreRegistry), process.ProvideService(), &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()) + opts.Initializer = pipeline.ProvideInitializationStage(cfg, reg, coreplugin.ProvideCoreProvider(coreRegistry), process.ProvideService(), &pluginfakes.FakeAuthService{}, pluginfakes.NewFakeRoleRegistry(), pluginfakes.NewFakeActionSetRegistry(), nil, tracing.InitializeTracerForTest(), provisionedplugins.NewNoop()) } if opts.Terminator == nil { diff --git a/pkg/services/preference/prefapi/api.go b/pkg/services/preference/prefapi/api.go index 6bf8057ac68..15d5c22c4bc 100644 --- a/pkg/services/preference/prefapi/api.go +++ b/pkg/services/preference/prefapi/api.go @@ -20,6 +20,10 @@ func UpdatePreferencesFor(ctx context.Context, return response.Error(http.StatusBadRequest, "Invalid theme", nil) } + if !pref.IsValidTimezone(dtoCmd.Timezone) { + return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil) + } + // convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query // nolint:staticcheck dashboardID := dtoCmd.HomeDashboardID diff --git a/pkg/services/preference/timezone.go b/pkg/services/preference/timezone.go new file mode 100644 index 00000000000..e69e8eda591 --- /dev/null +++ b/pkg/services/preference/timezone.go @@ -0,0 +1,21 @@ +package pref + +import ( + "time" +) + +// IsValidTimezone checks if the timezone string is valid. +// It accepts: +// - "" - uses default +// - "utc" +// - "browser" +// - Any valid IANA timezone (e.g., "America/New_York", "Europe/London") +func IsValidTimezone(timezone string) bool { + if timezone == "" || timezone == "utc" || timezone == "browser" { + return true + } + + // try to load as IANA timezone + _, err := time.LoadLocation(timezone) + return err == nil +} diff --git a/pkg/services/preference/timezone_test.go b/pkg/services/preference/timezone_test.go new file mode 100644 index 00000000000..e9bfceb6203 --- /dev/null +++ b/pkg/services/preference/timezone_test.go @@ -0,0 +1,38 @@ +package pref + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsValidTimezone(t *testing.T) { + tests := []struct { + timezone string + valid bool + }{ + { + timezone: "utc", + valid: true, + }, + { + timezone: "browser", + valid: true, + }, + { + timezone: "Europe/London", + valid: true, + }, + { + timezone: "invalid", + valid: false, + }, + { + timezone: "", + valid: true, + }, + } + for _, test := range tests { + assert.Equal(t, test.valid, IsValidTimezone(test.timezone)) + } +} diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 834912a6af1..8c5ca87ae93 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -542,9 +542,10 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService()) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) + dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, featuremgmt.WithFeatures()) _, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext. - ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) m := metrics.NewNGAlert(prometheus.NewRegistry()) diff --git a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go index fdb1860b674..1c018a653e2 100644 --- a/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go +++ b/pkg/services/secrets/kvstore/migrations/datasource_mig_test.go @@ -37,9 +37,10 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS features := featuremgmt.WithFeatures() secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) quotaService := quotatest.New(false, nil) + dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, features) dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, - plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider())) + plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever) require.NoError(t, err) migService := ProvideDataSourceMigrationService(dsService, kvStore, features) return migService diff --git a/pkg/services/store/kind/dashboard/ds_lookup.go b/pkg/services/store/kind/dashboard/ds_lookup.go index 5d132d569be..7330e598409 100644 --- a/pkg/services/store/kind/dashboard/ds_lookup.go +++ b/pkg/services/store/kind/dashboard/ds_lookup.go @@ -100,6 +100,9 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef { if ref == nil { return d.defaultDS } + if ref.UID == "default" && ref.Type == "" { + return d.defaultDS + } key := "" if ref.UID != "" { @@ -117,7 +120,13 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef { return ds } - return d.byName[key] + ds, ok = d.byName[key] + if ok { + return ds + } + + // With nothing was found (or configured), use the original reference + return ref } func (d *DsLookup) ByType(dsType string) []DataSourceRef { diff --git a/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json b/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json index 1ffaecb605b..2a687ad8865 100644 --- a/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json +++ b/pkg/services/store/kind/dashboard/testdata/panel-with-library-panel-field-info.json @@ -4,8 +4,8 @@ "tags": null, "datasource": [ { - "uid": "default.uid", - "type": "default.type" + "uid": "000000001", + "type": "graphite" } ], "panels": [ @@ -16,8 +16,8 @@ "libraryPanel": "dfkljg98345dkf", "datasource": [ { - "uid": "default.uid", - "type": "default.type" + "uid": "000000001", + "type": "graphite" } ] } diff --git a/pkg/services/store/kind/dashboard/types.go b/pkg/services/store/kind/dashboard/types.go index 51aa00a79fd..c1dea30abb7 100644 --- a/pkg/services/store/kind/dashboard/types.go +++ b/pkg/services/store/kind/dashboard/types.go @@ -1,5 +1,7 @@ package dashboard +import "iter" + type PanelSummaryInfo struct { ID int64 `json:"id"` Title string `json:"title"` @@ -30,3 +32,20 @@ type DashboardSummaryInfo struct { Refresh string `json:"refresh,omitempty"` ReadOnly bool `json:"readOnly,omitempty"` // editable = false } + +func (d *DashboardSummaryInfo) PanelIterator() iter.Seq[PanelSummaryInfo] { + return func(yield func(PanelSummaryInfo) bool) { + for _, p := range d.Panels { + if len(p.Collapsed) > 0 { + for _, c := range p.Collapsed { + if !yield(c) { // NOTE, rows can only be one level deep! + return + } + } + } + if !yield(p) { + return + } + } + } +} diff --git a/pkg/services/updatemanager/plugins_test.go b/pkg/services/updatemanager/plugins_test.go index 75834c44c7d..93b3d54b321 100644 --- a/pkg/services/updatemanager/plugins_test.go +++ b/pkg/services/updatemanager/plugins_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/open-feature/go-sdk/openfeature" + "github.com/open-feature/go-sdk/openfeature/memprovider" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/log" @@ -378,8 +379,10 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) { err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{ ProviderType: setting.StaticProviderType, - StaticFlags: map[string]bool{ - featuremgmt.FlagPluginsAutoUpdate: flagValue, + StaticFlags: map[string]memprovider.InMemoryFlag{ + featuremgmt.FlagPluginsAutoUpdate: { + Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue}, + }, }, }) require.NoError(t, err) diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index fc8d9589357..12b0ac6ed57 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -146,6 +146,7 @@ type UserSearchHitDTO struct { LastSeenAtAge string `json:"lastSeenAtAge"` AuthLabels []string `json:"authLabels"` AuthModule AuthModuleConversion `json:"-"` + Created time.Time `json:"created" xorm:"created"` } type GetUserProfileQuery struct { diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index a7773147373..64780c5c2eb 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -541,7 +541,7 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* sess.Limit(query.Limit, offset) } - sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned") + sess.Cols("u.id", "u.uid", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module", "u.is_provisioned", "u.created") if len(query.SortOpts) > 0 { for i := range query.SortOpts { diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index aafc9167ce4..9667b82b9fa 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -588,8 +588,10 @@ type Cfg struct { // Unified Storage UnifiedStorage map[string]UnifiedStorageConfig // DisableDataMigrations will disable resources data migration to unified storage at startup - DisableDataMigrations bool - MaxPageSizeBytes int + DisableDataMigrations bool + MaxPageSizeBytes int + // IndexPath the directory where index files are stored. + // Note: Bleve locks index files, so mounts cannot be shared between multiple instances. IndexPath string IndexWorkers int IndexRebuildWorkers int @@ -637,6 +639,8 @@ type UnifiedStorageConfig struct { // EnableMigration indicates whether migration is enabled for the resource. // If not set, will use the default from MigratedUnifiedResources. EnableMigration bool + // AutoMigrationThreshold is the threshold below which a resource is automatically migrated. + AutoMigrationThreshold int } type InstallPlugin struct { diff --git a/pkg/setting/setting_feature_toggles.go b/pkg/setting/setting_feature_toggles.go index e09b45e9edb..38bfd0269e1 100644 --- a/pkg/setting/setting_feature_toggles.go +++ b/pkg/setting/setting_feature_toggles.go @@ -1,13 +1,20 @@ package setting import ( + "encoding/json" + "math" "strconv" "gopkg.in/ini.v1" + "github.com/open-feature/go-sdk/openfeature/memprovider" + "github.com/grafana/grafana/pkg/util" ) +// DefaultVariantName a placeholder name for config-based Feature Flags +const DefaultVariantName = "default" + // Deprecated: should use `featuremgmt.FeatureToggles` func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error { section := iniFile.Section("feature_toggles") @@ -15,18 +22,27 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error { if err != nil { return err } + // TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely // nolint:staticcheck - cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] } + cfg.IsFeatureToggleEnabled = func(key string) bool { + toggle, ok := toggles[key] + if !ok { + return false + } + + value, ok := toggle.Variants[toggle.DefaultVariant].(bool) + return value && ok + } return nil } -func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) { - featureToggles := make(map[string]bool, 10) +func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) { + featureToggles := make(map[string]memprovider.InMemoryFlag, 10) // parse the comma separated list in `enable`. featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "") for _, feature := range util.SplitString(featuresTogglesStr) { - featureToggles[feature] = true + featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}} } // read all other settings under [feature_toggles]. If a toggle is @@ -36,7 +52,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str continue } - b, err := strconv.ParseBool(v.Value()) + b, err := ParseFlag(v.Name(), v.Value()) if err != nil { return featureToggles, err } @@ -45,3 +61,57 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str } return featureToggles, nil } + +func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) { + var structure map[string]any + + if integer, err := strconv.Atoi(value); err == nil { + return NewInMemoryFlag(name, integer), nil + } + if float, err := strconv.ParseFloat(value, 64); err == nil { + return NewInMemoryFlag(name, float), nil + } + if err := json.Unmarshal([]byte(value), &structure); err == nil { + return NewInMemoryFlag(name, structure), nil + } + if boolean, err := strconv.ParseBool(value); err == nil { + return NewInMemoryFlag(name, boolean), nil + } + + return NewInMemoryFlag(name, value), nil +} + +func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag { + return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}} +} + +func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string { + var res = map[string]string{} + for k, v := range m { + res[k] = serializeFlagValue(v) + } + return res +} + +func serializeFlagValue(flag memprovider.InMemoryFlag) string { + value := flag.Variants[flag.DefaultVariant] + + switch castedValue := value.(type) { + case bool: + return strconv.FormatBool(castedValue) + case int64: + return strconv.FormatInt(castedValue, 10) + case float64: + // handle cases with a single or no zeros after the decimal point + if math.Trunc(castedValue) == castedValue { + return strconv.FormatFloat(castedValue, 'f', 1, 64) + } + + return strconv.FormatFloat(castedValue, 'g', -1, 64) + case string: + return castedValue + default: + val, _ := json.Marshal(value) + return string(val) + } +} diff --git a/pkg/setting/setting_feature_toggles_test.go b/pkg/setting/setting_feature_toggles_test.go index b0c3730bcad..040a9ef7427 100644 --- a/pkg/setting/setting_feature_toggles_test.go +++ b/pkg/setting/setting_feature_toggles_test.go @@ -1,9 +1,11 @@ package setting import ( - "strconv" "testing" + "github.com/google/go-cmp/cmp" + "github.com/open-feature/go-sdk/openfeature/memprovider" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gopkg.in/ini.v1" ) @@ -12,17 +14,16 @@ func TestFeatureToggles(t *testing.T) { testCases := []struct { name string conf map[string]string - err error - expectedToggles map[string]bool + expectedToggles map[string]memprovider.InMemoryFlag }{ { name: "can parse feature toggles passed in the `enable` array", conf: map[string]string{ "enable": "feature1,feature2", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": true, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", true), }, }, { @@ -31,10 +32,10 @@ func TestFeatureToggles(t *testing.T) { "enable": "feature1,feature2", "feature3": "true", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": true, - "feature3": true, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", true), + "feature3": NewInMemoryFlag("feature3", true), }, }, { @@ -43,19 +44,26 @@ func TestFeatureToggles(t *testing.T) { "enable": "feature1,feature2", "feature2": "false", }, - expectedToggles: map[string]bool{ - "feature1": true, - "feature2": false, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", true), + "feature2": NewInMemoryFlag("feature2", false), }, }, { - name: "invalid boolean value should return syntax error", + name: "feature flags of different types are handled correctly", conf: map[string]string{ - "enable": "feature1,feature2", - "feature2": "invalid", + "feature1": "1", "feature2": "1.0", + "feature3": `{"foo":"bar"}`, "feature4": "bar", + "feature5": "t", "feature6": "T", + }, + expectedToggles: map[string]memprovider.InMemoryFlag{ + "feature1": NewInMemoryFlag("feature1", 1), + "feature2": NewInMemoryFlag("feature2", 1.0), + "feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}), + "feature4": NewInMemoryFlag("feature4", "bar"), + "feature5": NewInMemoryFlag("feature5", true), + "feature6": NewInMemoryFlag("feature6", true), }, - expectedToggles: map[string]bool{}, - err: strconv.ErrSyntax, }, } @@ -69,12 +77,35 @@ func TestFeatureToggles(t *testing.T) { } featureToggles, err := ReadFeatureTogglesFromInitFile(toggles) - require.ErrorIs(t, err, tc.err) + require.NoError(t, err) - if err == nil { - for k, v := range featureToggles { - require.Equal(t, tc.expectedToggles[k], v, tc.name) - } + for k, v := range featureToggles { + toggle := tc.expectedToggles[k] + require.Equal(t, toggle, v, tc.name) + } + } +} + +func TestFlagValueSerialization(t *testing.T) { + testCases := []memprovider.InMemoryFlag{ + NewInMemoryFlag("int", 1), + NewInMemoryFlag("1.0f", 1.0), + NewInMemoryFlag("1.01f", 1.01), + NewInMemoryFlag("1.10f", 1.10), + NewInMemoryFlag("struct", map[string]any{"foo": "bar"}), + NewInMemoryFlag("string", "bar"), + NewInMemoryFlag("true", true), + NewInMemoryFlag("false", false), + } + + for _, tt := range testCases { + asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt}) + + deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key]) + assert.NoError(t, err) + + if diff := cmp.Diff(tt, deserialized); diff != "" { + t.Errorf("(-want, +got) = %v", diff) } } } diff --git a/pkg/setting/setting_openfeature.go b/pkg/setting/setting_openfeature.go index 52966b0f7ba..17ef4d9de16 100644 --- a/pkg/setting/setting_openfeature.go +++ b/pkg/setting/setting_openfeature.go @@ -6,8 +6,9 @@ import ( ) const ( - StaticProviderType = "static" - GOFFProviderType = "goff" + StaticProviderType = "static" + FeaturesServiceProviderType = "features-service" + OFREPProviderType = "ofrep" ) type OpenFeatureSettings struct { @@ -33,7 +34,7 @@ func (cfg *Cfg) readOpenFeatureSettings() error { cfg.OpenFeature.TargetingKey = config.Key("targetingKey").MustString(defaultTargetingKey) - if strURL != "" && cfg.OpenFeature.ProviderType == GOFFProviderType { + if strURL != "" && (cfg.OpenFeature.ProviderType == FeaturesServiceProviderType || cfg.OpenFeature.ProviderType == OFREPProviderType) { u, err := url.Parse(strURL) if err != nil { return fmt.Errorf("invalid feature provider url: %w", err) diff --git a/pkg/setting/setting_secrets_manager.go b/pkg/setting/setting_secrets_manager.go index 260b98264d8..ed7386813ef 100644 --- a/pkg/setting/setting_secrets_manager.go +++ b/pkg/setting/setting_secrets_manager.go @@ -36,10 +36,16 @@ type SecretsManagerSettings struct { // How long to wait for the process to clean up a secure value to complete. GCWorkerPerSecureValueCleanupTimeout time.Duration + // Whether to register the MT CRUD API + RegisterAPIServer bool // Whether to create the MT secrets management database RunSecretsDBMigrations bool // Whether to run the data key id migration. Requires that RunSecretsDBMigrations is also true. RunDataKeyMigration bool + + // AWS Keeper + AWSKeeperAccessKeyID string + AWSKeeperSecretAccessKey string } func (cfg *Cfg) readSecretsManagerSettings() { @@ -60,9 +66,13 @@ func (cfg *Cfg) readSecretsManagerSettings() { cfg.SecretsManagement.GCWorkerPollInterval = secretsMgmt.Key("gc_worker_poll_interval").MustDuration(1 * time.Minute) cfg.SecretsManagement.GCWorkerPerSecureValueCleanupTimeout = secretsMgmt.Key("gc_worker_per_request_timeout").MustDuration(5 * time.Second) + cfg.SecretsManagement.RegisterAPIServer = secretsMgmt.Key("register_api_server").MustBool(true) cfg.SecretsManagement.RunSecretsDBMigrations = secretsMgmt.Key("run_secrets_db_migrations").MustBool(true) cfg.SecretsManagement.RunDataKeyMigration = secretsMgmt.Key("run_data_key_migration").MustBool(true) + cfg.SecretsManagement.AWSKeeperAccessKeyID = secretsMgmt.Key("aws_access_key_id").MustString("") + cfg.SecretsManagement.AWSKeeperSecretAccessKey = secretsMgmt.Key("aws_secret_access_key").MustString("") + // Extract available KMS providers from configuration sections providers := make(map[string]map[string]string) for _, section := range cfg.Raw.Sections() { diff --git a/pkg/setting/setting_secrets_manager_test.go b/pkg/setting/setting_secrets_manager_test.go index 34f88a481b5..c326c250821 100644 --- a/pkg/setting/setting_secrets_manager_test.go +++ b/pkg/setting/setting_secrets_manager_test.go @@ -171,6 +171,28 @@ domain = example.com assert.Empty(t, cfg.SecretsManagement.ConfiguredKMSProviders) }) + t.Run("should handle configuration with register_api_server disabled", func(t *testing.T) { + iniContent := ` +[secrets_manager] +register_api_server = false +` + cfg, err := NewCfgFromBytes([]byte(iniContent)) + require.NoError(t, err) + + assert.False(t, cfg.SecretsManagement.RegisterAPIServer) + }) + + t.Run("should handle configuration without register_api_server set", func(t *testing.T) { + iniContent := ` +[secrets_manager] +encryption_provider = aws_kms +` + cfg, err := NewCfgFromBytes([]byte(iniContent)) + require.NoError(t, err) + + assert.True(t, cfg.SecretsManagement.RegisterAPIServer) + }) + t.Run("should handle configuration with run_secrets_db_migrations disabled", func(t *testing.T) { iniContent := ` [secrets_manager] diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 8086151c40a..21a3f455993 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -8,6 +8,10 @@ import ( "github.com/grafana/grafana/pkg/util/osutil" ) +// DefaultAutoMigrationThreshold is the default threshold for auto migration switching. +// If a resource has entries at or below this count, it will be migrated. +const DefaultAutoMigrationThreshold = 10 + const ( PlaylistResource = "playlists.playlist.grafana.app" FolderResource = "folders.folder.grafana.app" @@ -21,6 +25,13 @@ var MigratedUnifiedResources = map[string]bool{ DashboardResource: false, } +// AutoMigratedUnifiedResources maps resources that support auto-migration +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +var AutoMigratedUnifiedResources = map[string]bool{ + FolderResource: true, + DashboardResource: true, +} + // read storage configs from ini file. They look like: // [unified_storage..] // = @@ -59,6 +70,13 @@ func (cfg *Cfg) setUnifiedStorageConfig() { enableMigration = section.Key("enableMigration").MustBool(MigratedUnifiedResources[resourceName]) } + // parse autoMigrationThreshold from resource section + autoMigrationThreshold := 0 + autoMigrate := AutoMigratedUnifiedResources[resourceName] + if autoMigrate { + autoMigrationThreshold = section.Key("autoMigrationThreshold").MustInt(DefaultAutoMigrationThreshold) + } + storageConfig[resourceName] = UnifiedStorageConfig{ DualWriterMode: rest.DualWriterMode(dualWriterMode), DualWriterPeriodicDataSyncJobEnabled: dualWriterPeriodicDataSyncJobEnabled, @@ -66,6 +84,7 @@ func (cfg *Cfg) setUnifiedStorageConfig() { DataSyncerRecordsLimit: dataSyncerRecordsLimit, DataSyncerInterval: dataSyncerInterval, EnableMigration: enableMigration, + AutoMigrationThreshold: autoMigrationThreshold, } } cfg.UnifiedStorage = storageConfig @@ -73,13 +92,13 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // Set indexer config for unified storage section := cfg.Raw.Section("unified_storage") cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false) - if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" { + if !cfg.DisableDataMigrations && cfg.UnifiedStorageType() == "unified" { // Helper log to find instances running migrations in the future cfg.Logger.Info("Unified migration configs enforced") cfg.enforceMigrationToUnifiedConfigs() } else { // Helper log to find instances disabling migration - cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.getUnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations) + cfg.Logger.Info("Unified migration configs enforcement disabled", "storage_type", cfg.UnifiedStorageType(), "disable_data_migrations", cfg.DisableDataMigrations) } cfg.EnableSearch = section.Key("enable_search").MustBool(false) cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0) @@ -147,14 +166,15 @@ func (cfg *Cfg) enforceMigrationToUnifiedConfigs() { DualWriterMode: 5, DualWriterMigrationDataSyncDisabled: true, EnableMigration: true, + AutoMigrationThreshold: resourceCfg.AutoMigrationThreshold, } } } -// getUnifiedStorageType returns the configured storage type without creating or mutating keys. +// UnifiedStorageType returns the configured storage type without creating or mutating keys. // Precedence: env > ini > default ("unified"). // Used to decide unified storage behavior early without side effects. -func (cfg *Cfg) getUnifiedStorageType() string { +func (cfg *Cfg) UnifiedStorageType() string { const ( grafanaAPIServerSectionName = "grafana-apiserver" storageTypeKeyName = "storage_type" @@ -168,3 +188,23 @@ func (cfg *Cfg) getUnifiedStorageType() string { } return defaultStorageType } + +// UnifiedStorageConfig returns the UnifiedStorageConfig for a resource. +func (cfg *Cfg) UnifiedStorageConfig(resource string) UnifiedStorageConfig { + if cfg.UnifiedStorage == nil { + return UnifiedStorageConfig{} + } + return cfg.UnifiedStorage[resource] +} + +// EnableMode5 enables migration and sets mode 5 for a resource. +func (cfg *Cfg) EnableMode5(resource string) { + if cfg.UnifiedStorage == nil { + cfg.UnifiedStorage = make(map[string]UnifiedStorageConfig) + } + config := cfg.UnifiedStorage[resource] + config.DualWriterMode = rest.Mode5 + config.DualWriterMigrationDataSyncDisabled = true + config.EnableMigration = true + cfg.UnifiedStorage[resource] = config +} diff --git a/pkg/setting/setting_unified_storage_test.go b/pkg/setting/setting_unified_storage_test.go index c112dc85962..58f0fe56a60 100644 --- a/pkg/setting/setting_unified_storage_test.go +++ b/pkg/setting/setting_unified_storage_test.go @@ -43,10 +43,16 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) { } assert.Equal(t, exists, true, migratedResource) + expectedThreshold := 0 + if AutoMigratedUnifiedResources[migratedResource] { + expectedThreshold = DefaultAutoMigrationThreshold + } + assert.Equal(t, UnifiedStorageConfig{ DualWriterMode: 5, DualWriterMigrationDataSyncDisabled: true, EnableMigration: isEnabled, + AutoMigrationThreshold: expectedThreshold, }, resourceCfg, migratedResource) } } @@ -71,6 +77,7 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) { DualWriterPeriodicDataSyncJobEnabled: true, DataSyncerRecordsLimit: 1001, DataSyncerInterval: time.Minute * 10, + AutoMigrationThreshold: 0, }) validateMigratedResources(false) diff --git a/pkg/setting/settings_zanzana.go b/pkg/setting/settings_zanzana.go index d30304e4bd0..f1814ab7be4 100644 --- a/pkg/setting/settings_zanzana.go +++ b/pkg/setting/settings_zanzana.go @@ -37,6 +37,8 @@ type ZanzanaServerSettings struct { OpenFGAHttpAddr string // Cache settings CacheSettings OpenFgaCacheSettings + // OpenFGA server settings + OpenFgaServerSettings OpenFgaServerSettings // Max number of results returned by ListObjects() query. Default is 1000. ListObjectsMaxResults uint32 // Deadline for the ListObjects() query. Default is 3 seconds. @@ -50,6 +52,92 @@ type ZanzanaServerSettings struct { AllowInsecure bool } +type OpenFgaServerSettings struct { + // ListObjects settings + // Max number of concurrent datastore reads for ListObjects queries + MaxConcurrentReadsForListObjects uint32 + // Enable dispatch throttling for ListObjects queries + ListObjectsDispatchThrottlingEnabled bool + // Frequency for dispatch throttling in ListObjects queries + ListObjectsDispatchThrottlingFrequency time.Duration + // Threshold for dispatch throttling in ListObjects queries + ListObjectsDispatchThrottlingThreshold uint32 + // Max threshold for dispatch throttling in ListObjects queries + ListObjectsDispatchThrottlingMaxThreshold uint32 + // Database throttle threshold for ListObjects queries + ListObjectsDatabaseThrottleThreshold int + // Database throttle duration for ListObjects queries + ListObjectsDatabaseThrottleDuration time.Duration + + // ListUsers settings + // Deadline for ListUsers queries + ListUsersDeadline time.Duration + // Max number of results returned by ListUsers queries + ListUsersMaxResults uint32 + // Max number of concurrent datastore reads for ListUsers queries + MaxConcurrentReadsForListUsers uint32 + // Enable dispatch throttling for ListUsers queries + ListUsersDispatchThrottlingEnabled bool + // Frequency for dispatch throttling in ListUsers queries + ListUsersDispatchThrottlingFrequency time.Duration + // Threshold for dispatch throttling in ListUsers queries + ListUsersDispatchThrottlingThreshold uint32 + // Max threshold for dispatch throttling in ListUsers queries + ListUsersDispatchThrottlingMaxThreshold uint32 + // Database throttle threshold for ListUsers queries + ListUsersDatabaseThrottleThreshold int + // Database throttle duration for ListUsers queries + ListUsersDatabaseThrottleDuration time.Duration + + // Check settings + // Max number of concurrent datastore reads for Check queries + MaxConcurrentReadsForCheck uint32 + // Database throttle threshold for Check queries + CheckDatabaseThrottleThreshold int + // Database throttle duration for Check queries + CheckDatabaseThrottleDuration time.Duration + + // Batch check settings + // Max number of concurrent checks per batch check request + MaxConcurrentChecksPerBatchCheck uint32 + // Max number of checks per batch check request + MaxChecksPerBatchCheck uint32 + + // Resolve node settings + // Max number of nodes that can be resolved in a single query + ResolveNodeLimit uint32 + // Max breadth of nodes that can be resolved in a single query + ResolveNodeBreadthLimit uint32 + + // Dispatch throttling settings for Check resolver + // Enable dispatch throttling for Check resolver + DispatchThrottlingCheckResolverEnabled bool + // Frequency for dispatch throttling in Check resolver + DispatchThrottlingCheckResolverFrequency time.Duration + // Threshold for dispatch throttling in Check resolver + DispatchThrottlingCheckResolverThreshold uint32 + // Max threshold for dispatch throttling in Check resolver + DispatchThrottlingCheckResolverMaxThreshold uint32 + + // Shadow check/query settings + // Timeout for shadow check resolver + ShadowCheckResolverTimeout time.Duration + // Timeout for shadow ListObjects query + ShadowListObjectsQueryTimeout time.Duration + // Max delta items for shadow ListObjects query + ShadowListObjectsQueryMaxDeltaItems int + + // Request settings + // Global request timeout + RequestTimeout time.Duration + // Max size in bytes for authorization model + MaxAuthorizationModelSizeInBytes int + // Size of the authorization model cache + AuthorizationModelCacheSize int + // Offset for changelog horizon + ChangelogHorizonOffset int +} + // Parameters to configure OpenFGA cache. type OpenFgaCacheSettings struct { // Number of items that will be kept in the in-memory cache used to resolve Check queries. @@ -156,5 +244,56 @@ func (cfg *Cfg) readZanzanaSettings() { zs.CacheSettings.SharedIteratorLimit = uint32(serverSec.Key("shared_iterator_limit").MustUint(1000)) zs.CacheSettings.SharedIteratorTTL = serverSec.Key("shared_iterator_ttl").MustDuration(10 * time.Second) + openfgaSec := cfg.SectionWithEnvOverrides("openfga") + + // ListObjects settings + zs.OpenFgaServerSettings.MaxConcurrentReadsForListObjects = uint32(openfgaSec.Key("max_concurrent_reads_for_list_objects").MustUint(0)) + zs.OpenFgaServerSettings.ListObjectsDispatchThrottlingEnabled = openfgaSec.Key("list_objects_dispatch_throttling_enabled").MustBool(false) + zs.OpenFgaServerSettings.ListObjectsDispatchThrottlingFrequency = openfgaSec.Key("list_objects_dispatch_throttling_frequency").MustDuration(0) + zs.OpenFgaServerSettings.ListObjectsDispatchThrottlingThreshold = uint32(openfgaSec.Key("list_objects_dispatch_throttling_threshold").MustUint(0)) + zs.OpenFgaServerSettings.ListObjectsDispatchThrottlingMaxThreshold = uint32(openfgaSec.Key("list_objects_dispatch_throttling_max_threshold").MustUint(0)) + zs.OpenFgaServerSettings.ListObjectsDatabaseThrottleThreshold = openfgaSec.Key("list_objects_database_throttle_threshold").MustInt(0) + zs.OpenFgaServerSettings.ListObjectsDatabaseThrottleDuration = openfgaSec.Key("list_objects_database_throttle_duration").MustDuration(0) + + // ListUsers settings + zs.OpenFgaServerSettings.ListUsersDeadline = openfgaSec.Key("list_users_deadline").MustDuration(0) + zs.OpenFgaServerSettings.ListUsersMaxResults = uint32(openfgaSec.Key("list_users_max_results").MustUint(0)) + zs.OpenFgaServerSettings.MaxConcurrentReadsForListUsers = uint32(openfgaSec.Key("max_concurrent_reads_for_list_users").MustUint(0)) + zs.OpenFgaServerSettings.ListUsersDispatchThrottlingEnabled = openfgaSec.Key("list_users_dispatch_throttling_enabled").MustBool(false) + zs.OpenFgaServerSettings.ListUsersDispatchThrottlingFrequency = openfgaSec.Key("list_users_dispatch_throttling_frequency").MustDuration(0) + zs.OpenFgaServerSettings.ListUsersDispatchThrottlingThreshold = uint32(openfgaSec.Key("list_users_dispatch_throttling_threshold").MustUint(0)) + zs.OpenFgaServerSettings.ListUsersDispatchThrottlingMaxThreshold = uint32(openfgaSec.Key("list_users_dispatch_throttling_max_threshold").MustUint(0)) + zs.OpenFgaServerSettings.ListUsersDatabaseThrottleThreshold = openfgaSec.Key("list_users_database_throttle_threshold").MustInt(0) + zs.OpenFgaServerSettings.ListUsersDatabaseThrottleDuration = openfgaSec.Key("list_users_database_throttle_duration").MustDuration(0) + + // Check settings + zs.OpenFgaServerSettings.MaxConcurrentReadsForCheck = uint32(openfgaSec.Key("max_concurrent_reads_for_check").MustUint(0)) + zs.OpenFgaServerSettings.CheckDatabaseThrottleThreshold = openfgaSec.Key("check_database_throttle_threshold").MustInt(0) + zs.OpenFgaServerSettings.CheckDatabaseThrottleDuration = openfgaSec.Key("check_database_throttle_duration").MustDuration(0) + + // Batch check settings + zs.OpenFgaServerSettings.MaxConcurrentChecksPerBatchCheck = uint32(openfgaSec.Key("max_concurrent_checks_per_batch_check").MustUint(0)) + zs.OpenFgaServerSettings.MaxChecksPerBatchCheck = uint32(openfgaSec.Key("max_checks_per_batch_check").MustUint(0)) + + // Resolve node settings + zs.OpenFgaServerSettings.ResolveNodeLimit = uint32(openfgaSec.Key("resolve_node_limit").MustUint(0)) + zs.OpenFgaServerSettings.ResolveNodeBreadthLimit = uint32(openfgaSec.Key("resolve_node_breadth_limit").MustUint(0)) + + // Dispatch throttling settings for Check resolver + zs.OpenFgaServerSettings.DispatchThrottlingCheckResolverEnabled = openfgaSec.Key("dispatch_throttling_check_resolver_enabled").MustBool(false) + zs.OpenFgaServerSettings.DispatchThrottlingCheckResolverFrequency = openfgaSec.Key("dispatch_throttling_check_resolver_frequency").MustDuration(0) + zs.OpenFgaServerSettings.DispatchThrottlingCheckResolverThreshold = uint32(openfgaSec.Key("dispatch_throttling_check_resolver_threshold").MustUint(0)) + zs.OpenFgaServerSettings.DispatchThrottlingCheckResolverMaxThreshold = uint32(openfgaSec.Key("dispatch_throttling_check_resolver_max_threshold").MustUint(0)) + + // Shadow check/query settings + zs.OpenFgaServerSettings.ShadowCheckResolverTimeout = openfgaSec.Key("shadow_check_resolver_timeout").MustDuration(0) + zs.OpenFgaServerSettings.ShadowListObjectsQueryTimeout = openfgaSec.Key("shadow_list_objects_query_timeout").MustDuration(0) + zs.OpenFgaServerSettings.ShadowListObjectsQueryMaxDeltaItems = openfgaSec.Key("shadow_list_objects_query_max_delta_items").MustInt(0) + + zs.OpenFgaServerSettings.RequestTimeout = openfgaSec.Key("request_timeout").MustDuration(0) + zs.OpenFgaServerSettings.MaxAuthorizationModelSizeInBytes = openfgaSec.Key("max_authorization_model_size_in_bytes").MustInt(0) + zs.OpenFgaServerSettings.AuthorizationModelCacheSize = openfgaSec.Key("authorization_model_cache_size").MustInt(0) + zs.OpenFgaServerSettings.ChangelogHorizonOffset = openfgaSec.Key("changelog_horizon_offset").MustInt(0) + cfg.ZanzanaServer = zs } diff --git a/pkg/storage/secret/metadata/decrypt_store.go b/pkg/storage/secret/metadata/decrypt_store.go index 4c0ebabb5e8..b8c241ccf7f 100644 --- a/pkg/storage/secret/metadata/decrypt_store.go +++ b/pkg/storage/secret/metadata/decrypt_store.go @@ -127,6 +127,10 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, // function call happens after this. sv, err := s.secureValueMetadataStorage.Read(ctx, namespace, name, contracts.ReadOpts{}) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while reading secure value metadata storage: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to read secure value metadata storage: %v (%w)", err, contracts.ErrDecryptNotFound) } @@ -137,6 +141,10 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, keeperConfig, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, namespace.String(), sv.Status.Keeper, contracts.ReadOpts{}) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while reading keeper config metadata storage: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to read keeper config metadata storage: %v (%w)", err, contracts.ErrDecryptFailed) } @@ -145,8 +153,25 @@ func (s *decryptStorage) Decrypt(ctx context.Context, namespace xkube.Namespace, return "", fmt.Errorf("failed to get keeper for config: %v (%w)", err, contracts.ErrDecryptFailed) } + if sv.Spec.Ref != nil { + exposedValue, err := keeper.RetrieveReference(ctx, keeperConfig, *sv.Spec.Ref) + if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while exposing secret using reference: %v (%w)", err, context.Canceled) + } + + return "", fmt.Errorf("failed to expose secret using reference: %v (%w)", err, contracts.ErrDecryptFailed) + } + + return exposedValue, nil + } + exposedValue, err := keeper.Expose(ctx, keeperConfig, namespace, name, sv.Status.Version) if err != nil { + if errors.Is(err, context.Canceled) { + return "", fmt.Errorf("operation canceled while exposing secret: %v (%w)", err, context.Canceled) + } + return "", fmt.Errorf("failed to expose secret: %v (%w)", err, contracts.ErrDecryptFailed) } diff --git a/pkg/storage/secret/metadata/decrypt_store_test.go b/pkg/storage/secret/metadata/decrypt_store_test.go index c07b65dbf04..9d8b1b46e76 100644 --- a/pkg/storage/secret/metadata/decrypt_store_test.go +++ b/pkg/storage/secret/metadata/decrypt_store_test.go @@ -9,12 +9,14 @@ import ( "github.com/grafana/grafana-app-sdk/logging" "github.com/stretchr/testify/require" grpcmetadata "google.golang.org/grpc/metadata" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" "github.com/grafana/grafana/pkg/util/testutil" ) @@ -66,6 +68,40 @@ func TestIntegrationDecrypt(t *testing.T) { } }) + t.Run("when the context is cancelled, it returns an error", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + + svcIdentity := "svc" + + // Create auth context with proper permissions that match the decrypters + authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, svcIdentity, types.TypeUser) + + // Setup service + sut := testutils.Setup(t) + + // Create a secure value + spec := secretv1beta1.SecureValueSpec{ + Description: "description", + Decrypters: []string{svcIdentity}, + Value: ptr.To(secretv1beta1.NewExposedSecureValue("value")), + } + sv := &secretv1beta1.SecureValue{Spec: spec} + sv.Name = "sv-test" + sv.Namespace = "default" + + _, err := sut.CreateSv(authCtx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + // Cancel immediately! + cancel() + + exposed, err := sut.DecryptStorage.Decrypt(authCtx, "default", "sv-test") + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, exposed) + }) + t.Run("when happy path with valid auth and permissions, it returns decrypted value", func(t *testing.T) { t.Parallel() @@ -324,6 +360,66 @@ func TestIntegrationDecrypt(t *testing.T) { } } }) + + t.Run("happy path, referencing a secret in a 3rd party store", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + tokenSvcIdentity := "svc" + stSvcIdentity := "st-svc" + + // Create auth context with proper permissions that match the decrypters + authCtx := createAuthContext(ctx, "default", []string{"secret.grafana.app/securevalues:decrypt"}, tokenSvcIdentity, types.TypeUser) + + // Needs to be incoming because we are pretending we received the metadata from a gRPC request + ctx = grpcmetadata.NewIncomingContext(authCtx, grpcmetadata.New(map[string]string{ + contracts.HeaderGrafanaServiceIdentityName: stSvcIdentity, + })) + + // Setup service + sut := testutils.Setup(t) + + // Create a secret on the 3rd party secret store + sut.ModelSecretsManager.Create("ref1", "value") + + // Create a 3rd party keeper + keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), &secretv1beta1.Keeper{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }, "actor-uid") + require.NoError(t, err) + require.NoError(t, sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(keeper.Namespace), keeper.Name)) + + // Create a secure value + sv := &secretv1beta1.SecureValue{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "sv-test", + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: "description", + Decrypters: []string{tokenSvcIdentity}, + Ref: ptr.To("ref1"), + }} + + _, err = sut.CreateSv(ctx, testutils.CreateSvWithSv(sv)) + require.NoError(t, err) + + fakeLogger := &mockLogger{} + + loggerCtx := logging.Context(ctx, fakeLogger) + + exposed, err := sut.DecryptStorage.Decrypt(loggerCtx, "default", "sv-test") + require.NoError(t, err) + require.Equal(t, "value", exposed.DangerouslyExposeAndConsumeValue()) + }) } func createAuthContext(ctx context.Context, namespace string, permissions []string, svc string, identityType types.IdentityType) context.Context { diff --git a/pkg/storage/secret/metadata/keeper_model.go b/pkg/storage/secret/metadata/keeper_model.go index 4831ee25a2e..c97f6adf679 100644 --- a/pkg/storage/secret/metadata/keeper_model.go +++ b/pkg/storage/secret/metadata/keeper_model.go @@ -59,16 +59,16 @@ func (kp *keeperDB) toKubernetes() (*secretv1beta1.Keeper, error) { } // Obtain provider configs - provider := toProvider(secretv1beta1.KeeperType(kp.Type), kp.Payload) + provider := parseKeeperConfigJson(kp.Name, secretv1beta1.KeeperType(kp.Type), kp.Payload) switch v := provider.(type) { - case *secretv1beta1.KeeperAWSConfig: - resource.Spec.Aws = v - case *secretv1beta1.KeeperAzureConfig: - resource.Spec.Azure = v - case *secretv1beta1.KeeperGCPConfig: - resource.Spec.Gcp = v - case *secretv1beta1.KeeperHashiCorpConfig: - resource.Spec.HashiCorpVault = v + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperAWSConfig]: + resource.Spec.Aws = v.Cfg + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperAzureConfig]: + resource.Spec.Azure = v.Cfg + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperGCPConfig]: + resource.Spec.Gcp = v.Cfg + case *secretv1beta1.NamedKeeperConfig[*secretv1beta1.KeeperHashiCorpConfig]: + resource.Spec.HashiCorpVault = v.Cfg } // Set all meta fields here for consistency. @@ -214,34 +214,34 @@ func toTypeAndPayload(kp *secretv1beta1.Keeper) (secretv1beta1.KeeperType, strin return "", "", fmt.Errorf("no keeper type found") } -// toProvider maps a KeeperType and payload into a provider config struct. +// parseKeeperConfigJson maps a KeeperType and payload into a provider config struct. // TODO: Move as method of KeeperType -func toProvider(keeperType secretv1beta1.KeeperType, payload string) secretv1beta1.KeeperConfig { +func parseKeeperConfigJson(keeperName string, keeperType secretv1beta1.KeeperType, payload string) secretv1beta1.KeeperConfig { switch keeperType { case secretv1beta1.AWSKeeperType: aws := &secretv1beta1.KeeperAWSConfig{} if err := json.Unmarshal([]byte(payload), aws); err != nil { return nil } - return aws + return secretv1beta1.NewNamedKeeperConfig(keeperName, aws) case secretv1beta1.AzureKeeperType: azure := &secretv1beta1.KeeperAzureConfig{} if err := json.Unmarshal([]byte(payload), azure); err != nil { return nil } - return azure + return secretv1beta1.NewNamedKeeperConfig(keeperName, azure) case secretv1beta1.GCPKeeperType: gcp := &secretv1beta1.KeeperGCPConfig{} if err := json.Unmarshal([]byte(payload), gcp); err != nil { return nil } - return gcp + return secretv1beta1.NewNamedKeeperConfig(keeperName, gcp) case secretv1beta1.HashiCorpKeeperType: hashicorp := &secretv1beta1.KeeperHashiCorpConfig{} if err := json.Unmarshal([]byte(payload), hashicorp); err != nil { return nil } - return hashicorp + return secretv1beta1.NewNamedKeeperConfig(keeperName, hashicorp) default: return nil } @@ -253,12 +253,16 @@ func extractSecureValues(kp *secretv1beta1.Keeper) map[string]struct{} { case kp.Spec.Aws != nil: secureValues := make(map[string]struct{}, 0) - if kp.Spec.Aws.AccessKeyID.SecureValueName != "" { - secureValues[kp.Spec.Aws.AccessKeyID.SecureValueName] = struct{}{} + if kp.Spec.Aws.AccessKey == nil { + return secureValues } - if kp.Spec.Aws.SecretAccessKey.SecureValueName != "" { - secureValues[kp.Spec.Aws.SecretAccessKey.SecureValueName] = struct{}{} + if kp.Spec.Aws.AccessKey.AccessKeyID.SecureValueName != "" { + secureValues[kp.Spec.Aws.AccessKey.AccessKeyID.SecureValueName] = struct{}{} + } + + if kp.Spec.Aws.AccessKey.SecretAccessKey.SecureValueName != "" { + secureValues[kp.Spec.Aws.AccessKey.SecretAccessKey.SecureValueName] = struct{}{} } return secureValues @@ -284,13 +288,13 @@ func extractSecureValues(kp *secretv1beta1.Keeper) map[string]struct{} { func getKeeperConfig(keeper *secretv1beta1.Keeper) secretv1beta1.KeeperConfig { switch keeper.Spec.GetType() { case secretv1beta1.AWSKeeperType: - return keeper.Spec.Aws + return secretv1beta1.NewNamedKeeperConfig(keeper.Name, keeper.Spec.Aws) case secretv1beta1.AzureKeeperType: - return keeper.Spec.Azure + return secretv1beta1.NewNamedKeeperConfig(keeper.Name, keeper.Spec.Azure) case secretv1beta1.GCPKeeperType: - return keeper.Spec.Gcp + return secretv1beta1.NewNamedKeeperConfig(keeper.Name, keeper.Spec.Gcp) case secretv1beta1.HashiCorpKeeperType: - return keeper.Spec.HashiCorpVault + return secretv1beta1.NewNamedKeeperConfig(keeper.Name, keeper.Spec.HashiCorpVault) default: return nil } diff --git a/pkg/storage/secret/metadata/keeper_store.go b/pkg/storage/secret/metadata/keeper_store.go index d4516158c81..fe57816444d 100644 --- a/pkg/storage/secret/metadata/keeper_store.go +++ b/pkg/storage/secret/metadata/keeper_store.go @@ -609,7 +609,7 @@ func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace s // Check if keeper is the systemwide one. if name == contracts.SystemKeeperName { - return &secretv1beta1.SystemKeeperConfig{}, nil + return secretv1beta1.NewNamedKeeperConfig(contracts.SystemKeeperName, &secretv1beta1.SystemKeeperConfig{}), nil } // Load keeper config from metadata store, or TODO: keeper cache. @@ -618,7 +618,7 @@ func (s *keeperMetadataStorage) GetKeeperConfig(ctx context.Context, namespace s return nil, err } - keeperConfig := toProvider(secretv1beta1.KeeperType(kp.Type), kp.Payload) + keeperConfig := parseKeeperConfigJson(kp.Name, secretv1beta1.KeeperType(kp.Type), kp.Payload) // TODO: this would be a good place to check if credentials are secure values and load them. return keeperConfig, nil @@ -636,13 +636,6 @@ func (s *keeperMetadataStorage) SetAsActive(ctx context.Context, namespace xkube return fmt.Errorf("template %q: %w", sqlKeeperSetAsActive.Name(), err) } - // Check keeper exists. No need to worry about time of check to time of use - // since trying to activate a just deleted keeper will result in all - // keepers being inactive and defaulting to the system keeper. - if _, err := s.read(ctx, namespace.String(), name, contracts.ReadOpts{}); err != nil { - return fmt.Errorf("reading keeper before setting as active: %w", err) - } - _, err = s.db.ExecContext(ctx, query, req.GetArgs()...) if err != nil { return fmt.Errorf("setting keeper as active %q: %w", query, err) @@ -726,7 +719,7 @@ func (s *keeperMetadataStorage) GetActiveKeeperConfig(ctx context.Context, names if err != nil { // When there are not active keepers, default to the system keeper if errors.Is(err, contracts.ErrKeeperNotFound) { - return contracts.SystemKeeperName, &secretv1beta1.SystemKeeperConfig{}, nil + return contracts.SystemKeeperName, secretv1beta1.NewNamedKeeperConfig(contracts.SystemKeeperName, &secretv1beta1.SystemKeeperConfig{}), nil } return "", nil, fmt.Errorf("fetching active keeper from db: %w", err) } diff --git a/pkg/storage/secret/metadata/keeper_store_test.go b/pkg/storage/secret/metadata/keeper_store_test.go index a38e0ec85b3..fe7b3cb9eaf 100644 --- a/pkg/storage/secret/metadata/keeper_store_test.go +++ b/pkg/storage/secret/metadata/keeper_store_test.go @@ -43,7 +43,7 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { // get system keeper config keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, contracts.SystemKeeperName, contracts.ReadOpts{}) require.NoError(t, err) - require.IsType(t, &secretv1beta1.SystemKeeperConfig{}, keeperConfig) + require.IsType(t, &secretv1beta1.NamedKeeperConfig[*secretv1beta1.SystemKeeperConfig]{}, keeperConfig) }) t.Run("get test keeper config", func(t *testing.T) { @@ -188,11 +188,13 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { Spec: secretv1beta1.KeeperSpec{ Description: "initial description", Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_ACCESS_KEY_ID_1", - }, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_SECRET_ACCESS_KEY_1", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID_1", + }, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY_1", + }, }, KmsKeyID: ptr.To("kms-key-id-1"), }, @@ -208,8 +210,8 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { // Verify initial AWS config keeper, err := keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) require.NoError(t, err) - require.Equal(t, "AWS_ACCESS_KEY_ID_1", keeper.Spec.Aws.AccessKeyID.ValueFromEnv) - require.Equal(t, "AWS_SECRET_ACCESS_KEY_1", keeper.Spec.Aws.SecretAccessKey.ValueFromEnv) + require.Equal(t, "AWS_ACCESS_KEY_ID_1", keeper.Spec.Aws.AccessKey.AccessKeyID.ValueFromEnv) + require.Equal(t, "AWS_SECRET_ACCESS_KEY_1", keeper.Spec.Aws.AccessKey.SecretAccessKey.ValueFromEnv) require.Equal(t, "kms-key-id-1", *keeper.Spec.Aws.KmsKeyID) // Update with new AWS config @@ -217,11 +219,13 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { Spec: secretv1beta1.KeeperSpec{ Description: "updated description", Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_ACCESS_KEY_ID_2", - }, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_SECRET_ACCESS_KEY_2", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID_2", + }, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY_2", + }, }, KmsKeyID: ptr.To("kms-key-id-2"), }, @@ -237,8 +241,8 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { // Verify updated AWS config updatedKeeper, err = keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) require.NoError(t, err) - require.Equal(t, "AWS_ACCESS_KEY_ID_2", updatedKeeper.Spec.Aws.AccessKeyID.ValueFromEnv) - require.Equal(t, "AWS_SECRET_ACCESS_KEY_2", updatedKeeper.Spec.Aws.SecretAccessKey.ValueFromEnv) + require.Equal(t, "AWS_ACCESS_KEY_ID_2", updatedKeeper.Spec.Aws.AccessKey.AccessKeyID.ValueFromEnv) + require.Equal(t, "AWS_SECRET_ACCESS_KEY_2", updatedKeeper.Spec.Aws.AccessKey.SecretAccessKey.ValueFromEnv) require.Equal(t, "kms-key-id-2", *updatedKeeper.Spec.Aws.KmsKeyID) }) @@ -278,11 +282,13 @@ func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { Spec: secretv1beta1.KeeperSpec{ Description: "initial description", Aws: &secretv1beta1.KeeperAWSConfig{ - AccessKeyID: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_ACCESS_KEY_ID", - }, - SecretAccessKey: secretv1beta1.KeeperCredentialValue{ - ValueFromEnv: "AWS_SECRET_ACCESS_KEY", + AccessKey: &secretv1beta1.KeeperAWSAccessKey{ + AccessKeyID: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_ACCESS_KEY_ID", + }, + SecretAccessKey: secretv1beta1.KeeperCredentialValue{ + ValueFromEnv: "AWS_SECRET_ACCESS_KEY", + }, }, }, }, diff --git a/pkg/storage/secret/metadata/metrics/metrics.go b/pkg/storage/secret/metadata/metrics/metrics.go index 094a19fa30d..3dce608d7d7 100644 --- a/pkg/storage/secret/metadata/metrics/metrics.go +++ b/pkg/storage/secret/metadata/metrics/metrics.go @@ -1,6 +1,7 @@ package metrics import ( + "context" "errors" "sync" @@ -185,6 +186,8 @@ func DecryptResultLabel(err error) string { return "error_not_found" } else if errors.Is(err, contracts.ErrDecryptNotAuthorized) { return "error_unauthorized" + } else if errors.Is(err, context.Canceled) { + return "error_context_canceled" } return "error_generic_failure" diff --git a/pkg/storage/secret/metadata/secure_value_store_test.go b/pkg/storage/secret/metadata/secure_value_store_test.go index f7551e9e484..9f3a99a2372 100644 --- a/pkg/storage/secret/metadata/secure_value_store_test.go +++ b/pkg/storage/secret/metadata/secure_value_store_test.go @@ -194,12 +194,12 @@ func TestPropertySecureValueMetadataStorage(t *testing.T) { rapid.Check(t, func(t *rapid.T) { sut := testutils.Setup(tt) - model := newModel() + model := testutils.NewModelGsm(nil) t.Repeat(map[string]func(*rapid.T){ "create": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") - modelCreatedSv, modelErr := model.create(sut.Clock.Now(), sv.DeepCopy()) + sv := testutils.AnySecureValueGen.Draw(t, "sv") + modelCreatedSv, modelErr := model.Create(sut.Clock.Now(), sv.DeepCopy()) createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv.DeepCopy())) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -209,10 +209,23 @@ func TestPropertySecureValueMetadataStorage(t *testing.T) { require.Equal(t, modelCreatedSv.Name, createdSv.Name) require.Equal(t, modelCreatedSv.Status.Version, createdSv.Status.Version) }, + "read": func(t *rapid.T) { + ns := testutils.NamespaceGen.Draw(t, "ns") + name := testutils.SecureValueNameGen.Draw(t, "name") + modelSv, modelErr := model.Read(ns, name) + sv, err := sut.SecureValueMetadataStorage.Read(t.Context(), xkube.Namespace(ns), name, contracts.ReadOpts{}) + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + require.Equal(t, modelSv.Namespace, sv.Namespace) + require.Equal(t, modelSv.Name, sv.Name) + require.Equal(t, modelSv.Status.Version, sv.Status.Version) + }, "delete": func(t *rapid.T) { - ns := namespaceGen.Draw(t, "ns") - name := secureValueNameGen.Draw(t, "name") - modelSv, modelErr := model.delete(ns, name) + ns := testutils.NamespaceGen.Draw(t, "ns") + name := testutils.SecureValueNameGen.Draw(t, "name") + modelSv, modelErr := model.Delete(ns, name) sv, err := sut.DeleteSv(t.Context(), ns, name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -227,7 +240,7 @@ func TestPropertySecureValueMetadataStorage(t *testing.T) { minAge := 300 * time.Second leaseTTL := 30 * time.Second maxBatchSize := rapid.Uint16Range(1, 10).Draw(t, "maxBatchSize") - modelSvs, modelErr := model.leaseInactiveSecureValues(sut.Clock.Now(), minAge, leaseTTL, maxBatchSize) + modelSvs, modelErr := model.LeaseInactiveSecureValues(sut.Clock.Now(), minAge, leaseTTL, maxBatchSize) svs, err := sut.SecureValueMetadataStorage.LeaseInactiveSecureValues(t.Context(), maxBatchSize) require.ErrorIs(t, err, modelErr) require.Equal(t, len(modelSvs), len(svs)) diff --git a/pkg/storage/secret/metadata/secure_value_test.go b/pkg/storage/secret/metadata/secure_value_test.go index dafd96fc3ad..e12fa9578ab 100644 --- a/pkg/storage/secret/metadata/secure_value_test.go +++ b/pkg/storage/secret/metadata/secure_value_test.go @@ -1,7 +1,6 @@ package metadata_test import ( - "fmt" "slices" "testing" "time" @@ -12,305 +11,11 @@ import ( "pgregory.net/rapid" secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1" - "github.com/grafana/grafana/apps/secret/pkg/decrypt" "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/registry/apis/secret/testutils" "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" ) -type modelSecureValue struct { - *secretv1beta1.SecureValue - active bool - created time.Time - leaseCreated time.Time -} - -type modelKeeper struct { - namespace string - name string - active bool -} - -// A simplified model of the grafana secrets manager -type model struct { - secureValues []*modelSecureValue - keepers []*modelKeeper -} - -func newModel() *model { - return &model{} -} - -func (m *model) getNewVersionNumber(namespace, name string) int64 { - latestVersion := int64(0) - for _, sv := range m.secureValues { - if sv.Namespace == namespace && sv.Name == name { - latestVersion = max(latestVersion, sv.Status.Version) - } - } - return latestVersion + 1 -} - -func (m *model) setVersionToActive(namespace, name string, version int64) { - for _, sv := range m.secureValues { - if sv.Namespace == namespace && sv.Name == name { - sv.active = sv.Status.Version == version - } - } -} - -func (m *model) setVersionToInactive(namespace, name string, version int64) { - for _, sv := range m.secureValues { - if sv.Namespace == namespace && sv.Name == name && sv.Status.Version == version { - sv.active = false - return - } - } -} - -func (m *model) readActiveVersion(namespace, name string) *modelSecureValue { - for _, sv := range m.secureValues { - if sv.Namespace == namespace && sv.Name == name && sv.active { - return sv - } - } - - return nil -} - -func (m *model) create(now time.Time, sv *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, error) { - keeper := m.getActiveKeeper(sv.Namespace) - sv = sv.DeepCopy() - - // Preserve the original creation time if this secure value already exists - created := now - if sv := m.readActiveVersion(sv.Namespace, sv.Name); sv != nil { - created = sv.created - } - - modelSv := &modelSecureValue{SecureValue: sv, active: false, created: created} - modelSv.Status.Version = m.getNewVersionNumber(modelSv.Namespace, modelSv.Name) - modelSv.Status.ExternalID = fmt.Sprintf("%d", modelSv.Status.Version) - modelSv.Status.Keeper = keeper.name - m.secureValues = append(m.secureValues, modelSv) - m.setVersionToActive(modelSv.Namespace, modelSv.Name, modelSv.Status.Version) - return modelSv.SecureValue, nil -} - -func (m *model) getActiveKeeper(namespace string) *modelKeeper { - for _, k := range m.keepers { - if k.namespace == namespace && k.active { - return k - } - } - - // Default to the system keeper when there are no active keepers in the namespace - return &modelKeeper{namespace: namespace, name: contracts.SystemKeeperName, active: true} -} - -func (m *model) keeperExists(namespace, name string) bool { - return m.findKeeper(namespace, name) != nil -} - -func (m *model) findKeeper(namespace, name string) *modelKeeper { - // The system keeper is not in the list of keepers - if name == contracts.SystemKeeperName { - return &modelKeeper{namespace: namespace, name: contracts.SystemKeeperName, active: true} - } - for _, k := range m.keepers { - if k.namespace == namespace && k.name == name { - return k - } - } - return nil -} - -func (m *model) createKeeper(keeper *secretv1beta1.Keeper) (*secretv1beta1.Keeper, error) { - if m.keeperExists(keeper.Namespace, keeper.Name) { - return nil, contracts.ErrKeeperAlreadyExists - } - - m.keepers = append(m.keepers, &modelKeeper{namespace: keeper.Namespace, name: keeper.Name}) - - return keeper.DeepCopy(), nil -} - -func (m *model) setKeeperAsActive(namespace, keeperName string) error { - keeper := m.findKeeper(namespace, keeperName) - if keeper == nil { - return contracts.ErrKeeperNotFound - } - // Set the keeper as active - keeper.active = true - - // Set every other keeper in the namespace as inactive - for _, k := range m.keepers { - if k.namespace == namespace && k.name != keeperName { - k.active = false - } - } - - return nil -} - -func (m *model) update(now time.Time, newSecureValue *secretv1beta1.SecureValue) (*secretv1beta1.SecureValue, bool, error) { - sv := m.readActiveVersion(newSecureValue.Namespace, newSecureValue.Name) - if sv == nil { - return nil, false, contracts.ErrSecureValueNotFound - } - - // If the keeper doesn't exist, return an error - if !m.keeperExists(sv.Namespace, sv.Status.Keeper) { - return nil, false, contracts.ErrKeeperNotFound - } - - // If the payload doesn't contain a value, get the value from current version - if newSecureValue.Spec.Value == nil { - newSecureValue.Spec.Value = sv.Spec.Value - } - - createdSv, err := m.create(now, newSecureValue) - - return createdSv, true, err -} - -func (m *model) delete(namespace, name string) (*secretv1beta1.SecureValue, error) { - modelSv := m.readActiveVersion(namespace, name) - if modelSv == nil { - return nil, contracts.ErrSecureValueNotFound - } - m.setVersionToInactive(namespace, name, modelSv.Status.Version) - return modelSv.SecureValue, nil -} - -func (m *model) list(namespace string) (*secretv1beta1.SecureValueList, error) { - out := make([]secretv1beta1.SecureValue, 0) - - for _, v := range m.secureValues { - if v.Namespace == namespace && v.active { - out = append(out, *v.SecureValue) - } - } - - return &secretv1beta1.SecureValueList{Items: out}, nil -} - -func (m *model) decrypt(decrypter, namespace, name string) (map[string]decrypt.DecryptResult, error) { - for _, v := range m.secureValues { - if v.Namespace == namespace && - v.Name == name && - v.active { - if slices.ContainsFunc(v.Spec.Decrypters, func(d string) bool { return d == decrypter }) { - return map[string]decrypt.DecryptResult{ - name: decrypt.NewDecryptResultValue(v.DeepCopy().Spec.Value), - }, nil - } - - return map[string]decrypt.DecryptResult{ - name: decrypt.NewDecryptResultErr(contracts.ErrDecryptNotAuthorized), - }, nil - } - } - return map[string]decrypt.DecryptResult{ - name: decrypt.NewDecryptResultErr(contracts.ErrDecryptNotFound), - }, nil -} - -func (m *model) read(namespace, name string) (*secretv1beta1.SecureValue, error) { - modelSv := m.readActiveVersion(namespace, name) - if modelSv == nil { - return nil, contracts.ErrSecureValueNotFound - } - return modelSv.SecureValue, nil -} - -func (m *model) leaseInactiveSecureValues(now time.Time, minAge, leaseTTL time.Duration, maxBatchSize uint16) ([]*modelSecureValue, error) { - out := make([]*modelSecureValue, 0) - - for _, sv := range m.secureValues { - if len(out) >= int(maxBatchSize) { - break - } - if !sv.active && now.Sub(sv.created) > minAge && now.Sub(sv.leaseCreated) > leaseTTL { - sv.leaseCreated = now - out = append(out, sv) - } - } - - return out, nil -} - -var ( - decryptersGen = rapid.SampledFrom([]string{"svc1", "svc2", "svc3", "svc4", "svc5"}) - secureValueNameGen = rapid.SampledFrom([]string{"n1", "n2", "n3", "n4", "n5"}) - keeperNameGen = rapid.SampledFrom([]string{"k1", "k2", "k3", "k4", "k5"}) - namespaceGen = rapid.SampledFrom([]string{"ns1", "ns2", "ns3", "ns4", "ns5"}) - anySecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { - return &secretv1beta1.SecureValue{ - ObjectMeta: metav1.ObjectMeta{ - Name: secureValueNameGen.Draw(t, "name"), - Namespace: namespaceGen.Draw(t, "ns"), - }, - Spec: secretv1beta1.SecureValueSpec{ - Description: rapid.SampledFrom([]string{"d1", "d2", "d3", "d4", "d5"}).Draw(t, "description"), - Value: ptr.To(secretv1beta1.NewExposedSecureValue(rapid.SampledFrom([]string{"v1", "v2", "v3", "v4", "v5"}).Draw(t, "value"))), - Decrypters: rapid.SliceOfDistinct(decryptersGen, func(v string) string { return v }).Draw(t, "decrypters"), - }, - Status: secretv1beta1.SecureValueStatus{}, - } - }) - updateSecureValueGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.SecureValue { - sv := anySecureValueGen.Draw(t, "sv") - // Maybe update the secret value, maybe not - if !rapid.Bool().Draw(t, "should_update_value") { - sv.Spec.Value = nil - } - return sv - }) - // Any secure value will do - deleteSecureValueGen = anySecureValueGen - decryptGen = rapid.Custom(func(t *rapid.T) decryptInput { - return decryptInput{ - namespace: namespaceGen.Draw(t, "ns"), - name: secureValueNameGen.Draw(t, "name"), - decrypter: decryptersGen.Draw(t, "decrypter"), - } - }) - anyKeeperGen = rapid.Custom(func(t *rapid.T) *secretv1beta1.Keeper { - spec := secretv1beta1.KeeperSpec{ - Description: rapid.String().Draw(t, "description"), - } - - keeperType := rapid.SampledFrom([]string{"isAwsKeeper", "isAzureKeeper", "isGcpKeeper", "isVaultKeeper"}).Draw(t, "keeperType") - switch keeperType { - case "isAwsKeeper": - spec.Aws = &secretv1beta1.KeeperAWSConfig{} - case "isAzureKeeper": - spec.Azure = &secretv1beta1.KeeperAzureConfig{} - case "isGcpKeeper": - spec.Gcp = &secretv1beta1.KeeperGCPConfig{} - case "isVaultKeeper": - spec.HashiCorpVault = &secretv1beta1.KeeperHashiCorpConfig{} - default: - panic(fmt.Sprintf("unhandled keeper type '%+v', did you forget a switch case?", keeperType)) - } - - return &secretv1beta1.Keeper{ - ObjectMeta: metav1.ObjectMeta{ - Name: keeperNameGen.Draw(t, "name"), - Namespace: namespaceGen.Draw(t, "ns"), - }, - Spec: spec, - } - }) -) - -type decryptInput struct { - namespace string - name string - decrypter string -} - func TestModel(t *testing.T) { t.Parallel() @@ -330,18 +35,18 @@ func TestModel(t *testing.T) { t.Run("creating secure values", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() // Create a secure value - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv1.Namespace) require.Equal(t, sv.Name, sv1.Name) require.EqualValues(t, 1, sv1.Status.Version) // Create a new version of a secure value - sv2, err := m.create(now, sv.DeepCopy()) + sv2, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv2.Namespace) require.Equal(t, sv.Name, sv2.Name) @@ -351,15 +56,15 @@ func TestModel(t *testing.T) { t.Run("updating secure values", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) // Create a new version of a secure value by updating it - sv2, _, err := m.update(now, sv1.DeepCopy()) + sv2, _, err := m.Update(now, sv1.DeepCopy()) require.NoError(t, err) require.Equal(t, sv.Namespace, sv2.Namespace) require.Equal(t, sv.Name, sv2.Name) @@ -369,55 +74,55 @@ func TestModel(t *testing.T) { sv3 := sv2.DeepCopy() sv3.Name = "i_dont_exist" sv3.Spec.Value = nil - _, _, err = m.update(now, sv3) + _, _, err = m.Update(now, sv3) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) // Updating a value that doesn't exist creates a new version sv4 := sv3.DeepCopy() sv4.Name = "i_dont_exist" sv4.Spec.Value = ptr.To(secretv1beta1.NewExposedSecureValue("sv4")) - _, _, err = m.update(now, sv4) + _, _, err = m.Update(now, sv4) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) }) t.Run("deleting a secure value", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) // Deleting a secure value - deletedSv, err := m.delete(sv1.Namespace, sv1.Name) + deletedSv, err := m.Delete(sv1.Namespace, sv1.Name) require.NoError(t, err) require.Equal(t, sv1.Namespace, deletedSv.Namespace) require.Equal(t, sv1.Name, deletedSv.Name) require.EqualValues(t, sv1.Status.Version, deletedSv.Status.Version) // Deleting a secure value that doesn't exist results in an error - _, err = m.delete(sv1.Namespace, sv1.Name) + _, err = m.Delete(sv1.Namespace, sv1.Name) require.ErrorIs(t, err, contracts.ErrSecureValueNotFound) }) t.Run("listing secure values", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() // No secure values exist yet - list, err := m.list(sv.Namespace) + list, err := m.List(sv.Namespace) require.NoError(t, err) require.Equal(t, 0, len(list.Items)) // Create a secure value - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) // 1 secure value exists and it should be returned - list, err = m.list(sv.Namespace) + list, err = m.List(sv.Namespace) require.NoError(t, err) require.Equal(t, 1, len(list.Items)) require.Equal(t, sv1.Namespace, list.Items[0].Namespace) @@ -428,11 +133,11 @@ func TestModel(t *testing.T) { t.Run("decrypting secure values", func(t *testing.T) { t.Parallel() - m := newModel() + m := testutils.NewModelGsm(nil) now := time.Now() // Decrypting a secure value that does not exist - result, err := m.decrypt("decrypter", "namespace", "name") + result, err := m.Decrypt(t.Context(), "decrypter", "namespace", "name") require.NoError(t, err) require.Equal(t, 1, len(result)) require.Nil(t, result["name"].Value()) @@ -440,16 +145,62 @@ func TestModel(t *testing.T) { // Create a secure value secret := "v1" - sv1, err := m.create(now, sv.DeepCopy()) + sv1, err := m.Create(now, sv.DeepCopy()) require.NoError(t, err) // Decrypt the just created secure value - result, err = m.decrypt(sv1.Spec.Decrypters[0], sv1.Namespace, sv1.Name) + result, err = m.Decrypt(t.Context(), sv1.Spec.Decrypters[0], sv1.Namespace, sv1.Name) require.NoError(t, err) require.Equal(t, 1, len(result)) require.Nil(t, result[sv1.Name].Error()) require.Equal(t, secret, result[sv1.Name].Value().DangerouslyExposeAndConsumeValue()) }) + + t.Run("decrypting with reference", func(t *testing.T) { + t.Parallel() + + secretsManager := testutils.NewModelSecretsManager() + m := testutils.NewModelGsm(secretsManager) + now := time.Now() + + keeper, err := m.CreateKeeper(&secretv1beta1.Keeper{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "k1", + }, + Spec: secretv1beta1.KeeperSpec{ + Aws: &secretv1beta1.KeeperAWSConfig{}, + }, + }) + require.NoError(t, err) + require.NoError(t, m.SetKeeperAsActive(keeper.Namespace, keeper.Name)) + + // Store the secret on the 3rd party secrets store + secret := "v1" + secretsManager.Create("ref1", secret) + + // Create a secure value that references the secret on the 3rd party secret store + sv, err := m.Create(now, &secretv1beta1.SecureValue{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sv1", + Namespace: "ns1", + }, + Spec: secretv1beta1.SecureValueSpec{ + Description: "desc1", + Ref: ptr.To("ref1"), + Decrypters: []string{"decrypter1"}, + }, + Status: secretv1beta1.SecureValueStatus{}, + }) + require.NoError(t, err) + + // Decrypt the just created secure value + result, err := m.Decrypt(t.Context(), sv.Spec.Decrypters[0], sv.Namespace, sv.Name) + require.NoError(t, err) + require.Equal(t, 1, len(result)) + require.Nil(t, result[sv.Name].Error()) + require.Equal(t, secret, result[sv.Name].Value().DangerouslyExposeAndConsumeValue()) + }) } func TestStateMachine(t *testing.T) { @@ -459,14 +210,13 @@ func TestStateMachine(t *testing.T) { rapid.Check(t, func(t *rapid.T) { sut := testutils.Setup(tt) - model := newModel() + model := testutils.NewModelGsm(sut.ModelSecretsManager) t.Repeat(map[string]func(*rapid.T){ - "create": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") - - modelCreatedSv, modelErr := model.create(sut.Clock.Now(), sv.DeepCopy()) + "createSecureValueWithSecretValue": func(t *rapid.T) { + sv := testutils.AnySecureValueGen.Draw(t, "sv") + modelCreatedSv, modelErr := model.Create(sut.Clock.Now(), sv.DeepCopy()) createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv.DeepCopy())) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -476,9 +226,27 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelCreatedSv.Name, createdSv.Name) require.Equal(t, modelCreatedSv.Status.Version, createdSv.Status.Version) }, + "createSecureValueWithRef": func(t *rapid.T) { + sv := testutils.AnySecureValueWithRefGen.Draw(t, "sv") + + modelCreatedSv, modelErr := model.Create(sut.Clock.Now(), sv.DeepCopy()) + createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv.DeepCopy())) + if err != nil || modelErr != nil { + require.ErrorIs(t, err, modelErr) + return + } + require.Equal(t, modelCreatedSv.Namespace, createdSv.Namespace) + require.Equal(t, modelCreatedSv.Name, createdSv.Name) + require.Equal(t, modelCreatedSv.Status.Version, createdSv.Status.Version) + }, + "createSecretOn3rdPartyKeeper": func(t *rapid.T) { + name := testutils.SecretsToRefGen.Draw(t, "name") + value := rapid.String().Draw(t, "value") + sut.ModelSecretsManager.Create(name, value) + }, "update": func(t *rapid.T) { - sv := updateSecureValueGen.Draw(t, "sv") - modelCreatedSv, _, modelErr := model.update(sut.Clock.Now(), sv.DeepCopy()) + sv := testutils.UpdateSecureValueGen.Draw(t, "sv") + modelCreatedSv, _, modelErr := model.Update(sut.Clock.Now(), sv.DeepCopy()) createdSv, err := sut.UpdateSv(t.Context(), sv.DeepCopy()) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -489,9 +257,10 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelCreatedSv.Status.Version, createdSv.Status.Version) }, "delete": func(t *rapid.T) { - sv := deleteSecureValueGen.Draw(t, "sv") - modelSv, modelErr := model.delete(sv.Namespace, sv.Name) - deletedSv, err := sut.DeleteSv(t.Context(), sv.Namespace, sv.Name) + ns := testutils.NamespaceGen.Draw(t, "ns") + name := testutils.SecureValueNameGen.Draw(t, "name") + modelSv, modelErr := model.Delete(ns, name) + deletedSv, err := sut.DeleteSv(t.Context(), ns, name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -501,12 +270,12 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelSv.Status.Version, deletedSv.Status.Version) }, "list": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") - authCtx := testutils.CreateUserAuthContext(t.Context(), sv.Namespace, map[string][]string{ + ns := testutils.NamespaceGen.Draw(t, "ns") + authCtx := testutils.CreateUserAuthContext(t.Context(), ns, map[string][]string{ "securevalues:read": {"securevalues:uid:*"}, }) - modelList, modelErr := model.list(sv.Namespace) - list, err := sut.SecureValueService.List(authCtx, xkube.Namespace(sv.Namespace)) + modelList, modelErr := model.List(ns) + list, err := sut.SecureValueService.List(authCtx, xkube.Namespace(ns)) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -525,9 +294,10 @@ func TestStateMachine(t *testing.T) { } }, "get": func(t *rapid.T) { - sv := anySecureValueGen.Draw(t, "sv") - modelSv, modelErr := model.read(sv.Namespace, sv.Name) - readSv, err := sut.SecureValueService.Read(t.Context(), xkube.Namespace(sv.Namespace), sv.Name) + ns := testutils.NamespaceGen.Draw(t, "ns") + name := testutils.SecureValueNameGen.Draw(t, "name") + modelSv, modelErr := model.Read(ns, name) + readSv, err := sut.SecureValueService.Read(t.Context(), xkube.Namespace(ns), name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -537,9 +307,9 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelSv.Status.Version, readSv.Status.Version) }, "decrypt": func(t *rapid.T) { - input := decryptGen.Draw(t, "decryptInput") - modelResult, modelErr := model.decrypt(input.decrypter, input.namespace, input.name) - result, err := sut.DecryptService.Decrypt(t.Context(), input.decrypter, input.namespace, input.name) + input := testutils.DecryptGen.Draw(t, "decryptInput") + modelResult, modelErr := model.Decrypt(t.Context(), input.Decrypter, input.Namespace, input.Name) + result, err := sut.DecryptService.Decrypt(t.Context(), input.Decrypter, input.Namespace, input.Name) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) return @@ -547,13 +317,13 @@ func TestStateMachine(t *testing.T) { require.Equal(t, len(modelResult), len(result)) for name := range modelResult { - require.Equal(t, modelResult[name].Error(), result[name].Error()) + require.ErrorIs(t, modelResult[name].Error(), result[name].Error()) require.Equal(t, modelResult[name].Value(), result[name].Value()) } }, "createKeeper": func(t *rapid.T) { - input := anyKeeperGen.Draw(t, "keeper") - modelKeeper, modelErr := model.createKeeper(input) + input := testutils.AnyKeeperGen.Draw(t, "keeper") + modelKeeper, modelErr := model.CreateKeeper(input) keeper, err := sut.KeeperMetadataStorage.Create(t.Context(), input, "actor-uid") if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) @@ -562,9 +332,14 @@ func TestStateMachine(t *testing.T) { require.Equal(t, modelKeeper.Name, keeper.Name) }, "setKeeperAsActive": func(t *rapid.T) { - namespace := namespaceGen.Draw(t, "namespace") - keeper := keeperNameGen.Draw(t, "keeper") - modelErr := model.setKeeperAsActive(namespace, keeper) + namespace := testutils.NamespaceGen.Draw(t, "namespace") + var keeper string + if rapid.Bool().Draw(t, "systemKeeper") { + keeper = contracts.SystemKeeperName + } else { + keeper = testutils.KeeperNameGen.Draw(t, "keeper") + } + modelErr := model.SetKeeperAsActive(namespace, keeper) err := sut.KeeperMetadataStorage.SetAsActive(t.Context(), xkube.Namespace(namespace), keeper) if err != nil || modelErr != nil { require.ErrorIs(t, err, modelErr) diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index e9bdbf88e37..9cd0d1fd01d 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -236,7 +236,6 @@ kubernetesDashboards = true kubernetesFolders = true unifiedStorage = true unifiedStorageHistoryPruner = true -unifiedStorageSearch = true unifiedStorageSearchPermissionFiltering = false unifiedStorageSearchSprinkles = false @@ -293,15 +292,15 @@ overrides_path = overrides.yaml overrides_reload_period = 5s ``` -To overrides the default quota for a tenant, add the following to the overrides.yaml file: +To override the default quota for a tenant, add the following to the `overrides.yaml` file: ```yaml overrides: : quotas: - .: + /: limit: 10 ``` -Unless otherwise set, the NAMESPACE when running locally is `default`. +Unless otherwise set, the `NAMESPACE` when running locally is `default`. To access quotas, use the following API endpoint: ``` @@ -806,8 +805,10 @@ flowchart TD #### Setting Dual Writer Mode ```ini -[unified_storage.{resource}.{kind}.{group}] -dualWriterMode = {0-5} +; [unified_storage.{resource}.{group}] +[unified_storage.dashboards.dashboard.grafana.app] +; modes {0-5} +dualWriterMode = 0 ``` #### Background Sync Configuration @@ -1376,4 +1377,3 @@ disable_data_migrations = false ### Documentation For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md). - \ No newline at end of file diff --git a/pkg/storage/unified/migrations/migrator_test.go b/pkg/storage/unified/migrations/migrator_test.go index 8552092d066..cfd909a9255 100644 --- a/pkg/storage/unified/migrations/migrator_test.go +++ b/pkg/storage/unified/migrations/migrator_test.go @@ -214,8 +214,18 @@ func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) { for _, state := range testStates { t.Run(state.tc.name(), func(t *testing.T) { - // Verify resources now exist in unified storage after migration - state.tc.verify(t, helper, true) + shouldExist := true + for _, gvr := range state.tc.resources() { + resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group) + // Resources exist if they're either: + // 1. In MigratedUnifiedResources (enabled by default), OR + // 2. In AutoMigratedUnifiedResources (auto-migrated because count is below threshold) + if !setting.MigratedUnifiedResources[resourceKey] && !setting.AutoMigratedUnifiedResources[resourceKey] { + shouldExist = false + break + } + } + state.tc.verify(t, helper, shouldExist) }) } @@ -270,7 +280,7 @@ const ( var migrationIDsToDefault = map[string]bool{ playlistsID: true, - foldersAndDashboardsID: false, + foldersAndDashboardsID: true, // Auto-migrated when resource count is below threshold } func verifyRegisteredMigrations(t *testing.T, helper *apis.K8sTestHelper, onlyDefault bool, optOut bool) { diff --git a/pkg/storage/unified/migrations/resource_migration.go b/pkg/storage/unified/migrations/resource_migration.go index 5bbed9c5722..d551ae32bde 100644 --- a/pkg/storage/unified/migrations/resource_migration.go +++ b/pkg/storage/unified/migrations/resource_migration.go @@ -10,9 +10,11 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util/xorm" + "github.com/grafana/grafana/pkg/util/xorm/core" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -31,6 +33,20 @@ type ResourceMigration struct { migrationID string validators []Validator // Optional: custom validation logic for this migration log log.Logger + cfg *setting.Cfg + autoMigrate bool // If true, auto-migrate resource if count is below threshold + hadErrors bool // Tracks if errors occurred during migration (used with ignoreErrors) +} + +// ResourceMigrationOption is a functional option for configuring ResourceMigration. +type ResourceMigrationOption func(*ResourceMigration) + +// WithAutoMigrate configures the migration to auto-migrate resource if count is below threshold. +func WithAutoMigrate(cfg *setting.Cfg) ResourceMigrationOption { + return func(m *ResourceMigration) { + m.cfg = cfg + m.autoMigrate = true + } } // NewResourceMigration creates a new migration for the specified resources. @@ -39,14 +55,24 @@ func NewResourceMigration( resources []schema.GroupResource, migrationID string, validators []Validator, + opts ...ResourceMigrationOption, ) *ResourceMigration { - return &ResourceMigration{ + m := &ResourceMigration{ migrator: migrator, resources: resources, migrationID: migrationID, validators: validators, log: log.New("storage.unified.resource_migration." + migrationID), } + for _, opt := range opts { + opt(m) + } + return m +} + +func (m *ResourceMigration) SkipMigrationLog() bool { + // Skip populating the log table if auto-migrate is enabled and errors occurred + return m.autoMigrate && m.hadErrors } var _ migrator.CodeMigration = (*ResourceMigration)(nil) @@ -57,7 +83,23 @@ func (m *ResourceMigration) SQL(_ migrator.Dialect) string { } // Exec implements migrator.CodeMigration interface. Executes the migration across all organizations. -func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { +func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) (err error) { + // Track any errors that occur during migration + defer func() { + if err != nil { + if m.autoMigrate { + m.log.Warn( + `[WARN] Resource migration failed and is currently skipped. +This migration will be enforced in the next major Grafana release, where failures will block startup or resource loading. + +This warning is intended to help you detect and report issues early. +Please investigate the failure and report it to the Grafana team so it can be addressed before the next major release.`, + "error", err) + } + m.hadErrors = true + } + }() + ctx := context.Background() orgs, err := m.getAllOrgs(sess) @@ -75,7 +117,8 @@ func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) erro if mg.Dialect.DriverName() == migrator.SQLite { // reuse transaction in SQLite to avoid "database is locked" errors - tx, err := sess.Tx() + var tx *core.Tx + tx, err = sess.Tx() if err != nil { m.log.Error("Failed to get transaction from session", "error", err) return fmt.Errorf("failed to get transaction: %w", err) @@ -85,12 +128,22 @@ func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) erro } for _, org := range orgs { - if err := m.migrateOrg(ctx, sess, org); err != nil { + if err = m.migrateOrg(ctx, sess, org); err != nil { return err } } + // Auto-enable mode 5 for resources after successful migration + // TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 + if m.autoMigrate { + for _, gr := range m.resources { + m.log.Info("Auto-enabling mode 5 for resource", "resource", gr.Resource+"."+gr.Group) + m.cfg.EnableMode5(gr.Resource + "." + gr.Group) + } + } + m.log.Info("Migration completed successfully for all organizations", "org_count", len(orgs)) + return nil } diff --git a/pkg/storage/unified/migrations/resources.go b/pkg/storage/unified/migrations/resources.go index ea32bb54322..debbb270642 100644 --- a/pkg/storage/unified/migrations/resources.go +++ b/pkg/storage/unified/migrations/resources.go @@ -1,11 +1,13 @@ package migrations import ( + "context" "fmt" v1beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" playlists "github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" @@ -14,69 +16,70 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) -type ResourceDefinition struct { - GroupResource schema.GroupResource - MigratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc. +type resourceDefinition struct { + groupResource schema.GroupResource + migratorFunc string // Name of the method: "MigrateFolders", "MigrateDashboards", etc. } type migrationDefinition struct { name string + migrationID string // The ID stored in the migration log table (e.g., "playlists migration") resources []string - registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) + registerFunc func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) } -var resourceRegistry = []ResourceDefinition{ +var resourceRegistry = []resourceDefinition{ { - GroupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE}, - MigratorFunc: "MigrateFolders", + groupResource: schema.GroupResource{Group: folders.GROUP, Resource: folders.RESOURCE}, + migratorFunc: "MigrateFolders", }, { - GroupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.LIBRARY_PANEL_RESOURCE}, - MigratorFunc: "MigrateLibraryPanels", + groupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.LIBRARY_PANEL_RESOURCE}, + migratorFunc: "MigrateLibraryPanels", }, { - GroupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.DASHBOARD_RESOURCE}, - MigratorFunc: "MigrateDashboards", + groupResource: schema.GroupResource{Group: v1beta1.GROUP, Resource: v1beta1.DASHBOARD_RESOURCE}, + migratorFunc: "MigrateDashboards", }, { - GroupResource: schema.GroupResource{Group: playlists.APIGroup, Resource: "playlists"}, - MigratorFunc: "MigratePlaylists", + groupResource: schema.GroupResource{Group: playlists.APIGroup, Resource: "playlists"}, + migratorFunc: "MigratePlaylists", }, } var migrationRegistry = []migrationDefinition{ { name: "playlists", + migrationID: "playlists migration", resources: []string{setting.PlaylistResource}, registerFunc: registerPlaylistMigration, }, { name: "folders and dashboards", + migrationID: "folders and dashboards migration", resources: []string{setting.FolderResource, setting.DashboardResource}, registerFunc: registerDashboardAndFolderMigration, }, } -func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) error { +func registerMigrations(ctx context.Context, + cfg *setting.Cfg, + mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + sqlStore db.DB, +) error { for _, migration := range migrationRegistry { - var ( - hasValue bool - allEnabled bool - ) - - for _, res := range migration.resources { - enabled := cfg.UnifiedStorage[res].EnableMigration - if !hasValue { - allEnabled = enabled - hasValue = true - continue - } - if enabled != allEnabled { - return fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources) - } + if shouldAutoMigrate(ctx, migration, cfg, sqlStore) { + migration.registerFunc(mg, migrator, client, WithAutoMigrate(cfg)) + continue } - if !allEnabled { + enabled, err := isMigrationEnabled(migration, cfg) + if err != nil { + return err + } + if !enabled { logger.Info("Migration is disabled in config, skipping", "migration", migration.name) continue } @@ -85,10 +88,193 @@ func registerMigrations(cfg *setting.Cfg, mg *sqlstoremigrator.Migrator, migrato return nil } -func getResourceDefinition(group, resource string) *ResourceDefinition { +func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + opts ...ResourceMigrationOption, +) { + foldersDef := getResourceDefinition("folder.grafana.app", "folders") + dashboardsDef := getResourceDefinition("dashboard.grafana.app", "dashboards") + driverName := mg.Dialect.DriverName() + + folderCountValidator := NewCountValidator( + client, + foldersDef.groupResource, + "dashboard", + "org_id = ? and is_folder = true", + driverName, + ) + + dashboardCountValidator := NewCountValidator( + client, + dashboardsDef.groupResource, + "dashboard", + "org_id = ? and is_folder = false", + driverName, + ) + + folderTreeValidator := NewFolderTreeValidator(client, foldersDef.groupResource, driverName) + + dashboardsAndFolders := NewResourceMigration( + migrator, + []schema.GroupResource{foldersDef.groupResource, dashboardsDef.groupResource}, + "folders-dashboards", + []Validator{folderCountValidator, dashboardCountValidator, folderTreeValidator}, + opts..., + ) + mg.AddMigration("folders and dashboards migration", dashboardsAndFolders) +} + +func registerPlaylistMigration(mg *sqlstoremigrator.Migrator, + migrator UnifiedMigrator, + client resource.ResourceClient, + opts ...ResourceMigrationOption, +) { + playlistsDef := getResourceDefinition("playlist.grafana.app", "playlists") + driverName := mg.Dialect.DriverName() + + playlistCountValidator := NewCountValidator( + client, + playlistsDef.groupResource, + "playlist", + "org_id = ?", + driverName, + ) + + playlistsMigration := NewResourceMigration( + migrator, + []schema.GroupResource{playlistsDef.groupResource}, + "playlists", + []Validator{playlistCountValidator}, + opts..., + ) + mg.AddMigration("playlists migration", playlistsMigration) +} + +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +func shouldAutoMigrate(ctx context.Context, migration migrationDefinition, cfg *setting.Cfg, sqlStore db.DB) bool { + autoMigrate := false + + for _, res := range migration.resources { + config := cfg.UnifiedStorageConfig(res) + + if config.DualWriterMode == 5 { + return false + } + + if !setting.AutoMigratedUnifiedResources[res] { + continue + } + + if checkIfAlreadyMigrated(ctx, migration, sqlStore) { + for _, res := range migration.resources { + cfg.EnableMode5(res) + } + logger.Info("Auto-migration already completed, enabling mode 5 for resources", "migration", migration.name) + return true + } + + autoMigrate = true + threshold := int64(setting.DefaultAutoMigrationThreshold) + if config.AutoMigrationThreshold > 0 { + threshold = int64(config.AutoMigrationThreshold) + } + + count, err := countResource(ctx, sqlStore, res) + if err != nil { + logger.Warn("Failed to count resource for auto migration check", "resource", res, "error", err) + return false + } + + logger.Info("Resource count for auto migration check", "resource", res, "count", count, "threshold", threshold) + + if count > threshold { + return false + } + } + + if !autoMigrate { + return false + } + + logger.Info("Auto-migration enabled for migration", "migration", migration.name) + return true +} + +func checkIfAlreadyMigrated(ctx context.Context, migration migrationDefinition, sqlStore db.DB) bool { + if migration.migrationID == "" { + return false + } + + exists, err := migrationExists(ctx, sqlStore, migration.migrationID) + if err != nil { + logger.Warn("Failed to check if migration exists", "migration", migration.name, "error", err) + return false + } + + return exists +} + +func isMigrationEnabled(migration migrationDefinition, cfg *setting.Cfg) (bool, error) { + var ( + hasValue bool + allEnabled bool + ) + + for _, res := range migration.resources { + enabled := cfg.UnifiedStorage[res].EnableMigration + if !hasValue { + allEnabled = enabled + hasValue = true + continue + } + if enabled != allEnabled { + return false, fmt.Errorf("cannot migrate resources separately: %v migration must be either all enabled or all disabled", migration.resources) + } + } + + return allEnabled, nil +} + +// TODO: remove this before Grafana 13 GA: https://github.com/grafana/search-and-storage-team/issues/613 +func countResource(ctx context.Context, sqlStore db.DB, resourceName string) (int64, error) { + var count int64 + err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + switch resourceName { + case setting.DashboardResource: + var err error + count, err = sess.Table("dashboard").Where("is_folder = ?", false).Count() + return err + case setting.FolderResource: + var err error + count, err = sess.Table("dashboard").Where("is_folder = ?", true).Count() + return err + default: + return fmt.Errorf("unknown resource: %s", resourceName) + } + }) + return count, err +} + +const migrationLogTableName = "unifiedstorage_migration_log" + +func migrationExists(ctx context.Context, sqlStore db.DB, migrationID string) (bool, error) { + var count int64 + err := sqlStore.WithDbSession(ctx, func(sess *db.Session) error { + var err error + count, err = sess.Table(migrationLogTableName).Where("migration_id = ?", migrationID).Count() + return err + }) + if err != nil { + return false, fmt.Errorf("failed to check migration existence: %w", err) + } + return count > 0, nil +} + +func getResourceDefinition(group, resource string) *resourceDefinition { for i := range resourceRegistry { r := &resourceRegistry[i] - if r.GroupResource.Group == group && r.GroupResource.Resource == resource { + if r.groupResource.Group == group && r.groupResource.Resource == resource { return r } } @@ -102,8 +288,8 @@ func buildResourceKey(group, resource, namespace string) *resourcepb.ResourceKey } return &resourcepb.ResourceKey{ Namespace: namespace, - Group: def.GroupResource.Group, - Resource: def.GroupResource.Resource, + Group: def.groupResource.Group, + Resource: def.groupResource.Resource, } } @@ -113,7 +299,7 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource return nil } - switch def.MigratorFunc { + switch def.migratorFunc { case "MigrateFolders": return accessor.MigrateFolders case "MigrateLibraryPanels": @@ -130,7 +316,7 @@ func getMigratorFunc(accessor legacy.MigrationDashboardAccessor, group, resource func validateRegisteredResources() error { registeredMap := make(map[string]bool) for _, gr := range resourceRegistry { - key := fmt.Sprintf("%s.%s", gr.GroupResource.Resource, gr.GroupResource.Group) + key := fmt.Sprintf("%s.%s", gr.groupResource.Resource, gr.groupResource.Group) registeredMap[key] = true } diff --git a/pkg/storage/unified/migrations/resources_test.go b/pkg/storage/unified/migrations/resources_test.go index 5a519e9ad62..261cd49e244 100644 --- a/pkg/storage/unified/migrations/resources_test.go +++ b/pkg/storage/unified/migrations/resources_test.go @@ -1,12 +1,15 @@ package migrations import ( + "context" + "strings" "testing" sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime/schema" ) // TestRegisterMigrations exercises registerMigrations with various EnableMigration configs using a table-driven test. @@ -14,20 +17,28 @@ func TestRegisterMigrations(t *testing.T) { origRegistry := migrationRegistry t.Cleanup(func() { migrationRegistry = origRegistry }) + // Use fake resource names that are NOT in setting.AutoMigratedUnifiedResources + // to avoid triggering the auto-migrate code path which requires a non-nil sqlStore. + const ( + fakePlaylistResource = "fake.playlists.resource" + fakeFolderResource = "fake.folders.resource" + fakeDashboardResource = "fake.dashboards.resource" + ) + // helper to build a fake registry with custom register funcs that bump counters makeFakeRegistry := func(migrationCalls map[string]int) []migrationDefinition { return []migrationDefinition{ { name: "playlists", - resources: []string{setting.PlaylistResource}, - registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { + resources: []string{fakePlaylistResource}, + registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) { migrationCalls["playlists"]++ }, }, { name: "folders and dashboards", - resources: []string{setting.FolderResource, setting.DashboardResource}, - registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { + resources: []string{fakeFolderResource, fakeDashboardResource}, + registerFunc: func(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient, opts ...ResourceMigrationOption) { migrationCalls["folders and dashboards"]++ }, }, @@ -38,7 +49,9 @@ func TestRegisterMigrations(t *testing.T) { makeCfg := func(vals map[string]bool) *setting.Cfg { cfg := &setting.Cfg{UnifiedStorage: make(map[string]setting.UnifiedStorageConfig)} for k, v := range vals { - cfg.UnifiedStorage[k] = setting.UnifiedStorageConfig{EnableMigration: v} + cfg.UnifiedStorage[k] = setting.UnifiedStorageConfig{ + EnableMigration: v, + } } return cfg } @@ -71,13 +84,13 @@ func TestRegisterMigrations(t *testing.T) { migrationRegistry = makeFakeRegistry(migrationCalls) cfg := makeCfg(map[string]bool{ - setting.PlaylistResource: tt.enablePlaylist, - setting.FolderResource: tt.enableFolder, - setting.DashboardResource: tt.enableDashboard, + fakePlaylistResource: tt.enablePlaylist, + fakeFolderResource: tt.enableFolder, + fakeDashboardResource: tt.enableDashboard, }) // We pass nils for migrator dependencies because our fake registerFuncs don't use them - err := registerMigrations(cfg, nil, nil, nil) + err := registerMigrations(context.Background(), cfg, nil, nil, nil, nil) if tt.wantErr { require.Error(t, err, "expected error for mismatched enablement") @@ -90,3 +103,176 @@ func TestRegisterMigrations(t *testing.T) { }) } } + +// TestResourceMigration_AutoMigrateEnablesMode5 verifies the autoMigrate behavior: +// - When autoMigrate=true AND cfg is set AND storage type is "unified", mode 5 should be enabled +// - In all other cases, mode 5 should NOT be enabled +func TestResourceMigration_AutoMigrateEnablesMode5(t *testing.T) { + // Helper to create a cfg with unified storage type + makeUnifiedCfg := func() *setting.Cfg { + cfg := setting.NewCfg() + cfg.Raw.Section("grafana-apiserver").Key("storage_type").SetValue("unified") + cfg.UnifiedStorage = make(map[string]setting.UnifiedStorageConfig) + return cfg + } + + // Helper to create a cfg with legacy storage type + makeLegacyCfg := func() *setting.Cfg { + cfg := setting.NewCfg() + cfg.Raw.Section("grafana-apiserver").Key("storage_type").SetValue("legacy") + cfg.UnifiedStorage = make(map[string]setting.UnifiedStorageConfig) + return cfg + } + + tests := []struct { + name string + autoMigrate bool + cfg *setting.Cfg + resources []string + wantMode5Enabled bool + description string + }{ + { + name: "autoMigrate enabled with unified storage", + autoMigrate: true, + cfg: makeUnifiedCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: true, + description: "Should enable mode 5 when autoMigrate=true and storage type is unified", + }, + { + name: "autoMigrate disabled with unified storage", + autoMigrate: false, + cfg: makeUnifiedCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when autoMigrate=false", + }, + { + name: "autoMigrate enabled with legacy storage", + autoMigrate: true, + cfg: makeLegacyCfg(), + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when storage type is legacy", + }, + { + name: "autoMigrate enabled with nil cfg", + autoMigrate: true, + cfg: nil, + resources: []string{setting.DashboardResource}, + wantMode5Enabled: false, + description: "Should NOT enable mode 5 when cfg is nil", + }, + { + name: "autoMigrate enabled with multiple resources", + autoMigrate: true, + cfg: makeUnifiedCfg(), + resources: []string{setting.FolderResource, setting.DashboardResource}, + wantMode5Enabled: true, + description: "Should enable mode 5 for all resources when autoMigrate=true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build schema.GroupResource from resource strings + resources := make([]schema.GroupResource, 0, len(tt.resources)) + for _, r := range tt.resources { + parts := strings.SplitN(r, ".", 2) + resources = append(resources, schema.GroupResource{ + Resource: parts[0], + Group: parts[1], + }) + } + + // Create the migration with options + var opts []ResourceMigrationOption + if tt.autoMigrate { + opts = append(opts, WithAutoMigrate(tt.cfg)) + } + + m := NewResourceMigration(nil, resources, "test-auto-migrate", nil, opts...) + + // Simulate what happens at the end of a successful migration + // This is the logic from Exec() that we're testing + if m.autoMigrate && m.cfg != nil && m.cfg.UnifiedStorageType() == "unified" { + for _, gr := range m.resources { + m.cfg.EnableMode5(gr.Resource + "." + gr.Group) + } + } + + // Verify mode 5 was enabled (or not) for each resource + for _, resourceName := range tt.resources { + if tt.cfg == nil { + // If cfg is nil, we can't check - just verify we didn't panic + continue + } + config := tt.cfg.UnifiedStorageConfig(resourceName) + if tt.wantMode5Enabled { + require.Equal(t, 5, int(config.DualWriterMode), "%s: %s", tt.description, resourceName) + require.True(t, config.EnableMigration, "%s: EnableMigration should be true for %s", tt.description, resourceName) + require.True(t, config.DualWriterMigrationDataSyncDisabled, "%s: DualWriterMigrationDataSyncDisabled should be true for %s", tt.description, resourceName) + } else { + require.Equal(t, 0, int(config.DualWriterMode), "%s: mode should be 0 for %s", tt.description, resourceName) + } + } + }) + } +} + +// TestResourceMigration_SkipMigrationLog verifies the SkipMigrationLog behavior: +// - When ignoreErrors=true AND errors occurred (hadErrors=true), skip writing to migration log +// This allows the migration to be re-run on the next startup +// - In all other cases, write to migration log normally +// +// This is important for the folders/dashboards migration which uses WithIgnoreErrors() to handle +// partial failures gracefully while still allowing retry on next startup. +func TestResourceMigration_SkipMigrationLog(t *testing.T) { + tests := []struct { + name string + autoMigrate bool + hadErrors bool + want bool + description string + }{ + { + name: "normal migration success", + autoMigrate: false, + hadErrors: false, + want: false, + description: "Normal successful migration should write to log", + }, + { + name: "ignoreErrors migration success", + autoMigrate: true, + hadErrors: false, + want: false, + description: "Migration with ignoreErrors that succeeds should still write to log", + }, + { + name: "normal migration with errors", + autoMigrate: false, + hadErrors: true, + want: false, + description: "Migration that fails without ignoreErrors should write error to log", + }, + { + name: "ignoreErrors migration with errors - skip log", + autoMigrate: true, + hadErrors: true, + want: true, + description: "Migration with ignoreErrors that has errors should SKIP log to allow retry", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &ResourceMigration{ + autoMigrate: tt.autoMigrate, + hadErrors: tt.hadErrors, + } + require.Equal(t, tt.want, m.SkipMigrationLog(), tt.description) + }) + } +} diff --git a/pkg/storage/unified/migrations/service.go b/pkg/storage/unified/migrations/service.go index 4ac9cdad218..8dec7ed019e 100644 --- a/pkg/storage/unified/migrations/service.go +++ b/pkg/storage/unified/migrations/service.go @@ -14,7 +14,6 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" - "k8s.io/apimachinery/pkg/runtime/schema" ) var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/migrations") @@ -54,6 +53,7 @@ func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error { logger.Info("Data migrations are disabled, skipping") return nil } + logger.Info("Running migrations for unified storage") metrics.MUnifiedStorageMigrationStatus.Set(3) return RegisterMigrations(ctx, p.migrator, p.cfg, p.sqlStore, p.client) @@ -79,7 +79,7 @@ func RegisterMigrations( return err } - if err := registerMigrations(cfg, mg, migrator, client); err != nil { + if err := registerMigrations(ctx, cfg, mg, migrator, client, sqlStore); err != nil { return err } @@ -92,65 +92,13 @@ func RegisterMigrations( db.SetMaxOpenConns(3) defer db.SetMaxOpenConns(maxOpenConns) } - if err := mg.RunMigrations(ctx, + err := mg.RunMigrations(ctx, sec.Key("migration_locking").MustBool(true), - sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil { + sec.Key("locking_attempt_timeout_sec").MustInt()) + if err != nil { return fmt.Errorf("unified storage data migration failed: %w", err) } logger.Info("Unified storage migrations completed successfully") return nil } - -func registerDashboardAndFolderMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { - foldersDef := getResourceDefinition("folder.grafana.app", "folders") - dashboardsDef := getResourceDefinition("dashboard.grafana.app", "dashboards") - driverName := mg.Dialect.DriverName() - - folderCountValidator := NewCountValidator( - client, - foldersDef.GroupResource, - "dashboard", - "org_id = ? and is_folder = true", - driverName, - ) - - dashboardCountValidator := NewCountValidator( - client, - dashboardsDef.GroupResource, - "dashboard", - "org_id = ? and is_folder = false", - driverName, - ) - - folderTreeValidator := NewFolderTreeValidator(client, foldersDef.GroupResource, driverName) - - dashboardsAndFolders := NewResourceMigration( - migrator, - []schema.GroupResource{foldersDef.GroupResource, dashboardsDef.GroupResource}, - "folders-dashboards", - []Validator{folderCountValidator, dashboardCountValidator, folderTreeValidator}, - ) - mg.AddMigration("folders and dashboards migration", dashboardsAndFolders) -} - -func registerPlaylistMigration(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator, client resource.ResourceClient) { - playlistsDef := getResourceDefinition("playlist.grafana.app", "playlists") - driverName := mg.Dialect.DriverName() - - playlistCountValidator := NewCountValidator( - client, - playlistsDef.GroupResource, - "playlist", - "org_id = ?", - driverName, - ) - - playlistsMigration := NewResourceMigration( - migrator, - []schema.GroupResource{playlistsDef.GroupResource}, - "playlists", - []Validator{playlistCountValidator}, - ) - mg.AddMigration("playlists migration", playlistsMigration) -} diff --git a/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go b/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go new file mode 100644 index 00000000000..4a542b3ae0b --- /dev/null +++ b/pkg/storage/unified/migrations/threshold/migrator_threshold_test.go @@ -0,0 +1,211 @@ +package threshold + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + + authlib "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// TODO: remove this test before Grafana 13 GA +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +// TestIntegrationAutoMigrateThresholdExceeded verifies that auto-migration is skipped when +// resource count exceeds the configured threshold. +// TODO: remove this test before Grafana 13 GA +func TestIntegrationAutoMigrateThresholdExceeded(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if db.IsTestDbSQLite() { + // Share the same SQLite DB file between steps + tmpDir := t.TempDir() + dbPath := tmpDir + "/shared-threshold-test.db" + + oldVal := os.Getenv("SQLITE_TEST_DB") + require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath)) + t.Cleanup(func() { + if oldVal == "" { + _ = os.Unsetenv("SQLITE_TEST_DB") + } else { + _ = os.Setenv("SQLITE_TEST_DB", oldVal) + } + }) + t.Logf("Using shared database path: %s", dbPath) + } + + var org1 *apis.OrgUsers + var orgB *apis.OrgUsers + + dashboardGVR := schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v1beta1", + Resource: "dashboards", + } + folderGVR := schema.GroupVersionResource{ + Group: "folder.grafana.app", + Version: "v1beta1", + Resource: "folders", + } + + dashboardKey := fmt.Sprintf("%s.%s", dashboardGVR.Resource, dashboardGVR.Group) + folderKey := fmt.Sprintf("%s.%s", folderGVR.Resource, folderGVR.Group) + playlistKey := "playlists.playlist.grafana.app" + + // Step 1: Create resources exceeding the threshold (3 resources, threshold=1) + t.Run("Step 1: Create resources exceeding threshold", func(t *testing.T) { + unifiedConfig := map[string]setting.UnifiedStorageConfig{} + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: true, + DisableDBCleanup: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }) + org1 = &helper.Org1 + orgB = &helper.OrgB + + // Create 3 dashboards + for i := 1; i <= 3; i++ { + createTestDashboard(t, helper, fmt.Sprintf("Threshold Dashboard %d", i)) + } + + // Create 3 folders + for i := 1; i <= 3; i++ { + createTestFolder(t, helper, fmt.Sprintf("folder-%d", i), fmt.Sprintf("Threshold Folder %d", i), "") + } + + // Explicitly shutdown helper before Step 1 ends to ensure database is properly closed + helper.Shutdown() + }) + + // Set SKIP_DB_TRUNCATE to prevent truncation in subsequent steps + oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE") + require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true")) + t.Cleanup(func() { + if oldSkipTruncate == "" { + _ = os.Unsetenv("SKIP_DB_TRUNCATE") + } else { + _ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate) + } + }) + + // Step 2: Verify auto-migration is skipped due to threshold + t.Run("Step 2: Verify auto-migration skipped (threshold exceeded)", func(t *testing.T) { + // Set threshold=1, but we have 3 resources of each type, so migration should be skipped + // Disable playlists migration since we're only testing dashboard/folder threshold behavior + unifiedConfig := map[string]setting.UnifiedStorageConfig{ + dashboardKey: {AutoMigrationThreshold: 1, EnableMigration: false}, + folderKey: {AutoMigrationThreshold: 1, EnableMigration: false}, + playlistKey: {EnableMigration: false}, + } + helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{ + GrafanaOpts: testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + DisableDataMigrations: false, // Allow migration system to run + APIServerStorageType: "unified", + UnifiedStorageConfig: unifiedConfig, + }, + Org1Users: org1, + OrgBUsers: orgB, + }) + t.Cleanup(helper.Shutdown) + + namespace := authlib.OrgNamespaceFormatter(helper.Org1.OrgID) + + dashCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: dashboardGVR, + }) + verifyResourceCount(t, dashCli, 3) + + folderCli := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: namespace, + GVR: folderGVR, + }) + verifyResourceCount(t, folderCli, 3) + + // Verify migration did NOT run by checking the migration log + count, err := helper.GetEnv().SQLStore.GetEngine().Table("unifiedstorage_migration_log"). + Where("migration_id = ?", "folders and dashboards migration"). + Count() + require.NoError(t, err) + require.Equal(t, int64(0), count, "Migration should not have run") + }) +} + +func createTestDashboard(t *testing.T, helper *apis.K8sTestHelper, title string) string { + t.Helper() + + payload := fmt.Sprintf(`{"dashboard": {"title": "%s", "panels": []}, "overwrite": false}`, title) + + result := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "POST", + Path: "/api/dashboards/db", + Body: []byte(payload), + }, &map[string]interface{}{}) + + require.NotNil(t, result.Response) + require.Equal(t, 200, result.Response.StatusCode) + + uid := (*result.Result)["uid"].(string) + require.NotEmpty(t, uid) + return uid +} + +func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder { + t.Helper() + + payload := fmt.Sprintf(`{ + "title": "%s", + "uid": "%s"`, title, uid) + + if parentUID != "" { + payload += fmt.Sprintf(`, + "parentUid": "%s"`, parentUID) + } + + payload += "}" + + folderCreate := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: http.MethodPost, + Path: "/api/folders", + Body: []byte(payload), + }, &folder.Folder{}) + + require.NotNil(t, folderCreate.Result) + return folderCreate.Result +} + +// verifyResourceCount verifies that the expected number of resources exist in K8s storage +func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) { + t.Helper() + + l, err := client.Resource.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + + resources, err := meta.ExtractList(l) + require.NoError(t, err) + require.Equal(t, expectedCount, len(resources)) +} diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql index 8372eb73463..72026e26362 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql @@ -12,7 +12,7 @@ INSERT INTO {{ .Ident .TableName }} VALUES ( {{ .Arg .GUID }}, {{ .Arg .KeyPath }}, - COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Value }}, {{ .Arg .Group }}, {{ .Arg .Resource }}, {{ .Arg .Namespace }}, diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql index 1f58bd28b43..2c034f4d757 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql @@ -11,7 +11,7 @@ INSERT INTO {{ .Ident "resource" }} {{ .Ident "previous_resource_version" }} ) VALUES ( - COALESCE({{ .Arg .Value }}, ""), + (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}), {{ .Arg .GUID }}, {{ .Arg .Group }}, {{ .Arg .Resource }}, @@ -19,13 +19,5 @@ VALUES ( {{ .Arg .Name }}, {{ .Arg .Action }}, {{ .Arg .Folder }}, - CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( - SELECT {{ .Ident "resource_version" }} - FROM {{ .Ident "resource" }} - WHERE {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} - AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} - AND {{ .Ident "name" }} = {{ .Arg .Name }} - ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 - ) END + {{ .Arg .PreviousRV }} ); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql index d52aac5063d..77680d190ff 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql @@ -7,38 +7,15 @@ INSERT INTO {{ .Ident "resource_history" }} {{ .Ident "namespace" }}, {{ .Ident "name" }}, {{ .Ident "action" }}, - {{ .Ident "folder" }}, - {{ .Ident "previous_resource_version" }}, - {{ .Ident "generation" }} + {{ .Ident "folder" }} ) VALUES ( - COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Value }}, {{ .Arg .GUID }}, {{ .Arg .Group }}, {{ .Arg .Resource }}, {{ .Arg .Namespace }}, {{ .Arg .Name }}, {{ .Arg .Action }}, - {{ .Arg .Folder }}, - CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( - SELECT {{ .Ident "resource_version" }} - FROM {{ .Ident "resource_history" }} - WHERE {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} - AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} - AND {{ .Ident "name" }} = {{ .Arg .Name }} - ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 - ) END, - CASE - WHEN {{ .Arg .Action }} = 1 THEN 1 - WHEN {{ .Arg .Action }} = 3 THEN 0 - ELSE 1 + ( - SELECT COUNT(1) - FROM {{ .Ident "resource_history" }} - WHERE {{ .Ident "group" }} = {{ .Arg .Group }} - AND {{ .Ident "resource" }} = {{ .Arg .Resource }} - AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} - AND {{ .Ident "name" }} = {{ .Arg .Name }} - ) - END + {{ .Arg .Folder }} ); diff --git a/pkg/storage/unified/resource/data/sqlkv_save_event.sql b/pkg/storage/unified/resource/data/sqlkv_save_event.sql index 669497dbb19..0091c6e1347 100644 --- a/pkg/storage/unified/resource/data/sqlkv_save_event.sql +++ b/pkg/storage/unified/resource/data/sqlkv_save_event.sql @@ -5,7 +5,7 @@ INSERT INTO {{ .Ident .TableName }} ) VALUES ( {{ .Arg .KeyPath }}, - COALESCE({{ .Arg .Value }}, "") + {{ .Arg .Value }} ) {{- if eq .DialectName "mysql" }} ON DUPLICATE KEY UPDATE {{ .Ident "value" }} = {{ .Arg .Value }} diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql index 1565d0894a4..3c9f92d4de5 100644 --- a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql @@ -1,8 +1,10 @@ UPDATE {{ .Ident "resource" }} SET - {{ .Ident "value" }} = {{ .Arg .Value }}, + {{ .Ident "guid" }} = {{ .Arg .GUID }}, + {{ .Ident "value" }} = (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}), {{ .Ident "action" }} = {{ .Arg .Action }}, - {{ .Ident "folder" }} = {{ .Arg .Folder }} + {{ .Ident "folder" }} = {{ .Arg .Folder }}, + {{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }} WHERE {{ .Ident "group" }} = {{ .Arg .Group }} AND {{ .Ident "resource" }} = {{ .Arg .Resource }} AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql new file mode 100644 index 00000000000..ac1f135fd76 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql @@ -0,0 +1,5 @@ +UPDATE {{ .Ident "resource_history" }} +SET + {{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}, + {{ .Ident "generation" }} = {{ .Arg .Generation }} + WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}; diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index 7a1b614323e..931a5b20560 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -12,6 +12,9 @@ import ( "time" "github.com/grafana/grafana/pkg/apimachinery/validation" + "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" gocache "github.com/patrickmn/go-cache" ) @@ -306,10 +309,6 @@ func (d *dataStore) GetResourceKeyAtRevision(ctx context.Context, key GetRequest return DataKey{}, fmt.Errorf("invalid get request key: %w", err) } - if rv == 0 { - rv = math.MaxInt64 - } - listKey := ListRequestKey(key) iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv}) @@ -598,7 +597,7 @@ func ParseKey(key string) (DataKey, error) { }, nil } -// Temporary while we need to support unified/sql/backend compatibility +// Temporary while we need to support unified/sql/backend compatibility. // Remove once we stop using RvManager in storage_backend.go func ParseKeyWithGUID(key string) (DataKey, error) { parts := strings.Split(key, "/") @@ -815,3 +814,127 @@ func (d *dataStore) getGroupResources(ctx context.Context) ([]GroupResource, err return results, nil } + +// TODO: remove when backwards compatibility is no longer needed. +var ( + sqlKVUpdateLegacyResourceHistory = mustTemplate("sqlkv_update_legacy_resource_history.sql") + sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql") + sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql") +) + +// TODO: remove when backwards compatibility is no longer needed. +type sqlKVLegacySaveRequest struct { + sqltemplate.SQLTemplate + GUID string + Group string + Resource string + Namespace string + Name string + Action int64 + Folder string + PreviousRV int64 +} + +func (req sqlKVLegacySaveRequest) Validate() error { + return nil +} + +// TODO: remove when backwards compatibility is no longer needed. +type sqlKVLegacyUpdateHistoryRequest struct { + sqltemplate.SQLTemplate + GUID string + PreviousRV int64 + Generation int64 +} + +func (req sqlKVLegacyUpdateHistoryRequest) Validate() error { + return nil +} + +// applyBackwardsCompatibleChanges updates the `resource` and `resource_history` tables +// to make sure the sqlkv implementation is backwards-compatible with the existing sql backend. +// Specifically, it will update the `resource_history` table to include the previous resource version +// and generation, which come from the `WriteEvent`, and also make the corresponding change on the +// `resource` table, no longer used in the storage backend. +// +// TODO: remove when backwards compatibility is no longer needed. +func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.Tx, event WriteEvent, key DataKey) error { + kv, isSQLKV := d.kv.(*sqlKV) + if !isSQLKV { + return nil + } + + generation := event.Object.GetGeneration() + if key.Action == DataActionDeleted { + generation = 0 + } + _, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResourceHistory, sqlKVLegacyUpdateHistoryRequest{ + SQLTemplate: sqltemplate.New(kv.dialect), + GUID: key.GUID, + PreviousRV: event.PreviousRV, + Generation: generation, + }) + + if err != nil { + return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err) + } + + var action int64 + switch key.Action { + case DataActionCreated: + action = 1 + case DataActionUpdated: + action = 2 + case DataActionDeleted: + action = 3 + } + + switch key.Action { + case DataActionCreated: + _, err := dbutil.Exec(ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(kv.dialect), + GUID: key.GUID, + Group: key.Group, + Resource: key.Resource, + Namespace: key.Namespace, + Name: key.Name, + Action: action, + Folder: key.Folder, + PreviousRV: event.PreviousRV, + }) + + if err != nil { + return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err) + } + case DataActionUpdated: + _, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(kv.dialect), + GUID: key.GUID, + Group: key.Group, + Resource: key.Resource, + Namespace: key.Namespace, + Name: key.Name, + Action: action, + Folder: key.Folder, + PreviousRV: event.PreviousRV, + }) + + if err != nil { + return fmt.Errorf("compatibility layer: failed to update resource: %w", err) + } + case DataActionDeleted: + _, err := dbutil.Exec(ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(kv.dialect), + Group: key.Group, + Resource: key.Resource, + Namespace: key.Namespace, + Name: key.Name, + }) + + if err != nil { + return fmt.Errorf("compatibility layer: failed to delete from resource: %w", err) + } + } + + return nil +} diff --git a/pkg/storage/unified/resource/notifier.go b/pkg/storage/unified/resource/notifier.go index 5dd6a17ad29..3d3b2024d7e 100644 --- a/pkg/storage/unified/resource/notifier.go +++ b/pkg/storage/unified/resource/notifier.go @@ -78,13 +78,13 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { cache := gocache.New(cacheTTL, cacheCleanupInterval) events := make(chan Event, opts.BufferSize) - initialRV, err := n.lastEventResourceVersion(ctx) + lastRV, err := n.lastEventResourceVersion(ctx) if errors.Is(err, ErrNotFound) { - initialRV = snowflakeFromTime(time.Now()) // No events yet, start from the beginning + lastRV = 0 // No events yet, start from the beginning } else if err != nil { n.log.Error("Failed to get last event resource version", "error", err) } - lastRV := initialRV + 1 // We want to start watching from the next event + lastRV = lastRV + 1 // We want to start watching from the next event go func() { defer close(events) @@ -110,7 +110,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event { } // Skip old events lower than the requested resource version - if evt.ResourceVersion <= initialRV { + if evt.ResourceVersion < lastRV { continue } diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go index 060f8eecfbe..f78629ebeb7 100644 --- a/pkg/storage/unified/resource/notifier_test.go +++ b/pkg/storage/unified/resource/notifier_test.go @@ -25,7 +25,6 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { return notifier, eventStore } -// nolint:unused func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { dbstore := db.InitTestDB(t) eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) @@ -60,8 +59,7 @@ func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testin func TestNotifier_lastEventResourceVersion(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) } func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -112,8 +110,7 @@ func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, not func TestNotifier_cachekey(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) } func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -167,8 +164,7 @@ func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, func TestNotifier_Watch_NoEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) } func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -209,8 +205,7 @@ func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *noti func TestNotifier_Watch_WithExistingEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) } func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -284,8 +279,7 @@ func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, noti func TestNotifier_Watch_EventDeduplication(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) } func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -351,8 +345,7 @@ func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, noti func TestNotifier_Watch_ContextCancellation(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) } func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { @@ -398,8 +391,7 @@ func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, not func TestNotifier_Watch_MultipleEvents(t *testing.T) { runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents) - // enable this when sqlkv is ready - // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) + runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) } func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index bca5fb98491..ade86760502 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -863,7 +863,7 @@ func newRebuildRequest(key NamespacedResource, minBuildTime, lastImportTime time func (s *searchSupport) getOrCreateIndex(ctx context.Context, stats *SearchStats, key NamespacedResource, reason string) (ResourceIndex, error) { if s == nil || s.search == nil { - return nil, fmt.Errorf("search is not configured properly (missing unifiedStorageSearch feature toggle?)") + return nil, fmt.Errorf("search is not configured properly (missing enable_search config?)") } ctx, span := tracer.Start(ctx, "resource.searchSupport.getOrCreateIndex") diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 7c890e72b00..d722ce1ee9f 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -28,6 +28,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" secrets "github.com/grafana/grafana/pkg/registry/apis/secret/contracts" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/util/scheduler" ) @@ -815,7 +816,7 @@ func (s *server) update(ctx context.Context, user claims.AuthInfo, req *resource // TODO: once we know the client is always sending the RV, require ResourceVersion > 0 // See: https://github.com/grafana/grafana/pull/111866 - if req.ResourceVersion > 0 && latest.ResourceVersion != req.ResourceVersion { + if req.ResourceVersion > 0 && !rvmanager.IsRvEqual(latest.ResourceVersion, req.ResourceVersion) { return &resourcepb.UpdateResponse{ Error: &ErrOptimisticLockingFailed, }, nil @@ -883,7 +884,7 @@ func (s *server) delete(ctx context.Context, user claims.AuthInfo, req *resource rsp.Error = latest.Error return rsp, nil } - if req.ResourceVersion > 0 && latest.ResourceVersion != req.ResourceVersion { + if req.ResourceVersion > 0 && !rvmanager.IsRvEqual(latest.ResourceVersion, req.ResourceVersion) { rsp.Error = &ErrOptimisticLockingFailed return rsp, nil } diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 6d406294a96..9651c65f2f6 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -44,8 +44,6 @@ var ( sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql") sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql") sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql") - sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql") - sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql") sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql") sqlKVDelete = mustTemplate("sqlkv_delete.sql") sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") @@ -157,26 +155,6 @@ func (req sqlKVSaveRequest) Validate() error { return req.sqlKVSectionKey.Validate() } -type sqlKVLegacySaveRequest struct { - sqltemplate.SQLTemplate - Value []byte - GUID string - Group string - Resource string - Namespace string - Name string - Action int64 - Folder string -} - -func (req sqlKVLegacySaveRequest) Validate() error { - return nil -} - -func (req sqlKVLegacySaveRequest) Results() ([]byte, error) { - return req.Value, nil -} - type sqlKVKeysRequest struct { sqltemplate.SQLTemplate sqlKVSection @@ -371,6 +349,11 @@ func (w *sqlWriteCloser) Close() error { } w.closed = true + value := w.buf.Bytes() + if value == nil { + // to prevent NOT NULL constraint violations + value = []byte{} + } // do regular kv save: simple key_path + value insert with conflict check. // can only do this on resource_events for now, until we drop the columns in resource_history @@ -378,7 +361,7 @@ func (w *sqlWriteCloser) Close() error { _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVSaveEvent, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -392,7 +375,7 @@ func (w *sqlWriteCloser) Close() error { // used to keep backwards compatibility between sql-based kvstore and unified/sql/backend tx, ok := rvmanager.TxFromCtx(w.ctx) if !ok { - // temporary save for dataStore without rvmanager + // temporary save for dataStore without rvmanager (non backwards-compatible) // we can use the same template as the event one after we: // - move PK from GUID to key_path // - remove all unnecessary columns (or at least their NOT NULL constraints) @@ -402,7 +385,7 @@ func (w *sqlWriteCloser) Close() error { SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, GUID: uuid.New().String(), - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -419,7 +402,7 @@ func (w *sqlWriteCloser) Close() error { _, err = dbutil.Exec(w.ctx, w.kv.db, sqlKVUpdateData, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + Value: value, }) if err != nil { @@ -429,11 +412,12 @@ func (w *sqlWriteCloser) Close() error { return nil } - // special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore, - // as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that - // component will be responsible for populating the resource_version and key_path columns - // note that we are not touching resource_version table, neither the resource_version columns or the key_path column - // as the RvManager will be responsible for this + // special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant + // for the kvstore, as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that + // component will be responsible for populating the resource_version and key_path columns. + // For full backwards-compatibility, the `Save` function needs to be called within a callback that updates the resource_history + // table with `previous_resource_version` and `generation` and updates the `resource` table accordingly. See the + // storage_backend for the full implementation. dataKey, err := ParseKeyWithGUID(w.sectionKey.Key) if err != nil { return fmt.Errorf("failed to parse key: %w", err) @@ -448,13 +432,13 @@ func (w *sqlWriteCloser) Close() error { case DataActionDeleted: action = 3 default: - return fmt.Errorf("failed to parse key: %w", err) + return fmt.Errorf("failed to parse key: invalid action") } _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{ SQLTemplate: sqltemplate.New(w.kv.dialect), - sqlKVSectionKey: w.sectionKey, - Value: w.buf.Bytes(), + sqlKVSectionKey: w.sectionKey, // unused: key_path is set by rvmanager + Value: value, GUID: dataKey.GUID, Group: dataKey.Group, Resource: dataKey.Resource, @@ -468,52 +452,6 @@ func (w *sqlWriteCloser) Close() error { return fmt.Errorf("failed to save to resource_history: %w", err) } - switch dataKey.Action { - case DataActionCreated: - _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{ - SQLTemplate: sqltemplate.New(w.kv.dialect), - Value: w.buf.Bytes(), - GUID: dataKey.GUID, - Group: dataKey.Group, - Resource: dataKey.Resource, - Namespace: dataKey.Namespace, - Name: dataKey.Name, - Action: action, - Folder: dataKey.Folder, - }) - - if err != nil { - return fmt.Errorf("failed to insert to resource: %w", err) - } - case DataActionUpdated: - _, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{ - SQLTemplate: sqltemplate.New(w.kv.dialect), - Value: w.buf.Bytes(), - Group: dataKey.Group, - Resource: dataKey.Resource, - Namespace: dataKey.Namespace, - Name: dataKey.Name, - Action: action, - Folder: dataKey.Folder, - }) - - if err != nil { - return fmt.Errorf("failed to update resource: %w", err) - } - case DataActionDeleted: - _, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{ - SQLTemplate: sqltemplate.New(w.kv.dialect), - Group: dataKey.Group, - Resource: dataKey.Resource, - Namespace: dataKey.Namespace, - Name: dataKey.Name, - }) - - if err != nil { - return fmt.Errorf("failed to delete from resource: %w", err) - } - } - return nil } diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 13f2b9d6159..4db6da89d9a 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -332,17 +332,21 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in dataKey.GUID = uuid.New().String() var err error rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) { - err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)) - if err != nil { + if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); err != nil { return "", fmt.Errorf("failed to write data: %w", err) } + if err := k.dataStore.applyBackwardsCompatibleChanges(ctx, tx, event, dataKey); err != nil { + return "", fmt.Errorf("failed to apply backwards compatible updates: %w", err) + } + return dataKey.GUID, nil }) if err != nil { return 0, fmt.Errorf("failed to write data: %w", err) } + rv = rvmanager.SnowflakeFromRv(rv) dataKey.ResourceVersion = rv } else { err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) @@ -369,22 +373,14 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened - if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { + if latestKey.ResourceVersion != dataKey.ResourceVersion { // Delete the data we just wrote since it's not the latest - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected") } if !rvmanager.IsRvEqual(prevKey.ResourceVersion, event.PreviousRV) { // Another concurrent write happened between our read and write - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion) } @@ -403,12 +399,8 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened - if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { + if latestKey.ResourceVersion != dataKey.ResourceVersion { // Delete the data we just wrote since it's not the latest - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -416,10 +408,6 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in // Verify that the immediate predecessor is not a create if prevKey.Action == DataActionCreated { // Another concurrent create happened - delete our write and return error - // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete - if k.rvManager != nil { - dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) - } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -431,7 +419,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Group: event.Key.Group, Resource: event.Key.Resource, Name: event.Key.Name, - ResourceVersion: rv, + ResourceVersion: dataKey.ResourceVersion, Action: action, Folder: obj.GetFolder(), PreviousRV: event.PreviousRV, diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index d6ff00a81c0..eec7290633b 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -45,7 +45,7 @@ import ( const ( indexStorageMemory = "memory" indexStorageFile = "file" - boltTimeout = "500ms" + boltTimeout = "1s" ) // Keys used to store internal data in index. @@ -417,25 +417,18 @@ func (b *bleveBackend) BuildIndex( // This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes) // If we do have an unexpired cached index already, we always build a new index from scratch. if cachedIndex == nil && !rebuild { - result := b.findPreviousFileBasedIndex(resourceDir) - if result != nil && result.IsOpen { - // Index file exists but is opened by another process, fallback to memory. - // Keep the name so we can skip cleanup of that directory. - newIndexType = indexStorageMemory - fileIndexName = result.Name - } else if result != nil && result.Index != nil { - // Found and opened existing index successfully - index = result.Index - fileIndexName = result.Name - indexRV = result.RV + var findErr error + index, fileIndexName, indexRV, findErr = b.findPreviousFileBasedIndex(resourceDir) + if findErr != nil { + return nil, findErr } } - if newIndexType == indexStorageFile && index != nil { + if index != nil { build = false logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName)) defer closeIndexOnExit(index, "") // Close index, but don't delete directory. - } else if newIndexType == indexStorageFile { + } else { // Building index from scratch. Index name has a time component in it to be unique, but if // we happen to create non-unique name, we bump the time and try again. @@ -462,9 +455,7 @@ func (b *bleveBackend) BuildIndex( logWithDetails.Info("Building index using filesystem", "directory", indexDir) defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory. } - } - - if newIndexType == indexStorageMemory { + } else { index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion) if err != nil { return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err) @@ -567,7 +558,7 @@ func cleanFileSegment(input string) string { return input } -// cleanOldIndexes deletes all subdirectories inside resourceDir, skipping directory with "skipName". +// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName". // "skipName" can be empty. func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { entries, err := os.ReadDir(resourceDir) @@ -578,19 +569,19 @@ func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err) return } - for _, ent := range entries { - if ent.IsDir() && ent.Name() != skipName { - indexDir := filepath.Join(resourceDir, ent.Name()) - if !isPathWithinRoot(indexDir, b.opts.Root) { - b.log.Warn("Skipping cleanup of directory", "directory", indexDir) + for _, entry := range entries { + if entry.IsDir() && entry.Name() != skipName { + entryDir := filepath.Join(resourceDir, entry.Name()) + if !isPathWithinRoot(entryDir, b.opts.Root) { + b.log.Warn("Skipping cleanup of directory", "directory", entryDir) continue } - err = os.RemoveAll(indexDir) + err = os.RemoveAll(entryDir) if err != nil { - b.log.Error("Unable to remove old index folder", "directory", indexDir, "error", err) + b.log.Error("Unable to remove old index folder", "directory", entryDir, "error", err) } else { - b.log.Info("Removed old index folder", "directory", indexDir) + b.log.Info("Removed old index folder", "directory", entryDir) } } } @@ -637,17 +628,10 @@ func formatIndexName(now time.Time) string { return now.Format("20060102-150405") } -type fileIndex struct { - Index bleve.Index - Name string - RV int64 - IsOpen bool -} - -func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex { +func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Index, string, int64, error) { entries, err := os.ReadDir(resourceDir) if err != nil { - return nil + return nil, "", 0, nil } for _, ent := range entries { @@ -657,14 +641,15 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex indexName := ent.Name() indexDir := filepath.Join(resourceDir, indexName) - idx, err := bleve.OpenUsing(indexDir, map[string]interface{}{"bolt_timeout": boltTimeout}) if err != nil { + // On timeout, the file probably is locked by another process. + // This indicates a setup issue that should be fixed rather than worked around by creating a new index file. if errors.Is(err, bolterrors.ErrTimeout) { - b.log.Debug("Index is opened by another process (timeout), skipping", "indexDir", indexDir) - return &fileIndex{Name: indexName, IsOpen: true} + b.log.Error("index is locked by another process", "indexDir", indexDir, "err", err) + return nil, "", 0, fmt.Errorf("index is locked by another process: indexDir=%s, err=%w", indexDir, err) } - b.log.Debug("error opening index", "indexDir", indexDir, "err", err) + b.log.Error("error opening index", "indexDir", indexDir, "err", err) continue } @@ -675,14 +660,10 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex continue } - return &fileIndex{ - Index: idx, - Name: indexName, - RV: indexRV, - } + return idx, indexName, indexRV, nil } - return nil + return nil, "", 0, nil } // Stop closes all indexes and stops background tasks. @@ -1272,21 +1253,23 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R queryExact.SetField(resource.SEARCH_FIELD_TITLE) queryExact.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token queryExact.Operator = query.MatchQueryOperatorAnd // This doesn't make a difference for keyword analyzer, we add it just to be explicit. + searchQuery := bleve.NewDisjunctionQuery(queryExact) // Query 2: Phrase query with standard analyzer queryPhrase := bleve.NewMatchPhraseQuery(req.Query) queryPhrase.SetBoost(5.0) queryPhrase.SetField(resource.SEARCH_FIELD_TITLE) queryPhrase.Analyzer = standard.Name + searchQuery.AddQuery(queryPhrase) // Query 3: Match query with standard analyzer queryAnalyzed := bleve.NewMatchQuery(removeSmallTerms(req.Query)) queryAnalyzed.SetField(resource.SEARCH_FIELD_TITLE) + queryAnalyzed.SetBoost(2.0) queryAnalyzed.Analyzer = standard.Name queryAnalyzed.Operator = query.MatchQueryOperatorAnd // Make sure all terms from the query are matched + searchQuery.AddQuery(queryAnalyzed) - // At least one of the queries must match - searchQuery := bleve.NewDisjunctionQuery(queryExact, queryAnalyzed, queryPhrase) queries = append(queries, searchQuery) } diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index c879440e7b6..c9c3967cd58 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -18,11 +18,11 @@ import ( "github.com/prometheus/client_golang/prometheus/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + bolterrors "go.etcd.io/bbolt/errors" "go.uber.org/atomic" "go.uber.org/goleak" authlib "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/log" @@ -1584,7 +1584,7 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int { return int(cnt) } -func TestBleveBackendFallsBackToMemory(t *testing.T) { +func TestBuildIndexReturnsErrorWhenIndexLocked(t *testing.T) { ns := resource.NamespacedResource{ Namespace: "test", Group: "group", @@ -1605,53 +1605,19 @@ func TestBleveBackendFallsBackToMemory(t *testing.T) { require.Equal(t, indexStorageFile, bleveIdx1.indexStorage) checkOpenIndexes(t, reg1, 0, 1) - // Now create a second backend using the same directory - // This simulates another instance trying to open the same index - backend2, reg2 := setupBleveBackend(t, withRootDir(tmpDir)) - - // BuildIndex should detect the file is locked and fallback to memory - index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) - require.NoError(t, err) - require.NotNil(t, index2) - - // Verify second index fell back to in-memory despite size being above file threshold - bleveIdx2, ok := index2.(*bleveIndex) - require.True(t, ok) - require.Equal(t, indexStorageMemory, bleveIdx2.indexStorage) - - // Verify metrics show 1 memory index and 0 file indexes for backend2 - checkOpenIndexes(t, reg2, 1, 0) - - // Verify the in-memory index works correctly - require.Equal(t, 10, docCount(t, index2)) - - // Clean up: close first backend to release the file lock - backend1.Stop() -} - -func TestBleveSkipCleanOldIndexesOnMemoryFallback(t *testing.T) { - ns := resource.NamespacedResource{ - Namespace: "test", - Group: "group", - Resource: "resource", - } - - tmpDir := t.TempDir() - - backend1, _ := setupBleveBackend(t, withRootDir(tmpDir)) - _, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) - require.NoError(t, err) - // Now create a second backend using the same directory // This simulates another instance trying to open the same index backend2, _ := setupBleveBackend(t, withRootDir(tmpDir)) - // BuildIndex should detect the file is locked and fallback to memory - _, err = backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + // BuildIndex should detect the file is locked and return an error after timeout + now := time.Now() + timeout, err := time.ParseDuration(boltTimeout) require.NoError(t, err) - - // Verify that the index directory still exists (i.e., cleanOldIndexes was skipped) - verifyDirEntriesCount(t, backend2.getResourceDir(ns), 1) + index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.Error(t, err) + require.ErrorIs(t, err, bolterrors.ErrTimeout) + require.Nil(t, index2) + require.GreaterOrEqual(t, time.Since(now).Milliseconds(), timeout.Milliseconds()-500, "BuildIndex should have waited for approximately boltTimeout duration") // Clean up: close first backend to release the file lock backend1.Stop() diff --git a/pkg/storage/unified/search/builders/dashboard.go b/pkg/storage/unified/search/builders/dashboard.go index 4f8d55111a9..a2963d71186 100644 --- a/pkg/storage/unified/search/builders/dashboard.go +++ b/pkg/storage/unified/search/builders/dashboard.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "slices" "sort" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -18,6 +19,7 @@ import ( const DASHBOARD_SCHEMA_VERSION = "schema_version" const DASHBOARD_LINK_COUNT = "link_count" const DASHBOARD_PANEL_TYPES = "panel_types" +const DASHBOARD_PANEL_TITLE = "panel_title" const DASHBOARD_DS_TYPES = "ds_types" const DASHBOARD_TRANSFORMATIONS = "transformation" const DASHBOARD_LIBRARY_PANEL_REFERENCE = "reference.LibraryPanel" @@ -53,11 +55,21 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource. Type: resourcepb.ResourceTableColumnDefinition_INT32, Description: "How many links appear on the page", }, + { + Name: DASHBOARD_PANEL_TITLE, + Type: resourcepb.ResourceTableColumnDefinition_STRING, + IsArray: true, + Description: "The panel title text", + Properties: &resourcepb.ResourceTableColumnDefinition_Properties{ + Filterable: false, // full text + FreeText: true, + }, + }, { Name: DASHBOARD_PANEL_TYPES, Type: resourcepb.ResourceTableColumnDefinition_STRING, IsArray: true, - Description: "How many links appear on the page", + Description: "The panel types used in this dashboard", Properties: &resourcepb.ResourceTableColumnDefinition_Properties{ Filterable: true, }, @@ -269,14 +281,22 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou doc.Description = summary.Description doc.Tags = summary.Tags + panelTitles := []string{} panelTypes := []string{} transformations := []string{} dsTypes := []string{} - for _, p := range summary.Panels { - if p.Type != "" { + for p := range summary.PanelIterator() { + switch p.Type { + case "": // ignore + case "row": // row should map to a layout type when we support v2 constructs + default: panelTypes = append(panelTypes, p.Type) } + + if len(p.Title) > 0 { + panelTitles = append(panelTitles, p.Title) + } if len(p.Transformer) > 0 { transformations = append(transformations, p.Transformer...) } @@ -309,17 +329,20 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou resource.SEARCH_FIELD_LEGACY_ID: summary.ID, } + if len(panelTitles) > 0 { + doc.Fields[DASHBOARD_PANEL_TITLE] = panelTitles + } if len(panelTypes) > 0 { sort.Strings(panelTypes) - doc.Fields[DASHBOARD_PANEL_TYPES] = panelTypes + doc.Fields[DASHBOARD_PANEL_TYPES] = slices.Compact(panelTypes) // distinct values } if len(dsTypes) > 0 { sort.Strings(dsTypes) - doc.Fields[DASHBOARD_DS_TYPES] = dsTypes + doc.Fields[DASHBOARD_DS_TYPES] = slices.Compact(dsTypes) // distinct values } if len(transformations) > 0 { sort.Strings(transformations) - doc.Fields[DASHBOARD_TRANSFORMATIONS] = transformations + doc.Fields[DASHBOARD_TRANSFORMATIONS] = slices.Compact(transformations) // distinct values } for k, v := range s.Stats[summary.UID] { diff --git a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json index a77725e2cc2..fdb77b02c3b 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json +++ b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa-out.json @@ -32,10 +32,16 @@ "errors_last_7_days": 1, "grafana.app/deprecatedInternalID": 141, "link_count": 0, + "panel_title": [ + "green pie", + "red pie", + "blue pie", + "collapsed row" + ], "panel_types": [ "barchart", "graph", - "row" + "pie" ], "schema_version": 38 }, @@ -46,6 +52,12 @@ "kind": "DataSource", "name": "DSUID" }, + { + "relation": "depends-on", + "group": "dashboards.grafana.app", + "kind": "LibraryPanel", + "name": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + }, { "relation": "depends-on", "group": "dashboards.grafana.app", diff --git a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json index d9ecbfc6aec..24360ee929b 100644 --- a/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json +++ b/pkg/storage/unified/search/builders/testdata/doc/dashboard-aaa.json @@ -67,7 +67,7 @@ "name": "red pie", "uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee" }, - "title": "green pie" + "title": "red pie" }, { "id": 7, @@ -78,6 +78,14 @@ "id": 8, "type": "graph" }, + { + "id": 20, + "type": "graph" + }, + { + "id": 30, + "type": "graph" + }, { "collapsed": true, "gridPos": { @@ -101,6 +109,10 @@ "uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" }, "title": "blue pie" + }, + { + "id": 40, + "type": "pie" } ], "title": "collapsed row", diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index 20a0874b598..d450e9ae24b 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -19,7 +19,7 @@ func NewSearchOptions( ownsIndexFn func(key resource.NamespacedResource) (bool, error), ) (resource.SearchOptions, error) { //nolint:staticcheck // not yet migrated to OpenFeature - if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { + if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { root := cfg.IndexPath if root == "" { root = filepath.Join(cfg.DataPath, "unified-search", "bleve") diff --git a/pkg/storage/unified/search/testdata/manual-dashboard.json b/pkg/storage/unified/search/testdata/manual-dashboard.json index 4208a58dcd3..2ae346d072c 100644 --- a/pkg/storage/unified/search/testdata/manual-dashboard.json +++ b/pkg/storage/unified/search/testdata/manual-dashboard.json @@ -71,11 +71,18 @@ "description": "How many links appear on the page", "priority": 0 }, + { + "name": "panel_title", + "type": "string", + "format": "", + "description": "The panel title text", + "priority": 0 + }, { "name": "panel_types", "type": "string", "format": "", - "description": "How many links appear on the page", + "description": "The panel types used in this dashboard", "priority": 0 }, { @@ -214,6 +221,7 @@ null, null, null, + null, null ], "object": { @@ -239,6 +247,7 @@ "repo", null, null, + null, [ "timeseries" ], @@ -282,6 +291,7 @@ "repo", null, null, + null, [ "timeseries", "table" diff --git a/pkg/storage/unified/sql/db/migrations/resource_mig.go b/pkg/storage/unified/sql/db/migrations/resource_mig.go index 0f19ae01d93..8f9be689718 100644 --- a/pkg/storage/unified/sql/db/migrations/resource_mig.go +++ b/pkg/storage/unified/sql/db/migrations/resource_mig.go @@ -217,5 +217,8 @@ func initResourceTables(mg *migrator.Migrator) string { migrator.ConvertUniqueKeyToPrimaryKey(mg, oldResourceVersionUniqueKey, updatedResourceVersionTable) + mg.AddMigration("Change key_path collation of resource_history in postgres", migrator.NewRawSQLMigration("").Postgres(`ALTER TABLE resource_history ALTER COLUMN key_path TYPE VARCHAR(2048) COLLATE "C";`)) + mg.AddMigration("Change key_path collation of resource_events in postgres", migrator.NewRawSQLMigration("").Postgres(`ALTER TABLE resource_events ALTER COLUMN key_path TYPE VARCHAR(2048) COLLATE "C";`)) + return marker } diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index eaf78de0779..166e2dac372 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -107,16 +107,20 @@ func TestIntegrationSQLStorageAndSQLKVCompatibilityTests(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) t.Cleanup(db.CleanupTestDB) + newKvBackend := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { + return unitest.NewTestSqlKvBackend(t, ctx, true) + } + t.Run("IsHA (polling notifier)", func(t *testing.T) { unitest.RunSQLStorageBackendCompatibilityTest(t, func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { return newTestBackend(t, true, 0) - }, nil) + }, newKvBackend, nil) }) t.Run("NotHA (in process notifier)", func(t *testing.T) { unitest.RunSQLStorageBackendCompatibilityTest(t, func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { return newTestBackend(t, false, 0) - }, nil) + }, newKvBackend, nil) }) } diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index 730efe418f6..faf6b70c600 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/bwmarrin/snowflake" "github.com/go-jose/go-jose/v4/jwt" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -44,7 +43,6 @@ const ( TestCreateNewResource = "create new resource" TestGetResourceLastImportTime = "get resource last import time" TestOptimisticLocking = "optimistic locking on concurrent writes" - TestKeyPathGeneration = "key_path generation" ) type NewBackendFunc func(ctx context.Context) resource.StorageBackend @@ -106,37 +104,6 @@ func RunStorageBackendTest(t *testing.T, newBackend NewBackendFunc, opts *TestOp } } -func RunSQLStorageBackendCompatibilityTest(t *testing.T, newBackend NewBackendWithDBFunc, opts *TestOptions) { - if opts == nil { - opts = &TestOptions{} - } - - if opts.NSPrefix == "" { - opts.NSPrefix = GenerateRandomNSPrefix() - } - - t.Logf("Running tests with namespace prefix: %s", opts.NSPrefix) - - cases := []struct { - name string - fn func(*testing.T, resource.StorageBackend, string, sqldb.DB) - }{ - {TestKeyPathGeneration, runTestIntegrationBackendKeyPathGeneration}, - } - - for _, tc := range cases { - if shouldSkip := opts.SkipTests[tc.name]; shouldSkip { - t.Logf("Skipping test: %s", tc.name) - continue - } - - t.Run(tc.name, func(t *testing.T) { - backend, db := newBackend(context.Background()) - tc.fn(t, backend, opts.NSPrefix, db) - }) - } -} - func runTestIntegrationBackendHappyPath(t *testing.T, backend resource.StorageBackend, nsPrefix string) { ctx := types.WithAuthInfo(context.Background(), authn.NewAccessTokenAuthInfo(authn.Claims[authn.AccessTokenClaims]{ Claims: jwt.Claims{ @@ -1759,222 +1726,3 @@ func runTestIntegrationBackendOptimisticLocking(t *testing.T, backend resource.S require.LessOrEqual(t, successes, 1, "at most one create should succeed (errors: %v)", errorMessages) }) } - -func runTestIntegrationBackendKeyPathGeneration(t *testing.T, backend resource.StorageBackend, nsPrefix string, db sqldb.DB) { - ctx := testutil.NewDefaultTestContext(t) - - t.Run("Create resource", func(t *testing.T) { - // Create a test resource - key := &resourcepb.ResourceKey{ - Group: "playlist.grafana.app", - Resource: "playlists", - Namespace: nsPrefix + "-default", - Name: "test-playlist-crud", - } - - // Create the K8s unstructured object - testObj := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "playlist.grafana.app/v0alpha1", - "kind": "Playlist", - "metadata": map[string]interface{}{ - "name": "test-playlist-crud", - "namespace": nsPrefix + "-default", - "uid": "test-uid-crud-123", - }, - "spec": map[string]interface{}{ - "title": "My Test Playlist", - }, - }, - } - - // Get metadata accessor - metaAccessor, err := utils.MetaAccessor(testObj) - require.NoError(t, err) - - // Serialize to JSON - jsonBytes, err := testObj.MarshalJSON() - require.NoError(t, err) - - // Create WriteEvent - writeEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: key, - Value: jsonBytes, - Object: metaAccessor, - PreviousRV: 0, // Always 0 for new resources - GUID: "create-guid-crud-123", - } - - // Create the resource using WriteEvent - createRV, err := backend.WriteEvent(ctx, writeEvent) - require.NoError(t, err) - require.Greater(t, createRV, int64(0)) - - // Verify created resource key_path - verifyKeyPath(t, db, ctx, key, "created", createRV, "") - - t.Run("Update resource", func(t *testing.T) { - // Update the resource - testObj.Object["spec"] = map[string]interface{}{ - "title": "My Updated Playlist", - } - - updatedMetaAccessor, err := utils.MetaAccessor(testObj) - require.NoError(t, err) - - updatedJsonBytes, err := testObj.MarshalJSON() - require.NoError(t, err) - - updateEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_MODIFIED, - Key: key, - Value: updatedJsonBytes, - Object: updatedMetaAccessor, - PreviousRV: createRV, - GUID: fmt.Sprintf("update-guid-%d", createRV), - } - - // Update the resource - updateRV, err := backend.WriteEvent(ctx, updateEvent) - require.NoError(t, err) - require.Greater(t, updateRV, createRV) - - // Verify updated resource key_path - verifyKeyPath(t, db, ctx, key, "updated", updateRV, "") - - t.Run("Delete resource", func(t *testing.T) { - deleteEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_DELETED, - Key: key, - Value: updatedJsonBytes, // Keep the last known value - Object: updatedMetaAccessor, - PreviousRV: updateRV, - GUID: fmt.Sprintf("delete-guid-%d", updateRV), - } - - // Delete the resource - deleteRV, err := backend.WriteEvent(ctx, deleteEvent) - require.NoError(t, err) - require.Greater(t, deleteRV, updateRV) - - // Verify deleted resource key_path - verifyKeyPath(t, db, ctx, key, "deleted", deleteRV, "") - }) - }) - }) - - t.Run("Resource with folder", func(t *testing.T) { - // Create a resource in a folder - folderKey := &resourcepb.ResourceKey{ - Group: "dashboard.grafana.app", - Resource: "dashboards", - Namespace: nsPrefix + "-default", - Name: "my-dashboard", - } - - // Create dashboard object with folder - dashboardObj := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "dashboard.grafana.app/v0alpha1", - "kind": "Dashboard", - "metadata": map[string]interface{}{ - "name": "my-dashboard", - "namespace": nsPrefix + "-default", - "uid": "dash-uid-456", - "annotations": map[string]interface{}{ - "grafana.app/folder": "test-folder", - }, - }, - "spec": map[string]interface{}{ - "title": "My Dashboard", - }, - }, - } - - folderMetaAccessor, err := utils.MetaAccessor(dashboardObj) - require.NoError(t, err) - - folderJsonBytes, err := dashboardObj.MarshalJSON() - require.NoError(t, err) - - folderWriteEvent := resource.WriteEvent{ - Type: resourcepb.WatchEvent_ADDED, - Key: folderKey, - Value: folderJsonBytes, - Object: folderMetaAccessor, - PreviousRV: 0, - GUID: "folder-guid-456", - } - - // Create the dashboard in folder - folderRV, err := backend.WriteEvent(ctx, folderWriteEvent) - require.NoError(t, err) - require.Greater(t, folderRV, int64(0)) - - // Verify folder resource key_path includes folder - verifyKeyPath(t, db, ctx, folderKey, "created", folderRV, "test-folder") - }) -} - -// verifyKeyPath is a helper function to verify key_path generation -func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) { - var query string - if db.DriverName() == "postgres" { - query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3" - } else { - query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?" - } - rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion) - require.NoError(t, err) - - require.True(t, rows.Next()) - - var keyPath string - var actualRV int64 - var actualAction int - var actualFolder string - - err = rows.Scan(&keyPath, &actualRV, &actualAction, &actualFolder) - require.NoError(t, err) - err = rows.Close() - require.NoError(t, err) - - // Verify basic key_path format - require.Contains(t, keyPath, "unified/data/") - require.Contains(t, keyPath, key.Group) - require.Contains(t, keyPath, key.Resource) - require.Contains(t, keyPath, key.Namespace) - require.Contains(t, keyPath, key.Name) - - // Verify action suffix - require.Contains(t, keyPath, fmt.Sprintf("~%s~", action)) - - // Verify snowflake calculation - expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000) - require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), fmt.Sprintf("actual RV: %d", actualRV)) - - // Verify folder if specified - if expectedFolder != "" { - require.Equal(t, expectedFolder, actualFolder) - require.Contains(t, keyPath, expectedFolder) - } - - // Verify action code matches - var expectedActionCode int - switch action { - case "created": - expectedActionCode = 1 - case "updated": - expectedActionCode = 2 - case "deleted": - expectedActionCode = 3 - } - require.Equal(t, expectedActionCode, actualAction) - - t.Logf("Action: %s, RV: %d, Snowflake: %d", action, resourceVersion, expectedSnowflake) - t.Logf("Key_path: %s", keyPath) - if expectedFolder != "" { - t.Logf("Folder: %s", actualFolder) - } -} diff --git a/pkg/storage/unified/testing/storage_backend_sql_compatibility.go b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go new file mode 100644 index 00000000000..5e1423fa1ec --- /dev/null +++ b/pkg/storage/unified/testing/storage_backend_sql_compatibility.go @@ -0,0 +1,1276 @@ +package test + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func NewTestSqlKvBackend(t *testing.T, ctx context.Context, withRvManager bool) (resource.KVBackend, sqldb.DB) { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := resource.NewSQLKV(eDB) + require.NoError(t, err) + db, err := eDB.Init(ctx) + require.NoError(t, err) + + kvOpts := resource.KVBackendOptions{ + KvStore: kv, + } + + if withRvManager { + dialect := sqltemplate.DialectForDriver(db.DriverName()) + rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{ + Dialect: dialect, + DB: db, + }) + require.NoError(t, err) + + kvOpts.RvManager = rvManager + } + + backend, err := resource.NewKVStorageBackend(kvOpts) + require.NoError(t, err) + return backend, db +} + +func RunSQLStorageBackendCompatibilityTest(t *testing.T, newSqlBackend, newKvBackend NewBackendWithDBFunc, opts *TestOptions) { + if opts == nil { + opts = &TestOptions{} + } + + if opts.NSPrefix == "" { + opts.NSPrefix = GenerateRandomNSPrefix() + } + + t.Logf("Running tests with namespace prefix: %s", opts.NSPrefix) + + cases := []struct { + name string + fn func(*testing.T, resource.StorageBackend, resource.StorageBackend, string, sqldb.DB) + }{ + {"key_path generation", runTestIntegrationBackendKeyPathGeneration}, + {"sql backend fields compatibility", runTestSQLBackendFieldsCompatibility}, + {"cross backend consistency", runTestCrossBackendConsistency}, + {"concurrent operations stress", runTestConcurrentOperationsStress}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if opts.SkipTests[tc.name] { + t.Skip() + } + + kvbackend, db := newKvBackend(t.Context()) + sqlbackend, _ := newSqlBackend(t.Context()) + + // Skip on SQLite due to concurrency limitations + if db.DriverName() == "sqlite3" { + t.Skip("Skipping concurrent operations stress test on SQLite") + } + + tc.fn(t, sqlbackend, kvbackend, opts.NSPrefix, db) + }) + } +} + +func runTestIntegrationBackendKeyPathGeneration(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Test SQL backend with 3 writes, 3 updates, 3 deletes + t.Run("SQL Backend Operations", func(t *testing.T) { + runKeyPathTest(t, sqlBackend, nsPrefix+"-sql", db, ctx) + }) + + // Test SQL KV backend with 3 writes, 3 updates, 3 deletes + t.Run("SQL KV Backend Operations", func(t *testing.T) { + runKeyPathTest(t, kvBackend, nsPrefix+"-kv", db, ctx) + }) +} + +// runKeyPathTest performs 3 writes, 3 updates, and 3 deletes on a backend then verifies that key_path is properly +// generated across both backends +func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix string, db sqldb.DB, ctx context.Context) { + // Create storage server from backend + server, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: backend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Track the current resource version for each resource (index 0, 1, 2 for resources 1, 2, 3) + currentRVs := make([]int64, 3) + + // Create 3 resources + for i := 1; i <= 3; i++ { + folder := "" + if i == 2 { + folder = "test-folder" // Resource 2 has folder annotation + } + + opts := PlaylistResourceOptions{ + Name: fmt.Sprintf("test-playlist-%d", i), + Namespace: nsPrefix, + UID: fmt.Sprintf("test-uid-%d", i), + Generation: 1, + Title: fmt.Sprintf("My Test Playlist %d", i), + Folder: folder, + } + + created := createPlaylistResource(t, server, ctx, opts) + currentRVs[i-1] = created.ResourceVersion + + // Verify created resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i)) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "created", created.ResourceVersion, "") + } + } + + // Update the 3 resources + for i := 1; i <= 3; i++ { + folder := "" + if i == 2 { + folder = "test-folder" // Resource 2 has folder annotation + } + + opts := PlaylistResourceOptions{ + Name: fmt.Sprintf("test-playlist-%d", i), + Namespace: nsPrefix, + UID: fmt.Sprintf("test-uid-%d", i), + Generation: 2, + Title: fmt.Sprintf("My Updated Playlist %d", i), + Folder: folder, + } + + updated := updatePlaylistResource(t, server, ctx, opts, currentRVs[i-1]) + currentRVs[i-1] = updated.ResourceVersion // Update to the latest resource version + + // Verify updated resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, fmt.Sprintf("test-playlist-%d", i)) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "updated", updated.ResourceVersion, "") + } + } + + // Delete the 3 resources + for i := 1; i <= 3; i++ { + name := fmt.Sprintf("test-playlist-%d", i) + deleted := deletePlaylistResource(t, server, ctx, nsPrefix, name, currentRVs[i-1]) + + // Verify deleted resource key_path (with folder for resource 2) + key := createPlaylistKey(nsPrefix, name) + if i == 2 { + verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "test-folder") + } else { + verifyKeyPath(t, db, ctx, key, "deleted", deleted.ResourceVersion, "") + } + } +} + +// verifyKeyPath is a helper function to verify key_path generation +func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) { + // For SQL backend (namespace contains "-sql"), resourceVersion is in microsecond format + // but key_path stores snowflake RV, so convert to snowflake + // For KV backend (namespace contains "-kv"), resourceVersion is already in snowflake format + isSqlBackend := strings.Contains(key.Namespace, "-sql") + + var keyPathRV int64 + if isSqlBackend { + // Convert microsecond RV to snowflake for key_path construction + keyPathRV = rvmanager.SnowflakeFromRv(resourceVersion) + } else { + // KV backend already provides snowflake RV + keyPathRV = resourceVersion + } + + // Build the expected key_path using DataKey format: unified/data/group/resource/namespace/name/resourceVersion~action~folder + expectedKeyPath := fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", key.Group, key.Resource, key.Namespace, key.Name, keyPathRV, action, expectedFolder) + + var query string + if db.DriverName() == "postgres" { + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = $1" + } else { + query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = ?" + } + rows, err := db.QueryContext(ctx, query, expectedKeyPath) + require.NoError(t, err) + + require.True(t, rows.Next(), "Resource not found in resource_history table - both SQL and KV backends should write to this table") + + var keyPath string + var actualRV int64 + var actualAction int + var actualFolder string + + err = rows.Scan(&keyPath, &actualRV, &actualAction, &actualFolder) + require.NoError(t, err) + + // Ensure there's exactly one row and no errors + require.False(t, rows.Next()) + require.NoError(t, rows.Err()) + + // Verify basic key_path format + require.Contains(t, keyPath, "unified/data/") + require.Contains(t, keyPath, key.Group) + require.Contains(t, keyPath, key.Resource) + require.Contains(t, keyPath, key.Namespace) + require.Contains(t, keyPath, key.Name) + + // Verify action suffix + require.Contains(t, keyPath, fmt.Sprintf("~%s~", action)) + + // Verify folder if specified + if expectedFolder != "" { + require.Equal(t, expectedFolder, actualFolder) + require.Contains(t, keyPath, expectedFolder) + } + + // Verify action code matches + var expectedActionCode int + switch action { + case "created": + expectedActionCode = 1 + case "updated": + expectedActionCode = 2 + case "deleted": + expectedActionCode = 3 + } + require.Equal(t, expectedActionCode, actualAction) +} + +// runTestSQLBackendFieldsCompatibility tests that KV backend with RvManager populates all SQL backend legacy fields +func runTestSQLBackendFieldsCompatibility(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create unique namespace for isolation + namespace := nsPrefix + "-fields-test" + + // Test SQL backend with 3 resources through complete lifecycle + t.Run("SQL Backend Operations", func(t *testing.T) { + runSQLBackendFieldsTest(t, sqlBackend, namespace+"-sql", db, ctx) + }) + + // Test KV backend with 3 resources through complete lifecycle + t.Run("KV Backend Operations", func(t *testing.T) { + runSQLBackendFieldsTest(t, kvBackend, namespace+"-kv", db, ctx) + }) +} + +// buildCrossDatabaseQuery converts query placeholders for different database drivers +func buildCrossDatabaseQuery(driverName, baseQuery string) string { + if driverName == "postgres" { + // Convert ? placeholders to $1, $2, etc. for PostgreSQL + placeholderCount := 1 + result := baseQuery + for { + oldResult := result + result = strings.Replace(result, "?", fmt.Sprintf("$%d", placeholderCount), 1) + if result == oldResult { + break + } + placeholderCount++ + } + return result + } + // MySQL and SQLite use ? placeholders + return baseQuery +} + +// runSQLBackendFieldsTest performs complete resource lifecycle testing and verifies all legacy SQL fields +func runSQLBackendFieldsTest(t *testing.T, backend resource.StorageBackend, namespace string, db sqldb.DB, ctx context.Context) { + // Create storage server from backend + server, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: backend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Resource definitions with different folder configurations + resources := []struct { + name string + folder string + }{ + {"test-resource-1", ""}, // No folder + {"test-resource-2", "test-folder"}, // With folder + {"test-resource-3", ""}, // No folder + } + + // Track resource versions for each resource + resourceVersions := make([][]int64, len(resources)) // [resourceIndex][versionIndex] + + // Create 3 resources + for i, res := range resources { + // Create the resource using helper function + opts := PlaylistResourceOptions{ + Name: res.name, + Namespace: namespace, + UID: fmt.Sprintf("test-uid-%d", i+1), + Generation: 1, + Title: fmt.Sprintf("Test Playlist %d", i+1), + Folder: res.folder, + } + + created := createPlaylistResource(t, server, ctx, opts) + // Store the resource version + resourceVersions[i] = append(resourceVersions[i], created.ResourceVersion) + } + + // Update 3 resources + for i, res := range resources { + // Update the resource using helper function + opts := PlaylistResourceOptions{ + Name: res.name, + Namespace: namespace, + UID: fmt.Sprintf("test-uid-%d", i+1), + Generation: 2, + Title: fmt.Sprintf("Updated Test Playlist %d", i+1), + Folder: res.folder, + } + + currentRV := resourceVersions[i][len(resourceVersions[i])-1] + updated := updatePlaylistResource(t, server, ctx, opts, currentRV) + // Store the new resource version + resourceVersions[i] = append(resourceVersions[i], updated.ResourceVersion) + } + + // Delete first 2 resources (leave the last one to validate resource table) + for i, res := range resources[:2] { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: res.name, + } + + // Delete the resource using the current resource version + currentRV := resourceVersions[i][len(resourceVersions[i])-1] + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: currentRV, + }) + require.NoError(t, err) + require.Nil(t, deleted.Error) + require.Greater(t, deleted.ResourceVersion, currentRV) + + // Store the delete resource version + resourceVersions[i] = append(resourceVersions[i], deleted.ResourceVersion) + } + + // Verify all legacy SQL fields are populated correctly + verifyResourceHistoryTable(t, db, namespace, resources, resourceVersions) + verifyResourceTable(t, db, namespace, resources, resourceVersions) + verifyResourceVersionTable(t, db, namespace, resources, resourceVersions) +} + +// ResourceHistoryRecord represents a row from the resource_history table +type ResourceHistoryRecord struct { + GUID string + Group string + Resource string + Namespace string + Name string + Value string + Action int + Folder string + PreviousResourceVersion int64 + Generation int + ResourceVersion int64 +} + +// ResourceRecord represents a row from the resource table +type ResourceRecord struct { + GUID string + Group string + Resource string + Namespace string + Name string + Value string + Action int + Folder string + PreviousResourceVersion int64 + ResourceVersion int64 +} + +// ResourceVersionRecord represents a row from the resource_version table +type ResourceVersionRecord struct { + Group string + Resource string + ResourceVersion int64 +} + +// verifyResourceHistoryTable validates all resource_history entries +func verifyResourceHistoryTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT guid, "group", resource, namespace, name, value, action, folder, + previous_resource_version, generation, resource_version + FROM resource_history + WHERE namespace = ? + ORDER BY resource_version ASC + `) + + rows, err := db.QueryContext(ctx, query, namespace) + require.NoError(t, err) + defer func() { + _ = rows.Close() + }() + + var records []ResourceHistoryRecord + for rows.Next() { + var record ResourceHistoryRecord + err := rows.Scan( + &record.GUID, &record.Group, &record.Resource, &record.Namespace, &record.Name, + &record.Value, &record.Action, &record.Folder, &record.PreviousResourceVersion, + &record.Generation, &record.ResourceVersion, + ) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect 8 records total: 3 creates + 3 updates + 2 deletes + require.Len(t, records, 8, "Expected 8 resource_history records (3 creates + 3 updates + 2 deletes)") + + // Verify each record - we'll validate in the order they were created (by resource_version) + // The records are already sorted by resource_version ASC, so we just need to verify each one + recordIndex := 0 + for resourceIdx, res := range resources { + // Check create record (action=1, generation=1) + createRecord := records[recordIndex] + verifyResourceHistoryRecord(t, createRecord, res, resourceIdx, 1, 0, 1, resourceVersions[resourceIdx][0]) + recordIndex++ + } + + for resourceIdx, res := range resources { + // Check update record (action=2, generation=2) + updateRecord := records[recordIndex] + verifyResourceHistoryRecord(t, updateRecord, res, resourceIdx, 2, resourceVersions[resourceIdx][0], 2, resourceVersions[resourceIdx][1]) + recordIndex++ + } + + for resourceIdx, res := range resources[:2] { + // Check delete record (action=3, generation=0) - only first 2 resources were deleted + deleteRecord := records[recordIndex] + verifyResourceHistoryRecord(t, deleteRecord, res, resourceIdx, 3, resourceVersions[resourceIdx][1], 0, resourceVersions[resourceIdx][2]) + recordIndex++ + } +} + +// verifyResourceHistoryRecord validates a single resource_history record +func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, expectedRes struct{ name, folder string }, resourceIdx, expectedAction int, expectedPrevRV int64, expectedGeneration int, expectedRV int64) { + // Validate GUID (should be non-empty) + require.NotEmpty(t, record.GUID, "GUID should not be empty") + + // Validate group/resource/namespace/name + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + require.Equal(t, expectedRes.name, record.Name) + + // Validate value contains expected JSON - server modifies/formats the JSON differently for different operations + // Check for both formats (with and without space after colon) + nameFound := strings.Contains(record.Value, fmt.Sprintf(`"name": "%s"`, expectedRes.name)) || + strings.Contains(record.Value, fmt.Sprintf(`"name":"%s"`, expectedRes.name)) + require.True(t, nameFound, "JSON should contain the expected name field") + + kindFound := strings.Contains(record.Value, `"kind": "Playlist"`) || + strings.Contains(record.Value, `"kind":"Playlist"`) + require.True(t, kindFound, "JSON should contain the expected kind field") + + // Validate action + require.Equal(t, expectedAction, record.Action) + + // Validate folder + if expectedRes.folder == "" { + require.Equal(t, "", record.Folder, "Folder should be empty when no folder annotation") + } else { + require.Equal(t, expectedRes.folder, record.Folder, "Folder should match annotation") + } + + // Validate previous_resource_version + // For KV backend operations, expectedPrevRV is now in snowflake format (returned by KV backend) + // but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison + if strings.Contains(record.Namespace, "-kv") { + require.True(t, rvmanager.IsRvEqual(expectedPrevRV, record.PreviousResourceVersion), + "Previous resource version should match (KV backend snowflake format)") + } else { + require.Equal(t, expectedPrevRV, record.PreviousResourceVersion) + } + + // Validate generation: 1 for create, 2 for update, 0 for delete + require.Equal(t, expectedGeneration, record.Generation) + + // Validate resource_version + // For KV backend operations, expectedRV is now in snowflake format (returned by KV backend) + // but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison + if strings.Contains(record.Namespace, "-kv") { + require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion), + "Resource version should match (KV backend snowflake format)") + } else { + require.Equal(t, expectedRV, record.ResourceVersion) + } +} + +// verifyResourceTable validates the resource table (latest state only) +func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT guid, "group", resource, namespace, name, value, action, folder, + previous_resource_version, resource_version + FROM resource + WHERE namespace = ? + ORDER BY name ASC + `) + + rows, err := db.QueryContext(ctx, query, namespace) + require.NoError(t, err) + defer func() { + _ = rows.Close() + }() + + var records []ResourceRecord + for rows.Next() { + var record ResourceRecord + err := rows.Scan( + &record.GUID, &record.Group, &record.Resource, &record.Namespace, &record.Name, + &record.Value, &record.Action, &record.Folder, &record.PreviousResourceVersion, + &record.ResourceVersion, + ) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect 1 record since only 2 resources were deleted (the 3rd remains) + require.Len(t, records, 1, "Expected 1 resource record since only 2 resources were deleted") + + // Validate the remaining record (should be the 3rd resource after update) + record := records[0] + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + require.Equal(t, "test-resource-3", record.Name) + + // Should be an update action (2) - resource table stores latest action + require.Equal(t, 2, record.Action) + + // Validate value contains expected JSON + nameFound := strings.Contains(record.Value, fmt.Sprintf(`"name": "%s"`, "test-resource-3")) || + strings.Contains(record.Value, fmt.Sprintf(`"name":"%s"`, "test-resource-3")) + require.True(t, nameFound, "JSON should contain the expected name field") + + kindFound := strings.Contains(record.Value, `"kind": "Playlist"`) || + strings.Contains(record.Value, `"kind":"Playlist"`) + require.True(t, kindFound, "JSON should contain the expected kind field") + + // Folder should be empty (3rd resource has no folder annotation) + require.Equal(t, "", record.Folder, "3rd resource should have no folder") + + // GUID should be non-empty + require.NotEmpty(t, record.GUID, "GUID should not be empty") + + // Resource version should match the expected version for test-resource-3 (updated version) + expectedRV := resourceVersions[2][1] // test-resource-3's update version + if strings.Contains(namespace, "-kv") { + require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion), + "Resource version should match (KV backend snowflake format)") + } else { + require.Equal(t, expectedRV, record.ResourceVersion) + } +} + +// verifyResourceVersionTable validates the resource_version table +func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, resources []struct{ name, folder string }, resourceVersions [][]int64) { + ctx := t.Context() + query := buildCrossDatabaseQuery(db.DriverName(), ` + SELECT "group", resource, resource_version + FROM resource_version + WHERE "group" = ? AND resource = ? + `) + + // Check that we have exactly one entry for playlist.grafana.app/playlists + rows, err := db.QueryContext(ctx, query, "playlist.grafana.app", "playlists") + require.NoError(t, err) + defer func() { + _ = rows.Close() + }() + + var records []ResourceVersionRecord + for rows.Next() { + var record ResourceVersionRecord + err := rows.Scan(&record.Group, &record.Resource, &record.ResourceVersion) + require.NoError(t, err) + records = append(records, record) + } + require.NoError(t, rows.Err()) + + // We expect exactly 1 record for the group+resource combination + require.Len(t, records, 1, "Expected 1 resource_version record for playlist.grafana.app/playlists") + + record := records[0] + require.Equal(t, "playlist.grafana.app", record.Group) + require.Equal(t, "playlists", record.Resource) + + // Find the highest resource version across all resources + var maxRV int64 + for _, rvs := range resourceVersions { + for _, rv := range rvs { + if rv > maxRV { + maxRV = rv + } + } + } + + // The resource_version table should contain the latest RV for the group+resource + // It might be slightly higher due to RV manager operations, so check it's at least our max + // For KV backend, maxRV is in snowflake format but record.ResourceVersion is in microsecond format + // Use IsRvEqual for proper comparison between different RV formats + isKvBackend := strings.Contains(namespace, "-kv") + recordResourceVersion := record.ResourceVersion + if isKvBackend { + recordResourceVersion = rvmanager.SnowflakeFromRv(record.ResourceVersion) + } + + require.Less(t, recordResourceVersion, int64(9223372036854775807), "resource_version should be reasonable") + require.Greater(t, recordResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked") +} + +// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight) +func runTestCrossBackendConsistency(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create storage servers from both backends + sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: sqlBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: kvBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Create isolated namespaces for each test phase + sqlNamespace := nsPrefix + "-concurrent-sql" + kvNamespace := nsPrefix + "-concurrent-kv" + + t.Run("Write to SQL, Read from Both", func(t *testing.T) { + runWriteToOneReadFromBoth(t, sqlServer, kvServer, sqlNamespace+"-writeSQL", ctx, "sql") + }) + + t.Run("Write to KV, Read from Both", func(t *testing.T) { + runWriteToOneReadFromBoth(t, kvServer, sqlServer, kvNamespace+"-writeKV", ctx, "kv") + }) + + t.Run("Resource Version Consistency", func(t *testing.T) { + runResourceVersionConsistencyTest(t, sqlServer, kvServer, nsPrefix+"-rv-consistency", ctx) + }) +} + +// runTestConcurrentOperationsStress tests heavy concurrent operations between SQL and KV backends +func runTestConcurrentOperationsStress(t *testing.T, sqlBackend, kvBackend resource.StorageBackend, nsPrefix string, db sqldb.DB) { + ctx := testutil.NewDefaultTestContext(t) + + // Create storage servers from both backends + sqlServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: sqlBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + kvServer, err := resource.NewResourceServer(resource.ResourceServerOptions{ + Backend: kvBackend, + AccessClient: claims.FixedAccessClient(true), // Allow all operations for testing + }) + require.NoError(t, err) + + // Create isolated namespace for mixed operations + mixedNamespace := nsPrefix + "-concurrent-mixed" + + // do a single create using the sql backend to initialize the resource_version table + // without this, both backend may try to insert the same group+resource to the resource_version which breaks the + // tests + initNamespace := mixedNamespace + "-init" + initOpts := PlaylistResourceOptions{ + Name: "init-resource", + Namespace: initNamespace, + UID: "init-uid", + Generation: 1, + Title: "Init Resource", + Folder: "", + } + createPlaylistResource(t, sqlServer, ctx, initOpts) + + // Heavy Mixed Concurrent Operations + t.Run("Mixed Concurrent Operations", func(t *testing.T) { + runMixedConcurrentOperations(t, sqlServer, kvServer, mixedNamespace, ctx) + }) +} + +// runWriteToOneReadFromBoth writes resources to one backend then reads from both to verify consistency +func runWriteToOneReadFromBoth(t *testing.T, writeServer, readServer resource.ResourceServer, namespace string, ctx context.Context, writerBackend string) { + // Create 5 test resources + resourceNames := []string{ + fmt.Sprintf("resource-%s-1", writerBackend), + fmt.Sprintf("resource-%s-2", writerBackend), + fmt.Sprintf("resource-%s-3", writerBackend), + fmt.Sprintf("resource-%s-4", writerBackend), + fmt.Sprintf("resource-%s-5", writerBackend), + } + + createdResourceVersions := make([]int64, len(resourceNames)) + + // Write all resources to the write backend + for i, resourceName := range resourceNames { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: resourceName, + } + + resourceJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "namespace": "%s", + "uid": "test-uid-%d", + "generation": 1 + }, + "spec": { + "title": "Concurrent Test Playlist %d" + } + }`, resourceName, namespace, i+1, i+1) + + created, err := writeServer.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: []byte(resourceJSON), + }) + require.NoError(t, err) + require.Nil(t, created.Error) + require.Greater(t, created.ResourceVersion, int64(0)) + createdResourceVersions[i] = created.ResourceVersion + } + + // Add a small delay to ensure data propagates + time.Sleep(10 * time.Millisecond) + + // Read from both backends and compare payloads + for _, resourceName := range resourceNames { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: resourceName, + } + + // Read from write backend + writeResp, err := writeServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err, "Failed to read %s from write backend", resourceName) + require.Nil(t, writeResp.Error, "Read error from write backend %s: %s", resourceName, writeResp.Error) + require.Greater(t, writeResp.ResourceVersion, int64(0), "Invalid resource version for %s on write backend", resourceName) + + // Read from read backend + readResp, err := readServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err, "Failed to read %s from read backend", resourceName) + require.Nil(t, readResp.Error, "Read error from read backend %s: %s", resourceName, readResp.Error) + require.Greater(t, readResp.ResourceVersion, int64(0), "Invalid resource version for %s on read backend", resourceName) + + // Validate that both backends return identical payload content + require.JSONEq(t, string(writeResp.Value), string(readResp.Value), + "Payload mismatch for resource %s between write and read backends.\nWrite backend: %s\nRead backend: %s", + resourceName, string(writeResp.Value), string(readResp.Value)) + + // Validate that both backends return equivalent resource versions using rvmanager compatibility check + // Note: rvmanager.IsRvEqual expects snowflake format as first parameter, so we check both orderings + require.True(t, rvmanager.IsRvEqual(writeResp.ResourceVersion, readResp.ResourceVersion) || rvmanager.IsRvEqual(readResp.ResourceVersion, writeResp.ResourceVersion), + "Resource version mismatch for resource %s between backends.\nWrite backend (%s): %d\nRead backend (%s): %d", + resourceName, writerBackend, writeResp.ResourceVersion, getOtherBackendName(writerBackend), readResp.ResourceVersion) + + t.Logf("✓ Resource %s: payload and resource version (%d) consistency verified between %s (write) and %s (read) backends", + resourceName, writeResp.ResourceVersion, writerBackend, getOtherBackendName(writerBackend)) + } + + // Verify List consistency between backends + verifyListConsistencyBetweenServers(t, writeServer, readServer, namespace, len(resourceNames)) +} + +// getOtherBackendName returns the complementary backend name +func getOtherBackendName(backend string) string { + if backend == "sql" { + return "kv" + } + return "sql" +} + +// runMixedConcurrentOperations runs different operations simultaneously on both backends +func runMixedConcurrentOperations(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) { + var wg sync.WaitGroup + errors := make(chan error, 20) + startBarrier := make(chan struct{}) + + // Use higher operation counts to ensure concurrency + opCounts := BackendOperationCounts{ + Creates: 25, + Updates: 15, + Deletes: 10, + } + + // SQL backend operations + wg.Add(1) + go func() { + defer wg.Done() + <-startBarrier // Wait for signal to start + if err := runBackendOperationsWithCounts(ctx, sqlServer, namespace+"-sql", "sql", opCounts); err != nil { + errors <- fmt.Errorf("SQL backend operations failed: %w", err) + } + }() + + // KV backend operations + wg.Add(1) + go func() { + defer wg.Done() + <-startBarrier // Wait for signal to start + if err := runBackendOperationsWithCounts(ctx, kvServer, namespace+"-kv", "kv", opCounts); err != nil { + errors <- fmt.Errorf("KV backend operations failed: %w", err) + } + }() + + // Start both goroutines simultaneously + close(startBarrier) + + // Wait for operations to complete with timeout + done := make(chan bool) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // Operations completed + case <-time.After(10 * time.Second): + t.Fatal("Timeout waiting for mixed concurrent operations") + } + + // Check for errors + close(errors) + for err := range errors { + t.Error(err) + } + + // Allow some time for data propagation + time.Sleep(50 * time.Millisecond) + + // Calculate expected remaining resources based on operation counts + expectedRemaining := opCounts.Creates - opCounts.Deletes // Creates - Deletes = Remaining + + // Verify consistency of resources created by SQL backend operations + // Note: Skip resource version checking since these are separate operations on different backends + verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-sql", expectedRemaining, false) + + // Verify consistency of resources created by KV backend operations + // Note: Skip resource version checking since these are separate operations on different backends + verifyListConsistencyBetweenServersWithRVCheck(t, sqlServer, kvServer, namespace+"-kv", expectedRemaining, false) +} + +// BackendOperationCounts defines how many operations of each type to perform +type BackendOperationCounts struct { + Creates int + Updates int + Deletes int +} + +// runBackendOperationsWithCounts performs configurable create, update, delete operations on a backend +func runBackendOperationsWithCounts(ctx context.Context, server resource.ResourceServer, namespace, backendType string, counts BackendOperationCounts) error { + // Create resources + resourceVersions := make([]int64, counts.Creates) + for i := 1; i <= counts.Creates; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + resourceJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "resource-%s-%d", + "namespace": "%s", + "uid": "test-uid-%s-%d", + "generation": 1 + }, + "spec": { + "title": "Mixed Test Playlist %s %d" + } + }`, backendType, i, namespace, backendType, i, backendType, i) + + created, err := server.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: []byte(resourceJSON), + }) + if err != nil { + return fmt.Errorf("failed to create resource %d: %w", i, err) + } + if created.Error != nil { + return fmt.Errorf("create error for resource %d: %s", i, created.Error.Message) + } + resourceVersions[i-1] = created.ResourceVersion + } + + // Update resources (only update as many as we have, limited by creates and updates count) + updateCount := counts.Updates + if updateCount > counts.Creates { + updateCount = counts.Creates // Can't update more resources than we created + } + for i := 1; i <= updateCount; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + updatedJSON := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "resource-%s-%d", + "namespace": "%s", + "uid": "test-uid-%s-%d", + "generation": 2 + }, + "spec": { + "title": "Updated Mixed Test Playlist %s %d" + } + }`, backendType, i, namespace, backendType, i, backendType, i) + + updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ + Key: key, + Value: []byte(updatedJSON), + ResourceVersion: resourceVersions[i-1], + }) + if err != nil { + return fmt.Errorf("failed to update resource %d: %w", i, err) + } + if updated.Error != nil { + return fmt.Errorf("update error for resource %d: %s", i, updated.Error.Message) + } + resourceVersions[i-1] = updated.ResourceVersion + } + + // Delete resources (only delete as many as we have, limited by creates and deletes count) + deleteCount := counts.Deletes + if deleteCount > updateCount { + deleteCount = updateCount // Can only delete resources that were updated (have latest RV) + } + for i := 1; i <= deleteCount; i++ { + key := &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: fmt.Sprintf("resource-%s-%d", backendType, i), + } + + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: resourceVersions[i-1], // Use the resource version from updates + }) + if err != nil { + return fmt.Errorf("failed to delete resource %d: %w", i, err) + } + if deleted.Error != nil { + return fmt.Errorf("delete error for resource %d: %s", i, deleted.Error.Message) + } + } + + return nil +} + +// runResourceVersionConsistencyTest verifies resource version handling across backends +func runResourceVersionConsistencyTest(t *testing.T, sqlServer, kvServer resource.ResourceServer, namespace string, ctx context.Context) { + // Create a resource on SQL backend + opts := PlaylistResourceOptions{ + Name: "rv-test-resource", + Namespace: namespace, + UID: "test-uid-rv", + Generation: 1, + Title: "RV Test Playlist", + Folder: "", // No folder + } + + createPlaylistResource(t, sqlServer, ctx, opts) + + // Allow data to propagate + time.Sleep(10 * time.Millisecond) + + // Read from KV backend to get the same resource + key := createPlaylistKey(namespace, "rv-test-resource") + kvRead, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, kvRead.Error) + // Note: Resource versions may differ between backends, but content should be the same + require.Greater(t, kvRead.ResourceVersion, int64(0), "KV backend should return a valid resource version") + + // Read from SQL backend to compare content + sqlReadInitial, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, sqlReadInitial.Error) + require.JSONEq(t, string(sqlReadInitial.Value), string(kvRead.Value), "Both backends should return the same initial content") + + // Update via KV backend + updateOpts := PlaylistResourceOptions{ + Name: "rv-test-resource", + Namespace: namespace, + UID: "test-uid-rv", + Generation: 2, + Title: "Updated RV Test Playlist", + Folder: "", // No folder + } + + updatePlaylistResource(t, kvServer, ctx, updateOpts, kvRead.ResourceVersion) + + // Allow data to propagate + time.Sleep(10 * time.Millisecond) + + // Read from SQL backend to verify consistency + sqlRead, err := sqlServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, sqlRead.Error) + // Note: Resource versions may differ, but content should be consistent + require.Greater(t, sqlRead.ResourceVersion, int64(0), "SQL backend should return a valid resource version") + + // Verify both backends return the same content - we need to read from KV again to get the Value + kvReadAfterUpdate, err := kvServer.Read(ctx, &resourcepb.ReadRequest{Key: key}) + require.NoError(t, err) + require.Nil(t, kvReadAfterUpdate.Error) + require.JSONEq(t, string(kvReadAfterUpdate.Value), string(sqlRead.Value), "Both backends should return the same updated content") +} + +// verifyListConsistencyBetweenServers verifies that both servers return consistent list results +func verifyListConsistencyBetweenServers(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int) { + verifyListConsistencyBetweenServersWithRVCheck(t, server1, server2, namespace, expectedCount, true) +} + +// verifyListConsistencyBetweenServersWithRVCheck verifies list consistency with optional resource version checking +func verifyListConsistencyBetweenServersWithRVCheck(t *testing.T, server1, server2 resource.ResourceServer, namespace string, expectedCount int, checkResourceVersions bool) { + ctx := testutil.NewDefaultTestContext(t) + + // Get lists from both servers + list1, err := server1.List(ctx, &resourcepb.ListRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + }, + }, + }) + require.NoError(t, err) + require.Nil(t, list1.Error) + + list2, err := server2.List(ctx, &resourcepb.ListRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + }, + }, + }) + require.NoError(t, err) + require.Nil(t, list2.Error) + + // Create maps for easier comparison by extracting names from JSON + items1 := make(map[string]*resourcepb.ResourceWrapper) + for _, item := range list1.Items { + itemNamespace := extractResourceNamespaceFromJSON(t, item.Value) + if itemNamespace == namespace { // Only compare items from our exact namespace + name := extractResourceNameFromJSON(t, item.Value) + items1[name] = item + } + } + + items2 := make(map[string]*resourcepb.ResourceWrapper) + for _, item := range list2.Items { + itemNamespace := extractResourceNamespaceFromJSON(t, item.Value) + if itemNamespace == namespace { // Only compare items from our exact namespace + name := extractResourceNameFromJSON(t, item.Value) + items2[name] = item + } + } + + // Verify counts match after filtering by namespace + require.Equal(t, expectedCount, len(items1), "Server 1 should return expected count after filtering") + require.Equal(t, expectedCount, len(items2), "Server 2 should return expected count after filtering") + require.Equal(t, len(items1), len(items2), "Both servers should return same count after filtering") + + // Verify all items exist in both lists with same content and resource version + for name, item1 := range items1 { + item2, exists := items2[name] + require.True(t, exists, "Item %s should exist in both lists", name) + require.Greater(t, item1.ResourceVersion, int64(0), "Item1 should have valid resource version for %s", name) + require.Greater(t, item2.ResourceVersion, int64(0), "Item2 should have valid resource version for %s", name) + require.JSONEq(t, string(item1.Value), string(item2.Value), "Content should match for %s", name) + + // Validate that both backends return equivalent resource versions using rvmanager compatibility check + if checkResourceVersions { + require.True(t, rvmanager.IsRvEqual(item1.ResourceVersion, item2.ResourceVersion) || rvmanager.IsRvEqual(item2.ResourceVersion, item1.ResourceVersion), + "Resource version mismatch for item %s between backends. Item1: %d, Item2: %d", name, item1.ResourceVersion, item2.ResourceVersion) + } + } +} + +// extractResourceNameFromJSON extracts the resource name from JSON metadata +func extractResourceNameFromJSON(t *testing.T, jsonData []byte) string { + var obj map[string]interface{} + err := json.Unmarshal(jsonData, &obj) + require.NoError(t, err, "Failed to unmarshal JSON") + + metadata, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok, "metadata field not found or not an object") + + name, ok := metadata["name"].(string) + require.True(t, ok, "name field not found or not a string") + + return name +} + +// extractResourceNamespaceFromJSON extracts the resource namespace from JSON metadata +func extractResourceNamespaceFromJSON(t *testing.T, jsonData []byte) string { + var obj map[string]interface{} + err := json.Unmarshal(jsonData, &obj) + require.NoError(t, err, "Failed to unmarshal JSON") + + metadata, ok := obj["metadata"].(map[string]interface{}) + require.True(t, ok, "metadata field not found or not an object") + + namespace, ok := metadata["namespace"].(string) + require.True(t, ok, "namespace field not found or not a string") + + return namespace +} + +// PlaylistResourceOptions defines options for creating test playlist resources +type PlaylistResourceOptions struct { + Name string + Namespace string + UID string + Generation int + Title string + Folder string // optional - empty string means no folder +} + +// createPlaylistJSON creates standardized JSON for playlist resources +func createPlaylistJSON(opts PlaylistResourceOptions) []byte { + folderAnnotation := "" + if opts.Folder != "" { + folderAnnotation = fmt.Sprintf(`, + "annotations": { + "grafana.app/folder": "%s" + }`, opts.Folder) + } + + jsonStr := fmt.Sprintf(`{ + "apiVersion": "playlist.grafana.app/v0alpha1", + "kind": "Playlist", + "metadata": { + "name": "%s", + "namespace": "%s", + "uid": "%s", + "generation": %d%s + }, + "spec": { + "title": "%s" + } + }`, opts.Name, opts.Namespace, opts.UID, opts.Generation, folderAnnotation, opts.Title) + + return []byte(jsonStr) +} + +// createPlaylistKey creates standardized ResourceKey for playlist resources +func createPlaylistKey(namespace, name string) *resourcepb.ResourceKey { + return &resourcepb.ResourceKey{ + Group: "playlist.grafana.app", + Resource: "playlists", + Namespace: namespace, + Name: name, + } +} + +// createPlaylistResource creates a playlist resource using the server with consistent error handling +func createPlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions) *resourcepb.CreateResponse { + t.Helper() + key := createPlaylistKey(opts.Namespace, opts.Name) + value := createPlaylistJSON(opts) + + created, err := server.Create(ctx, &resourcepb.CreateRequest{ + Key: key, + Value: value, + }) + require.NoError(t, err) + require.Nil(t, created.Error) + require.Greater(t, created.ResourceVersion, int64(0)) + + return created +} + +// updatePlaylistResource updates a playlist resource using the server with consistent error handling +func updatePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, opts PlaylistResourceOptions, resourceVersion int64) *resourcepb.UpdateResponse { + t.Helper() + key := createPlaylistKey(opts.Namespace, opts.Name) + value := createPlaylistJSON(opts) + + updated, err := server.Update(ctx, &resourcepb.UpdateRequest{ + Key: key, + Value: value, + ResourceVersion: resourceVersion, + }) + require.NoError(t, err) + require.Nil(t, updated.Error) + require.Greater(t, updated.ResourceVersion, int64(0)) // Just check it's positive, not necessarily greater than input + + return updated +} + +// deletePlaylistResource deletes a playlist resource using the server with consistent error handling +func deletePlaylistResource(t *testing.T, server resource.ResourceServer, ctx context.Context, namespace, name string, resourceVersion int64) *resourcepb.DeleteResponse { + t.Helper() + key := createPlaylistKey(namespace, name) + + deleted, err := server.Delete(ctx, &resourcepb.DeleteRequest{ + Key: key, + ResourceVersion: resourceVersion, + }) + require.NoError(t, err) + require.Nil(t, deleted.Error) + require.Greater(t, deleted.ResourceVersion, int64(0)) + + return deleted +} diff --git a/pkg/storage/unified/testing/storage_backend_test.go b/pkg/storage/unified/testing/storage_backend_test.go index 70e3b15aa7b..3046967adee 100644 --- a/pkg/storage/unified/testing/storage_backend_test.go +++ b/pkg/storage/unified/testing/storage_backend_test.go @@ -7,11 +7,7 @@ import ( badger "github.com/dgraph-io/badger/v4" "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" - sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" - "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" ) func TestBadgerKVStorageBackend(t *testing.T) { @@ -41,48 +37,37 @@ func TestBadgerKVStorageBackend(t *testing.T) { } func TestSQLKVStorageBackend(t *testing.T) { - newBackendFunc := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { - dbstore := db.InitTestDB(t) - eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) - require.NoError(t, err) - kv, err := resource.NewSQLKV(eDB) - require.NoError(t, err) - kvOpts := resource.KVBackendOptions{ - KvStore: kv, - } - backend, err := resource.NewKVStorageBackend(kvOpts) - require.NoError(t, err) - db, err := eDB.Init(ctx) - require.NoError(t, err) - return backend, db + skipTests := map[string]bool{ + TestWatchWriteEvents: true, + TestList: true, + TestBlobSupport: true, + TestGetResourceStats: true, + TestListHistory: true, + TestListHistoryErrorReporting: true, + TestListModifiedSince: true, + TestListTrash: true, + TestCreateNewResource: true, + TestGetResourceLastImportTime: true, + TestOptimisticLocking: true, } - RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { - backend, _ := newBackendFunc(ctx) - return backend - }, &TestOptions{ - NSPrefix: "sqlkvstorage-test", - SkipTests: map[string]bool{ - TestHappyPath: true, - TestWatchWriteEvents: true, - TestList: true, - TestBlobSupport: true, - TestGetResourceStats: true, - TestListHistory: true, - TestListHistoryErrorReporting: true, - TestListModifiedSince: true, - TestListTrash: true, - TestCreateNewResource: true, - TestGetResourceLastImportTime: true, - TestOptimisticLocking: true, - TestKeyPathGeneration: true, - }, + t.Run("Without RvManager", func(t *testing.T) { + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := NewTestSqlKvBackend(t, ctx, false) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstorage-test", + SkipTests: skipTests, + }) }) - RunSQLStorageBackendCompatibilityTest(t, newBackendFunc, &TestOptions{ - NSPrefix: "sqlkvstorage-compatibility-test", - SkipTests: map[string]bool{ - TestKeyPathGeneration: true, - }, + t.Run("With RvManager", func(t *testing.T) { + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := NewTestSqlKvBackend(t, ctx, true) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstorage-withrvmanager-test", + SkipTests: skipTests, + }) }) } diff --git a/pkg/tests/apis/collections/stars_test.go b/pkg/tests/apis/collections/stars_test.go index 949e3d6c6ac..960fdc4e5c4 100644 --- a/pkg/tests/apis/collections/stars_test.go +++ b/pkg/tests/apis/collections/stars_test.go @@ -140,7 +140,8 @@ func TestIntegrationStars(t *testing.T) { }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "removed dashboard star") - rspObj, err := starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) + adminStarName := "user-" + starsClient.Args.User.Identity.GetIdentifier() + rspObj, err := starsClient.Resource.Get(ctx, adminStarName, metav1.GetOptions{}) require.NoError(t, err) after := typed(t, rspObj, &collections.Stars{}) @@ -154,7 +155,7 @@ func TestIntegrationStars(t *testing.T) { rspObj, err = starsClient.Resource.Update(ctx, &unstructured.Unstructured{ Object: map[string]any{ "metadata": map[string]any{ - "name": "user-" + starsClient.Args.User.Identity.GetIdentifier(), + "name": adminStarName, "namespace": "default", }, "spec": map[string]any{ @@ -179,7 +180,15 @@ func TestIntegrationStars(t *testing.T) { []string{"test-2", "aaa", "bbb"}, // keeps the requested order, removing duplicates resources[0].Names) - rspObj, err = starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) + // Now add a star with the sub-resource route used by the UI + client := helper.Org1.Admin.RESTClient(t, &collections.GroupVersion) + res := client.Put().AbsPath("apis", "collections.grafana.app", "v1alpha1", + "namespaces", "default", + "stars", adminStarName, + "update", "dashboard.grafana.app", "Dashboard", "xxx").Do(ctx) + require.NoError(t, res.Error()) + + rspObj, err = starsClient.Resource.Get(ctx, adminStarName, metav1.GetOptions{}) require.NoError(t, err) after = typed(t, rspObj, &collections.Stars{}) @@ -197,7 +206,8 @@ func TestIntegrationStars(t *testing.T) { "names": [ "aaa", "bbb", - "test-2" + "test-2", + "xxx" ] } ] @@ -212,6 +222,17 @@ func TestIntegrationStars(t *testing.T) { rspObj, err = starsClientViewer.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) require.Error(t, err) require.Nil(t, rspObj) + + // Use the API to star a non-dashboard resource + res = client.Put().AbsPath("apis", "collections.grafana.app", "v1alpha1", + "namespaces", "default", + "stars", adminStarName, + "update", "servicemodel.ext.grafana.com", "Component", "xxx").Do(ctx) + if mode == grafanarest.Mode5 { + require.NoError(t, res.Error()) + } else { + require.Error(t, res.Error(), "only dashboard stars are supported until the migration to unified storage is complete") + } }) } } diff --git a/pkg/tests/apis/config_test.go b/pkg/tests/apis/config_test.go deleted file mode 100644 index 665d79a5e50..00000000000 --- a/pkg/tests/apis/config_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package apis - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/tests/testinfra" - "github.com/grafana/grafana/pkg/util/testutil" -) - -const pluginsDiscoveryJSON = `[ -{ - "version": "v0alpha1", - "freshness": "Current", - "resources": [ - { - "resource": "metas", - "responseKind": { - "group": "", - "kind": "Meta", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "meta", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "Meta", - "version": "" - }, - "subresource": "status", - "verbs": [ - "get", - "patch", - "update" - ] - } - ], - "verbs": [ - "get", - "list" - ] - }, - { - "resource": "plugins", - "responseKind": { - "group": "", - "kind": "Plugin", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "plugin", - "subresources": [ - { - "responseKind": { - "group": "", - "kind": "Plugin", - "version": "" - }, - "subresource": "status", - "verbs": [ - "get", - "patch", - "update" - ] - } - ], - "verbs": [ - "create", - "delete", - "deletecollection", - "get", - "list", - "patch", - "update", - "watch" - ] - } - ] -} -]` - -func setupHelper(t *testing.T, openFeatureAPIEnabled bool) *K8sTestHelper { - t.Helper() - helper := NewK8sTestHelper(t, testinfra.GrafanaOpts{ - AppModeProduction: true, - DisableAnonymous: true, - APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true", - OpenFeatureAPIEnabled: openFeatureAPIEnabled, - }) - t.Cleanup(func() { helper.Shutdown() }) - return helper -} - -func TestIntegrationAPIServerRuntimeConfig(t *testing.T) { - testutil.SkipIntegrationTestInShortMode(t) - - t.Run("discovery with openfeature api enabled", func(t *testing.T) { - helper := setupHelper(t, true) - disco, err := helper.GetGroupVersionInfoJSON("features.grafana.app") - require.NoError(t, err) - require.JSONEq(t, `[ - { - "freshness": "Current", - "resources": [ - { - "resource": "noop", - "responseKind": { - "group": "", - "kind": "Status", - "version": "" - }, - "scope": "Namespaced", - "singularResource": "noop", - "verbs": [ - "get" - ] - } - ], - "version": "v0alpha1" - } - ]`, disco) - - // plugins should still be discoverable - disco, err = helper.GetGroupVersionInfoJSON("plugins.grafana.app") - require.NoError(t, err) - require.JSONEq(t, pluginsDiscoveryJSON, disco) - require.NoError(t, err) - }) - - t.Run("discovery with openfeature api false", func(t *testing.T) { - helper := setupHelper(t, false) - _, err := helper.GetGroupVersionInfoJSON("features.grafana.app") - require.Error(t, err, "expected error when openfeature api is disabled") - - // plugins should still be discoverable - disco, err := helper.GetGroupVersionInfoJSON("plugins.grafana.app") - require.NoError(t, err) - require.JSONEq(t, pluginsDiscoveryJSON, disco) - require.NoError(t, err) - }) -} diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index ee40bf00b47..96a364cb8bc 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/require" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -132,6 +133,94 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { } } +func TestIntegrationDashboardAPIZanzana(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableAuthZClientCache: true, + DisableZanzanaCache: true, + DisableZanzanaServerCheckQueryCache: true, + ZanzanaReconciliationInterval: 1 * time.Second, + APIServerStorageType: "unified", + DBMaxConns: 10, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: rest.Mode5, + }, + "folders.folder.grafana.app": { + DualWriterMode: rest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + "zanzana", + "zanzanaNoLegacyClient", + "kubernetesAuthzZanzanaSync", + }, + UnifiedStorageEnableSearch: true, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + org1Ctx := createTestContext(t, helper, helper.Org1, rest.Mode5) + org2Ctx := createTestContext(t, helper, helper.OrgB, rest.Mode5) + + t.Run("Dashboard permission tests", func(t *testing.T) { + runDashboardPermissionTests(t, org1Ctx, true) + }) + + t.Run("Authorization tests for all identity types", func(t *testing.T) { + runAuthorizationTests(t, org1Ctx) + }) + t.Run("Dashboard HTTP API test", func(t *testing.T) { + runDashboardHttpTest(t, org1Ctx, org2Ctx) + }) + + t.Run("Cross-organization tests", func(t *testing.T) { + runCrossOrgTests(t, org1Ctx, org2Ctx) + }) +} + +// list tests will go very slowly if the cache is disabled - allow the cache solely for Lists +func TestIntegrationDashboardAPIZanzanaList(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + DBMaxConns: 4, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: rest.Mode5, + }, + "folders.folder.grafana.app": { + DualWriterMode: rest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + "zanzana", + "zanzanaNoLegacyClient", + "kubernetesAuthzZanzanaSync", + }, + UnifiedStorageEnableSearch: true, + ZanzanaReconciliationInterval: 100 * time.Millisecond, + }) + + t.Cleanup(func() { + helper.Shutdown() + }) + + org1Ctx := createTestContext(t, helper, helper.Org1, rest.Mode5) + + runDashboardListTests(t, org1Ctx) +} + // TestIntegrationDashboardAPI tests the dashboard K8s API func TestIntegrationDashboardAPI(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -211,11 +300,11 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("reject dashboard with existing UID", func(t *testing.T) { // Create a dashboard with a specific UID specificUID := "existing-uid-dash" - createdDash, err := createDashboard(t, adminClient, "Dashboard with Specific UID", nil, &specificUID) + createdDash, err := createDashboard(t, adminClient, "Dashboard with Specific UID", nil, &specificUID, ctx.Helper) require.NoError(t, err) // Try to create another dashboard with the same UID - _, err = createDashboard(t, adminClient, "Another Dashboard with Same UID", nil, &specificUID) + _, err = createDashboard(t, adminClient, "Another Dashboard with Same UID", nil, &specificUID, ctx.Helper) require.Error(t, err) // Clean up @@ -227,14 +316,14 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("reject dashboard with too long UID", func(t *testing.T) { // Create a dashboard with a long UID (over 40 chars) longUID := "this-uid-is-way-too-long-for-a-dashboard-uid-12345678901234567890" - _, err := createDashboard(t, adminClient, "Dashboard with Long UID", nil, &longUID) + _, err := createDashboard(t, adminClient, "Dashboard with Long UID", nil, &longUID, ctx.Helper) require.Error(t, err) }) // Test creating dashboard with invalid UID characters t.Run("reject dashboard with invalid UID characters", func(t *testing.T) { invalidUID := "invalid/uid/with/slashes" - _, err := createDashboard(t, adminClient, "Dashboard with Invalid UID", nil, &invalidUID) + _, err := createDashboard(t, adminClient, "Dashboard with Invalid UID", nil, &invalidUID, ctx.Helper) require.Error(t, err) }) }) @@ -243,21 +332,21 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("Dashboard title validations", func(t *testing.T) { // Test empty title t.Run("reject dashboard with empty title", func(t *testing.T) { - _, err := createDashboard(t, adminClient, "", nil, nil) + _, err := createDashboard(t, adminClient, "", nil, nil, ctx.Helper) require.Error(t, err) }) // Test long title t.Run("reject dashboard with excessively long title", func(t *testing.T) { veryLongTitle := strings.Repeat("a", 10000) - _, err := createDashboard(t, adminClient, veryLongTitle, nil, nil) + _, err := createDashboard(t, adminClient, veryLongTitle, nil, nil, ctx.Helper) require.Error(t, err) }) // Test updating dashboard with empty title t.Run("reject dashboard update with empty title", func(t *testing.T) { // First create a valid dashboard - dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil) + dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -273,7 +362,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test updating dashboard with excessively long title t.Run("reject dashboard update with excessively long title", func(t *testing.T) { // First create a valid dashboard - dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil) + dash, err := createDashboard(t, adminClient, "Valid Dashboard Title", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -291,7 +380,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("Dashboard message validations", func(t *testing.T) { // Test long message t.Run("reject dashboard with excessively long update message", func(t *testing.T) { - dash, err := createDashboard(t, adminClient, "Regular dashboard", nil, nil) + dash, err := createDashboard(t, adminClient, "Regular dashboard", nil, nil, ctx.Helper) require.NoError(t, err) veryLongMessage := strings.Repeat("a", 600) @@ -304,18 +393,78 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { }) }) + t.Run("Dashboard tag validations", func(t *testing.T) { + t.Run("reject dashboard with tag over 50 characters on creation", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Long Tag", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"} + _ = meta.SetSpec(specMap) + _, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + }) + + t.Run("reject dashboard update with tag over 50 characters", func(t *testing.T) { + dash, err := createDashboard(t, adminClient, "Valid Dashboard", nil, nil, ctx.Helper) + require.NoError(t, err) + require.NotNil(t, dash) + meta, _ := utils.MetaAccessor(dash) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"} + _ = meta.SetSpec(specMap) + _, err = adminClient.Resource.Update(context.Background(), dash, v1.UpdateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("accept dashboard with tag at 50 characters", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Valid Tag", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{"this-tag-is-exactly-fifty-characters-long-12345"} + _ = meta.SetSpec(specMap) + createdDash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, createdDash) + err = adminClient.Resource.Delete(context.Background(), createdDash.GetName(), v1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Run("reject dashboard with multiple tags where one exceeds limit", func(t *testing.T) { + dashObj := createDashboardObject(t, "Dashboard with Mixed Tags", "", 0) + meta, _ := utils.MetaAccessor(dashObj) + spec, _ := meta.GetSpec() + specMap := spec.(map[string]interface{}) + specMap["tags"] = []string{ + "valid-tag", + "another-valid-tag", + "this-is-a-very-long-tag-that-exceeds-fifty-characters-limit", + } + _ = meta.SetSpec(specMap) + _, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "tag too long") + }) + }) + t.Run("Dashboard folder validations", func(t *testing.T) { // Test non-existent folder UID t.Run("reject dashboard with non-existent folder UID", func(t *testing.T) { nonExistentFolderUID := "non-existent-folder-uid" - _, err := createDashboard(t, adminClient, "Dashboard in Non-existent Folder", &nonExistentFolderUID, nil) + _, err := createDashboard(t, adminClient, "Dashboard in Non-existent Folder", &nonExistentFolderUID, nil, ctx.Helper) ctx.Helper.EnsureStatusError(err, http.StatusNotFound, "folders.folder.grafana.app \"non-existent-folder-uid\" not found") }) t.Run("allow moving folder to general folder", func(t *testing.T) { folder1 := createFolderObject(t, "folder1", "default", "") folder1UID := folder1.GetName() - dash, err := createDashboard(t, adminClient, "Dashboard in a Folder", &folder1UID, nil) + dash, err := createDashboard(t, adminClient, "Dashboard in a Folder", &folder1UID, nil, ctx.Helper) require.NoError(t, err) generalFolderUID := "" @@ -437,7 +586,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test version increment on update t.Run("version increments on dashboard update", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Version Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Version Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for version test") dashUID := dash.GetName() @@ -464,7 +613,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Test generation conflict when updating concurrently t.Run("reject update with version conflict", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Version Conflict Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Version Conflict Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for version conflict test") dashUID := dash.GetName() @@ -517,7 +666,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { t.Run("dashboard version history available, even for UIDs ending in hyphen", func(t *testing.T) { dashboardUID := "test-dashboard-" - dash, err := createDashboard(t, adminClient, "Dashboard with uid ending in hyphen", nil, &dashboardUID) + dash, err := createDashboard(t, adminClient, "Dashboard with uid ending in hyphen", nil, &dashboardUID, ctx.Helper) require.NoError(t, err) updatedDash, err := updateDashboard(t, adminClient, dash, "Updated dashboard with uid ending in hyphen", nil) @@ -564,7 +713,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for Provisioning Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Provisioning Test", nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create dashboard for provisioning test") dashUID := dash.GetName() @@ -689,7 +838,7 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) { // Create a dashboard with a specific UID to make it easier to manage specificUID := "size-limit-test-dash" - dash, err := createDashboard(t, adminClient, "Dashboard Exceeding Size Limit", nil, &specificUID) + dash, err := createDashboard(t, adminClient, "Dashboard Exceeding Size Limit", nil, &specificUID, ctx.Helper) require.NoError(t, err) meta, _ := utils.MetaAccessor(dash) @@ -877,11 +1026,11 @@ func runQuotaTests(t *testing.T, ctx TestContext) { require.NoError(t, err, "Failed to update quota") // Create first dashboard - should succeed - dash1, err := createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 1 (%s)", tc.name), nil, nil) + dash1, err := createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 1 (%s)", tc.name), nil, nil, ctx.Helper) require.NoError(t, err, "Failed to create first dashboard") // Create second dashboard - should fail due to quota - _, err = createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 2 (%s)", tc.name), nil, nil) + _, err = createDashboard(t, adminClient, fmt.Sprintf("Quota Test Dashboard 2 (%s)", tc.name), nil, nil, ctx.Helper) require.Error(t, err, "Creating second dashboard should fail due to quota") require.Contains(t, err.Error(), "quota", "Error should mention quota") @@ -911,6 +1060,8 @@ func runQuotaTests(t *testing.T, ctx TestContext) { // Helper function to create test context for an organization func createTestContext(t *testing.T, helper *apis.K8sTestHelper, orgUsers apis.OrgUsers, dualWriterMode rest.DualWriterMode) TestContext { + apis.AwaitZanzanaReconcileNext(t, helper) + // Create test folder folderTitle := "Test Folder Org " + strconv.FormatInt(orgUsers.Admin.Identity.GetOrgID(), 10) testFolder, err := createFolder(t, helper, orgUsers.Admin, folderTitle) @@ -1013,6 +1164,8 @@ func createFolder(t *testing.T, helper *apis.K8sTestHelper, user apis.User, titl return nil, err } + apis.AwaitZanzanaReconcileNext(t, helper) + meta, _ := utils.MetaAccessor(createdFolder) // Create a folder struct to return (for compatibility with existing code) @@ -1087,7 +1240,7 @@ func markDashboardObjectAsProvisioned(t *testing.T, dashboard *unstructured.Unst } // Create a dashboard -func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, folderUID *string, uid *string) (*unstructured.Unstructured, error) { +func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, folderUID *string, uid *string, helper *apis.K8sTestHelper) (*unstructured.Unstructured, error) { t.Helper() var folderUIDStr string @@ -1111,6 +1264,8 @@ func createDashboard(t *testing.T, client *apis.K8sResourceClient, title string, return nil, err } + apis.AwaitZanzanaReconcileNext(t, helper) + // Fetch the generated object to ensure we're not running into any caching or UID mismatch issues databaseDash, err := client.Resource.Get(context.Background(), createdDash.GetName(), v1.GetOptions{}) if err != nil { @@ -1254,11 +1409,13 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { {name: "in folder", folderUID: ctx.TestFolder.UID}, } + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) + for _, loc := range locations { t.Run(loc.name, func(t *testing.T) { if roleCapabilities.canCreate { // Test can create dashboard - dash, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, &loc.folderUID, nil) + dash, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, &loc.folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1274,7 +1431,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { require.NoError(t, err) } else { // Test cannot create dashboard - _, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, nil, nil) + _, err := createDashboard(t, identity.DashboardClient, identity.Name+" Dashboard "+loc.name, nil, nil, ctx.Helper) require.Error(t, err) } }) @@ -1284,7 +1441,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard updates t.Run("dashboard update", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard to Update by "+identity.Name, nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard to Update by "+identity.Name, nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1311,7 +1468,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard deletion permissions t.Run("dashboard deletion", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for deletion test by "+identity.Name, nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for deletion test by "+identity.Name, nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1331,7 +1488,7 @@ func runAuthorizationTests(t *testing.T, ctx TestContext) { // Test dashboard viewing for all roles t.Run("dashboard viewing", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard for "+identity.Name+" to view", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for "+identity.Name+" to view", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1363,7 +1520,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test custom dashboard permissions t.Run("Dashboard with custom permissions", func(t *testing.T) { // Create a dashboard with admin - dash, err := createDashboard(t, adminClient, "Dashboard with Custom Permissions", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard with Custom Permissions", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1394,12 +1551,12 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test dashboard-specific permission overrides (new test case) t.Run("Dashboard-specific permission overrides", func(t *testing.T) { // Create multiple dashboards with admin - dash1, err := createDashboard(t, adminClient, "Dashboard with No Custom Permissions", nil, nil) + dash1, err := createDashboard(t, adminClient, "Dashboard with No Custom Permissions", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash1) dash1UID := dash1.GetName() - dash2, err := createDashboard(t, adminClient, "Dashboard with Viewer Edit Permission", nil, nil) + dash2, err := createDashboard(t, adminClient, "Dashboard with Viewer Edit Permission", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash2) dash2UID := dash2.GetName() @@ -1443,7 +1600,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo setResourceUserPermission(t, ctx, ctx.AdminUser, false, folderUID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) // Create a dashboard in the folder with admin - dash, err := createDashboard(t, adminClient, "Dashboard in Custom Permission Folder", &folderUID, nil) + dash, err := createDashboard(t, adminClient, "Dashboard in Custom Permission Folder", &folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) @@ -1462,7 +1619,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo require.Equal(t, "Updated by Viewer with Folder Permission", meta.FindTitle("")) // User should be able to create a dashboard in the folder - dashViewer, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Custom Permission Folder", &folderUID, nil) + dashViewer, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Custom Permission Folder", &folderUID, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dashViewer) @@ -1509,7 +1666,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo setResourceUserPermission(t, ctx, ctx.AdminUser, false, folder2UID, addUserPermission(t, nil, ctx.ViewerUser, ResourcePermissionLevelEdit)) // Have the viewer create a dashboard in folder2 - viewerDash, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Edit Permission Folder", &folder2UID, nil) + viewerDash, err := createDashboard(t, viewerClient, "Dashboard created by Viewer in Edit Permission Folder", &folder2UID, nil, ctx.Helper) require.NoError(t, err, "Viewer should be able to create dashboard in folder with edit permissions") require.NotNil(t, viewerDash) dashUID := viewerDash.GetName() @@ -1544,7 +1701,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo // Test creator permissions (new test case) t.Run("Creator of dashboard gets admin permission", func(t *testing.T) { // Create a dashboard as an editor user (not admin) - editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor", nil, nil) + editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, editorCreatedDash) dashUID := editorCreatedDash.GetName() @@ -1575,7 +1732,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo t.Run("Admin can override creator permissions", func(t *testing.T) { t.Skip("Have to double check if that's actually the case") // Create a dashboard as an editor user (not admin) - editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor for Permission Test", nil, nil) + editorCreatedDash, err := createDashboard(t, editorClient, "Dashboard Created by Editor for Permission Test", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, editorCreatedDash) dashUID := editorCreatedDash.GetName() @@ -1614,7 +1771,7 @@ func runDashboardPermissionTests(t *testing.T, ctx TestContext, kubernetesDashbo otherOrgClient := getResourceClient(t, ctx.Helper, ctx.Helper.OrgB.Viewer, getDashboardGVR()) // Create a dashboard with admin in the current org - dash, err := createDashboard(t, adminClient, "Dashboard for Cross-Org Permissions Test", nil, nil) + dash, err := createDashboard(t, adminClient, "Dashboard for Cross-Org Permissions Test", nil, nil, ctx.Helper) require.NoError(t, err) require.NotNil(t, dash) org1DashUID := dash.GetName() @@ -1703,11 +1860,11 @@ func runCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) { dashTitle := "Cross-Org Dashboard" // Create in org1 - dash1, err := createDashboard(t, org1SuperAdminClient, dashTitle, nil, &uid) + dash1, err := createDashboard(t, org1SuperAdminClient, dashTitle, nil, &uid, org1Ctx.Helper) require.NoError(t, err, "Failed to create dashboard in org1") // Create in org2 with same UID - should succeed (UIDs only need to be unique within an org) - dash2, err := createDashboard(t, org2SuperAdminClient, dashTitle, nil, &uid) + dash2, err := createDashboard(t, org2SuperAdminClient, dashTitle, nil, &uid, org2Ctx.Helper) require.NoError(t, err, "Failed to create dashboard with same UID in org2") // Verify both dashboards were created @@ -1793,12 +1950,12 @@ func runCrossOrgTests(t *testing.T, org1Ctx, org2Ctx TestContext) { // Test cross-organization access t.Run("Cross-organization access", func(t *testing.T) { // Create dashboards in both orgs - org1Dashboard, err := createDashboard(t, org1SuperAdminClient, "Org1 Dashboard", nil, nil) + org1Dashboard, err := createDashboard(t, org1SuperAdminClient, "Org1 Dashboard", nil, nil, org1Ctx.Helper) require.NoError(t, err) require.NotNil(t, org1Dashboard) org1DashUID := org1Dashboard.GetName() - org2Dashboard, err := createDashboard(t, org2SuperAdminClient, "Org2 Dashboard", nil, nil) + org2Dashboard, err := createDashboard(t, org2SuperAdminClient, "Org2 Dashboard", nil, nil, org2Ctx.Helper) require.NoError(t, err) require.NotNil(t, org2Dashboard) org2DashUID := org2Dashboard.GetName() @@ -1957,6 +2114,8 @@ func setResourceUserPermission(t *testing.T, ctx TestContext, actingUser apis.Us // Check response status code require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for %s", resourceUID) + + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) } // Test creating a dashboard via HTTP and deleting it @@ -2033,6 +2192,7 @@ func runDashboardHttpTest(t *testing.T, ctx TestContext, foreignOrgCtx TestConte for _, userTC := range userTestCases { testName := fmt.Sprintf("%s by %s", locTC.name, userTC.name) t.Run(testName, func(t *testing.T) { + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) // Create a unique dashboard UID - ensure it's 40 chars max dashboardUID := fmt.Sprintf("test-%s-%s-%s", "POST", @@ -2078,6 +2238,8 @@ func runDashboardHttpTest(t *testing.T, ctx TestContext, foreignOrgCtx TestConte ContentType: "application/json", }, &struct{}{}) + apis.AwaitZanzanaReconcileNext(t, ctx.Helper) + // Check if the creation was successful or failed as expected adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) @@ -2421,7 +2583,7 @@ func runDashboardListTests(t *testing.T, ctx TestContext) { // Create all test resources (folders, dashboards) in one loop for i, fc := range folderConfigs { // Create root dashboard - rootDash, err := createDashboard(t, adminClient, fmt.Sprintf("Root Dashboard - %s", fc.name), nil, nil) + rootDash, err := createDashboard(t, adminClient, fmt.Sprintf("Root Dashboard - %s", fc.name), nil, nil, ctx.Helper) require.NoError(t, err) rootDashboards[i] = rootDash fc.permissions(t, ctx, rootDash.GetName(), true) @@ -2433,7 +2595,7 @@ func runDashboardListTests(t *testing.T, ctx TestContext) { fc.permissions(t, ctx, folder.UID, false) // Create dashboard in folder - folderDash, err := createDashboard(t, adminClient, fmt.Sprintf("Dashboard in %s folder", fc.name), &folder.UID, nil) + folderDash, err := createDashboard(t, adminClient, fmt.Sprintf("Dashboard in %s folder", fc.name), &folder.UID, nil, ctx.Helper) require.NoError(t, err) folderDashboards[i] = folderDash } @@ -2594,10 +2756,10 @@ func runDashboardTrashTests(t *testing.T, ctx TestContext) { t.Run("regular dashboards appear in trash but provisioned ones do not", func(t *testing.T) { // create two dashboards, one that is provisioned and one that is not - regularDash, err := createDashboard(t, adminClient, "Regular Dashboard for Trash Comparison", nil, nil) + regularDash, err := createDashboard(t, adminClient, "Regular Dashboard for Trash Comparison", nil, nil, ctx.Helper) require.NoError(t, err) regularDashUID := regularDash.GetName() - provisionedDash, err := createDashboard(t, adminClient, "Provisioned Dashboard for Trash Comparison", nil, nil) + provisionedDash, err := createDashboard(t, adminClient, "Provisioned Dashboard for Trash Comparison", nil, nil, ctx.Helper) require.NoError(t, err) provisionedDashUID := provisionedDash.GetName() meta, err := utils.MetaAccessor(provisionedDash) @@ -2626,7 +2788,7 @@ func runDashboardTrashTests(t *testing.T, ctx TestContext) { }) t.Run("permission checks - admin can see everything, users can see their own deleted items", func(t *testing.T) { - dash, err := createDashboard(t, editorClient, "Dashboard for Trash Test", nil, nil) + dash, err := createDashboard(t, editorClient, "Dashboard for Trash Test", nil, nil, ctx.Helper) require.NoError(t, err) dashUID := dash.GetName() err = editorClient.Resource.Delete(context.Background(), dashUID, v1.DeleteOptions{}) diff --git a/pkg/tests/apis/dashboard/search_test.go b/pkg/tests/apis/dashboard/search_test.go index 3fe35c51b3a..2227c67287e 100644 --- a/pkg/tests/apis/dashboard/search_test.go +++ b/pkg/tests/apis/dashboard/search_test.go @@ -4,10 +4,15 @@ import ( "context" "encoding/json" "fmt" + "io/fs" + "math" "net/http" + "os" + "path/filepath" "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" @@ -16,12 +21,167 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/util/testutil" ) +func TestIntegrationSearchDevDashboards(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + ctx := context.Background() + + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5}, + "folders.folder.grafana.app": {DualWriterMode: rest.Mode5}, + }, + UnifiedStorageEnableSearch: true, + }) + defer helper.Shutdown() + + // Create devenv dashboards from legacy API + cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig()) + cfg.GroupVersion = &dashboardV0.GroupVersion + adminClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + adminClient.Get() + + fileCount := 0 + devenv := "../../../../devenv/dev-dashboards/panel-timeseries" + err = filepath.WalkDir(devenv, func(p string, d fs.DirEntry, e error) error { + require.NoError(t, err) + if d.IsDir() || filepath.Ext(d.Name()) != ".json" { + return nil + } + + // use the filename as UID + uid := strings.TrimSuffix(d.Name(), ".json") + if len(uid) > 40 { + uid = uid[:40] // avoid uid too long, max 40 characters + } + + // nolint:gosec + data, err := os.ReadFile(p) + require.NoError(t, err) + + cmd := dashboards.SaveDashboardCommand{ + Dashboard: &simplejson.Json{}, + Overwrite: true, + } + err = cmd.Dashboard.FromDB(data) + require.NoError(t, err) + cmd.Dashboard.Set("id", nil) + cmd.Dashboard.Set("uid", uid) + data, err = json.Marshal(cmd) + require.NoError(t, err) + + var statusCode int + result := adminClient.Post().AbsPath("api", "dashboards", "db"). + Body(data). + SetHeader("Content-type", "application/json"). + Do(ctx). + StatusCode(&statusCode) + require.NoError(t, result.Error(), "file: [%d] %s [status:%d]", fileCount, d.Name(), statusCode) + require.Equal(t, int(http.StatusOK), statusCode) + fileCount++ + return nil + }) + require.NoError(t, err) + require.Equal(t, 16, fileCount, "file count from %s", devenv) + + // Helper to call search + callSearch := func(user apis.User, params string) dashboardV0.SearchResults { + require.NotNil(t, user) + ns := user.Identity.GetNamespace() + cfg := dynamic.ConfigFor(user.NewRestConfig()) + cfg.GroupVersion = &dashboardV0.GroupVersion + restClient, err := k8srest.RESTClientFor(cfg) + require.NoError(t, err) + + var statusCode int + req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search"). + Param("limit", "1000"). + Param("type", "dashboard") // Only search dashboards + + for kv := range strings.SplitSeq(params, "&") { + if kv == "" { + continue + } + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 { + req = req.Param(parts[0], parts[1]) + } + } + res := req.Do(ctx).StatusCode(&statusCode) + require.NoError(t, res.Error()) + require.Equal(t, int(http.StatusOK), statusCode) + var sr dashboardV0.SearchResults + raw, err := res.Raw() + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &sr)) + + // Normalize scores and query cost for snapshot comparison + sr.QueryCost = 0 // this depends on the hardware + sr.MaxScore = roundTo(sr.MaxScore, 3) + for i := range sr.Hits { + sr.Hits[i].Score = roundTo(sr.Hits[i].Score, 3) // 0.6250571494814442 -> 0.625 + } + return sr + } + + // Compare a results to snapshots + testCases := []struct { + name string + user apis.User + params string + }{ + { + name: "all", + user: helper.Org1.Admin, + params: "", // only dashboards + }, + { + name: "simple-query", + user: helper.Org1.Admin, + params: "query=stacking", + }, + { + name: "with-text-panel", + user: helper.Org1.Admin, + params: "field=panel_types&panelType=text", + }, + } + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res := callSearch(tc.user, tc.params) + jj, err := json.MarshalIndent(res, "", " ") + require.NoError(t, err) + + fname := fmt.Sprintf("testdata/searchV0/t%02d-%s.json", i, tc.name) + // nolint:gosec + snapshot, err := os.ReadFile(fname) + if err != nil { + assert.Failf(t, "Failed to read snapshot", "file: %s", fname) + err = os.WriteFile(fname, jj, 0o644) + require.NoErrorf(t, err, "Failed to write snapshot file %s", fname) + return + } + + if !assert.JSONEq(t, string(snapshot), string(jj)) { + err = os.WriteFile(fname, jj, 0o644) + require.NoErrorf(t, err, "Failed to write snapshot file %s", fname) + } + }) + } +} + func TestIntegrationSearchPermissionFiltering(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -285,3 +445,11 @@ func setFolderPermissions(t *testing.T, helper *apis.K8sTestHelper, actingUser a require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for folder %s", folderUID) } + +// roundTo rounds a float64 to a specified number of decimal places. +func roundTo(n float64, decimals uint32) float64 { + // Calculate the power of 10 for the desired number of decimals + scale := math.Pow(10, float64(decimals)) + // Multiply, round to the nearest integer, and then divide back + return math.Round(n*scale) / scale +} diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json b/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json new file mode 100644 index 00000000000..35b7cff0302 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t00-all.json @@ -0,0 +1,165 @@ +{ + "totalHits": 16, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries", + "title": "Panel Tests - Graph NG", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-by-value-color-schemes", + "title": "Panel Tests - Graph NG - By value color schemes", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-nulls", + "title": "Panel Tests - Graph NG - Discrete panels", + "tags": [ + "gdev", + "panel-tests", + "graph-ng", + "timeseries", + "trend", + "state-timeline", + "transform" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-gradient-area", + "title": "Panel Tests - Graph NG - Gradient Area Fills", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-soft-limits", + "title": "Panel Tests - Graph NG - softMin/softMax", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-yaxis-ticks", + "title": "Panel Tests - Graph NG - Y axis ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-hue-gradients", + "title": "Panel Tests - GraphNG - Hue Gradients", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-time", + "title": "Panel Tests - GraphNG - Time Axis", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-thresholds", + "title": "Panel Tests - GraphNG Thresholds", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-shared-tooltip-cursor-positio", + "title": "Panel Tests - shared tooltips cursor positioning", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-bars-high-density", + "title": "Panel Tests - TimeSeries - bars high density (stroke + fill)", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-out-of-rage", + "title": "Panel Tests - Timeseries - Out of range", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-stacking", + "title": "Panel Tests - TimeSeries - stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-formats", + "title": "Panel Tests - Timeseries - Supported input formats" + }, + { + "resource": "dashboards", + "name": "timeseries-stacking2", + "title": "TimeSeries \u0026 BarChart Stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + }, + { + "resource": "dashboards", + "name": "timeseries-y-ticks-zero-decimals", + "title": "Zero Decimals Y Ticks", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ] + } + ], + "maxScore": 1 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json b/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json new file mode 100644 index 00000000000..6c9a935dfe8 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t01-simple-query.json @@ -0,0 +1,28 @@ +{ + "totalHits": 2, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-stacking", + "title": "Panel Tests - TimeSeries - stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.658 + }, + { + "resource": "dashboards", + "name": "timeseries-stacking2", + "title": "TimeSeries \u0026 BarChart Stacking", + "tags": [ + "gdev", + "panel-tests", + "graph-ng" + ], + "score": 0.625 + } + ], + "maxScore": 0.658 +} \ No newline at end of file diff --git a/pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json b/pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json new file mode 100644 index 00000000000..b38cfb14b40 --- /dev/null +++ b/pkg/tests/apis/dashboard/testdata/searchV0/t02-with-text-panel.json @@ -0,0 +1,18 @@ +{ + "totalHits": 1, + "hits": [ + { + "resource": "dashboards", + "name": "timeseries-formats", + "title": "Panel Tests - Timeseries - Supported input formats", + "field": { + "panel_types": [ + "table", + "text", + "timeseries" + ] + } + } + ], + "maxScore": 1.778 +} \ No newline at end of file diff --git a/pkg/tests/apis/datasource/testdata_test.go b/pkg/tests/apis/datasource/testdata_test.go index 9a94bea5dce..85450ec6536 100644 --- a/pkg/tests/apis/datasource/testdata_test.go +++ b/pkg/tests/apis/datasource/testdata_test.go @@ -62,7 +62,7 @@ func TestIntegrationTestDatasource(t *testing.T) { t.Run("Admin configs", func(t *testing.T) { client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ - Group: "testdata.datasource.grafana.app", + Group: "grafana-testdata-datasource.datasource.grafana.app", Version: "v0alpha1", Resource: "datasources", }).Namespace("default") @@ -92,7 +92,7 @@ func TestIntegrationTestDatasource(t *testing.T) { t.Run("Call subresources", func(t *testing.T) { client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{ - Group: "testdata.datasource.grafana.app", + Group: "grafana-testdata-datasource.datasource.grafana.app", Version: "v0alpha1", Resource: "datasources", }).Namespace("default") @@ -128,7 +128,7 @@ func TestIntegrationTestDatasource(t *testing.T) { raw := apis.DoRequest[any](helper, apis.RequestParams{ User: helper.Org1.Admin, Method: "GET", - Path: "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource", + Path: "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource", }, nil) // endpoint is disabled currently because it has not been // sufficiently tested. diff --git a/pkg/tests/apis/features/features_test.go b/pkg/tests/apis/features/features_test.go index ab7bfea64d4..0a8c9564492 100644 --- a/pkg/tests/apis/features/features_test.go +++ b/pkg/tests/apis/features/features_test.go @@ -44,6 +44,6 @@ func TestIntegrationFeatures(t *testing.T) { "value": true, "key":"`+flag+`", "reason":"static provider evaluation result", - "variant":"enabled"}`, string(rsp.Body)) + "variant":"default"}`, string(rsp.Body)) }) } diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 4d95d64b024..227dec47d73 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -33,15 +33,15 @@ import ( ) func TestIntegrationFolderTreeZanzana(t *testing.T) { - // TODO: Add back OSS seeding and enable this test - t.Skip("Skipping folder tree test with Zanzana") testutil.SkipIntegrationTestInShortMode(t) runIntegrationFolderTree(t, testinfra.GrafanaOpts{ - DisableDataMigrations: true, - AppModeProduction: true, - DisableAnonymous: true, - APIServerStorageType: "unified", + DisableDataMigrations: true, + AppModeProduction: true, + DisableAnonymous: true, + DisableAuthZClientCache: true, + DisableZanzanaServerCheckQueryCache: true, + APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { DualWriterMode: grafanarest.Mode5, diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index ce7dd5f6795..cdcc798c93a 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -2054,9 +2054,7 @@ func TestIntegrationDeleteFolderWithProvisionedDashboards(t *testing.T) { DualWriterMode: modeDw, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, } setupProvisioningDir(t, &ops) @@ -2163,9 +2161,7 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T DualWriterMode: mode3, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, } setupProvisioningDir(t, &ops) diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 379e3ac9734..6087b64687d 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -14,6 +14,7 @@ import ( "testing" "time" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/api/errors" @@ -207,6 +208,10 @@ func (c *K8sTestHelper) GetEnv() server.TestEnv { return c.env } +func (c *K8sTestHelper) SetGithubConnectionFactory(f githubConnection.GithubFactory) { + c.env.GithubConnectionFactory = f +} + func (c *K8sTestHelper) GetListenerAddress() string { return c.listenerAddress } diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index 4634143bd45..3ed64961b1f 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1830,6 +1830,22 @@ "type": "string" } }, + { + "name": "panelType", + "in": "query", + "description": "find dashboards using panels of a given plugin type", + "schema": { + "type": "string" + } + }, + { + "name": "dataSourceType", + "in": "query", + "description": "find dashboards using datasources of a given plugin type", + "schema": { + "type": "string" + } + }, { "name": "permission", "in": "query", diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json index b0d21a3a60c..b89d431883b 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json @@ -1786,6 +1786,13 @@ "skipUrlSync": { "type": "boolean", "default": false + }, + "valuesFormat": { + "enum": [ + "csv", + "json" + ], + "type": "string" } }, "additionalProperties": false diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json index f4ecc6c4599..7f396bc20d4 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2beta1.json @@ -1801,6 +1801,13 @@ "skipUrlSync": { "type": "boolean", "default": false + }, + "valuesFormat": { + "type": "string", + "enum": [ + "csv", + "json" + ] } }, "additionalProperties": false diff --git a/pkg/tests/apis/openapi_snapshots/testdata.datasource.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/grafana-testdata-datasource.datasource.grafana.app-v0alpha1.json similarity index 96% rename from pkg/tests/apis/openapi_snapshots/testdata.datasource.grafana.app-v0alpha1.json rename to pkg/tests/apis/openapi_snapshots/grafana-testdata-datasource.datasource.grafana.app-v0alpha1.json index 4249cf16db0..a6d02c218c6 100644 --- a/pkg/tests/apis/openapi_snapshots/testdata.datasource.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/grafana-testdata-datasource.datasource.grafana.app-v0alpha1.json @@ -2,10 +2,10 @@ "openapi": "3.0.0", "info": { "description": "Generates test data in different forms", - "title": "testdata.datasource.grafana.app/v0alpha1" + "title": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1" }, "paths": { - "/apis/testdata.datasource.grafana.app/v0alpha1/": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/": { "get": { "tags": [ "API Discovery" @@ -36,7 +36,7 @@ } } }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/query": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/query": { "post": { "tags": [ "Connections (deprecated)" @@ -68,7 +68,7 @@ "deprecated": true, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "QueryDataResponse" } @@ -96,7 +96,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources": { "get": { "tags": [ "DataSource" @@ -137,7 +137,7 @@ }, "x-kubernetes-action": "list", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "DataSource" } @@ -254,7 +254,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}": { "get": { "tags": [ "DataSource" @@ -285,7 +285,7 @@ }, "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "DataSource" } @@ -322,7 +322,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": { "get": { "tags": [ "DataSource" @@ -343,7 +343,7 @@ }, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "HealthCheckResult" } @@ -371,7 +371,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/query": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/query": { "post": { "tags": [ "DataSource" @@ -401,7 +401,7 @@ }, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "QueryDataResponse" } @@ -429,7 +429,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": { "get": { "tags": [ "DataSource" @@ -450,7 +450,7 @@ }, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "Status" } @@ -478,7 +478,7 @@ } ] }, - "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/queryconvert/{name}": { + "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/queryconvert/{name}": { "post": { "tags": [ "QueryDataRequest" @@ -499,7 +499,7 @@ }, "x-kubernetes-action": "connect", "x-kubernetes-group-version-kind": { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "version": "v0alpha1", "kind": "QueryDataRequest" } @@ -620,7 +620,7 @@ "apiVersion": { "type": "string", "enum": [ - "testdata.datasource.grafana.app/v0alpha1" + "grafana-testdata-datasource.datasource.grafana.app/v0alpha1" ] }, "kind": { @@ -660,7 +660,7 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "kind": "DataSource", "version": "v0alpha1" } @@ -703,7 +703,7 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "kind": "DataSourceList", "version": "v0alpha1" } @@ -744,7 +744,7 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "kind": "HealthCheckResult", "version": "v0alpha1" } @@ -833,7 +833,7 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "testdata.datasource.grafana.app", + "group": "grafana-testdata-datasource.datasource.grafana.app", "kind": "QueryDataResponse", "version": "v0alpha1" } diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index dab9f3cd8b1..f03b3c3369b 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -87,7 +87,7 @@ "tags": [ "ExternalGroupMapping" ], - "description": "list or watch objects of kind ExternalGroupMapping", + "description": "list objects of kind ExternalGroupMapping", "operationId": "listExternalGroupMapping", "parameters": [ { @@ -8690,32 +8690,6 @@ "description": "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.", "type": "string", "format": "date-time" - }, - "io.k8s.apimachinery.pkg.apis.meta.v1.WatchEvent": { - "description": "Event represents a single event to a watched resource.", - "type": "object", - "required": [ - "type", - "object" - ], - "properties": { - "object": { - "description": "Object is:\n * If Type is Added or Modified: the new state of the object.\n * If Type is Deleted: the state of the object immediately before deletion.\n * If Type is Error: *Status is recommended; other types may make sense\n depending on context.", - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.runtime.RawExtension" - } - ] - }, - "type": { - "type": "string", - "default": "" - } - } - }, - "io.k8s.apimachinery.pkg.runtime.RawExtension": { - "description": "RawExtension is used to hold extensions in external versions.\n\nTo use this, make a field which has RawExtension as its type in your external, versioned struct, and Object in your internal struct. You also need to register your various plugin types.\n\n// Internal package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.Object `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// External package:\n\n\ttype MyAPIObject struct {\n\t\truntime.TypeMeta `json:\",inline\"`\n\t\tMyPlugin runtime.RawExtension `json:\"myPlugin\"`\n\t}\n\n\ttype PluginA struct {\n\t\tAOption string `json:\"aOption\"`\n\t}\n\n// On the wire, the JSON will look something like this:\n\n\t{\n\t\t\"kind\":\"MyAPIObject\",\n\t\t\"apiVersion\":\"v1\",\n\t\t\"myPlugin\": {\n\t\t\t\"kind\":\"PluginA\",\n\t\t\t\"aOption\":\"foo\",\n\t\t},\n\t}\n\nSo what happens? Decode first uses json or yaml to unmarshal the serialized data into your external MyAPIObject. That causes the raw JSON to be stored, but not unpacked. The next step is to copy (using pkg/conversion) into the internal struct. The runtime package's DefaultScheme has conversion functions installed which will unpack the JSON stored in RawExtension, turning it into the correct object type, and storing it in the Object. (TODO: In the case where the object is of an unknown type, a runtime.Unknown object will be created and stored.)", - "type": "object" } } } diff --git a/pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json similarity index 61% rename from pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json rename to pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json index 017323b2a1e..de166b1984d 100644 --- a/pkg/tests/apis/openapi_snapshots/investigations.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/logsdrilldown.grafana.app-v1beta1.json @@ -1,10 +1,10 @@ { "openapi": "3.0.0", "info": { - "title": "investigations.grafana.app/v0alpha1" + "title": "logsdrilldown.grafana.app/v1beta1" }, "paths": { - "/apis/investigations.grafana.app/v0alpha1/": { + "/apis/logsdrilldown.grafana.app/v1beta1/": { "get": { "tags": [ "API Discovery" @@ -35,13 +35,13 @@ } } }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigationindexes": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns": { "get": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "list or watch objects of kind InvestigationIndex", - "operationId": "listInvestigationIndex", + "description": "list or watch objects of kind LogsDrilldownDefaultColumns", + "operationId": "listLogsDrilldownDefaultColumns", "parameters": [ { "name": "allowWatchBookmarks", @@ -140,27 +140,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/json;stream=watch": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/vnd.kubernetes.protobuf;stream=watch": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList" } } } @@ -168,17 +168,17 @@ }, "x-kubernetes-action": "list", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "post": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "create an InvestigationIndex", - "operationId": "createInvestigationIndex", + "description": "create LogsDrilldownDefaultColumns", + "operationId": "createLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -212,17 +212,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -234,17 +234,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -254,17 +254,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -274,17 +274,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -292,17 +292,17 @@ }, "x-kubernetes-action": "post", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "delete": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "delete collection of InvestigationIndex", - "operationId": "deletecollectionInvestigationIndex", + "description": "delete collection of LogsDrilldownDefaultColumns", + "operationId": "deletecollectionLogsDrilldownDefaultColumns", "parameters": [ { "name": "continue", @@ -446,9 +446,9 @@ }, "x-kubernetes-action": "deletecollection", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ @@ -473,30 +473,30 @@ } ] }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigationindexes/{name}": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns/{name}": { "get": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "read the specified InvestigationIndex", - "operationId": "getInvestigationIndex", + "description": "read the specified LogsDrilldownDefaultColumns", + "operationId": "getLogsDrilldownDefaultColumns", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -504,17 +504,17 @@ }, "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "put": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "replace the specified InvestigationIndex", - "operationId": "replaceInvestigationIndex", + "description": "replace the specified LogsDrilldownDefaultColumns", + "operationId": "replaceLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -548,17 +548,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -570,17 +570,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -590,17 +590,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -608,17 +608,17 @@ }, "x-kubernetes-action": "put", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "delete": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "delete an InvestigationIndex", - "operationId": "deleteInvestigationIndex", + "description": "delete LogsDrilldownDefaultColumns", + "operationId": "deleteLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -710,17 +710,17 @@ }, "x-kubernetes-action": "delete", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "patch": { "tags": [ - "InvestigationIndex" + "LogsDrilldownDefaultColumns" ], - "description": "partially update the specified InvestigationIndex", - "operationId": "updateInvestigationIndex", + "description": "partially update the specified LogsDrilldownDefaultColumns", + "operationId": "updateLogsDrilldownDefaultColumns", "parameters": [ { "name": "dryRun", @@ -790,17 +790,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -810,17 +810,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -828,16 +828,16 @@ }, "x-kubernetes-action": "patch", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "InvestigationIndex" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ { "name": "name", "in": "path", - "description": "name of the InvestigationIndex", + "description": "name of the LogsDrilldownDefaultColumns", "required": true, "schema": { "type": "string", @@ -865,468 +865,30 @@ } ] }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigations": { + "/apis/logsdrilldown.grafana.app/v1beta1/namespaces/{namespace}/logsdrilldowndefaultcolumns/{name}/status": { "get": { "tags": [ - "Investigation" - ], - "description": "list or watch objects of kind Investigation", - "operationId": "listInvestigation", - "parameters": [ - { - "name": "allowWatchBookmarks", - "in": "query", - "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "watch", - "in": "query", - "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - } + "LogsDrilldownDefaultColumns" ], + "description": "read status of the specified LogsDrilldownDefaultColumns", + "operationId": "getLogsDrilldownDefaultColumnsStatus", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - }, - "application/json;stream=watch": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - }, - "application/vnd.kubernetes.protobuf;stream=watch": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList" - } - } - } - } - }, - "x-kubernetes-action": "list", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "post": { - "tags": [ - "Investigation" - ], - "description": "create an Investigation", - "operationId": "createInvestigation", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldManager", - "in": "query", - "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldValidation", - "in": "query", - "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - }, - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - }, - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - } - } - } - }, - "x-kubernetes-action": "post", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "delete": { - "tags": [ - "Investigation" - ], - "description": "delete collection of Investigation", - "operationId": "deletecollectionInvestigation", - "parameters": [ - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "gracePeriodSeconds", - "in": "query", - "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "ignoreStoreReadErrorWithClusterBreakingPotential", - "in": "query", - "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "orphanDependents", - "in": "query", - "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "propagationPolicy", - "in": "query", - "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - } - }, - "x-kubernetes-action": "deletecollection", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "parameters": [ - { - "name": "namespace", - "in": "path", - "description": "object name and auth scope, such as for teams and projects", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "pretty", - "in": "query", - "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ] - }, - "/apis/investigations.grafana.app/v0alpha1/namespaces/{namespace}/investigations/{name}": { - "get": { - "tags": [ - "Investigation" - ], - "description": "read the specified Investigation", - "operationId": "getInvestigation", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1334,17 +896,17 @@ }, "x-kubernetes-action": "get", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "put": { "tags": [ - "Investigation" + "LogsDrilldownDefaultColumns" ], - "description": "replace the specified Investigation", - "operationId": "replaceInvestigation", + "description": "replace status of the specified LogsDrilldownDefaultColumns", + "operationId": "replaceLogsDrilldownDefaultColumnsStatus", "parameters": [ { "name": "dryRun", @@ -1378,17 +940,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } }, @@ -1400,17 +962,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1420,17 +982,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1438,119 +1000,17 @@ }, "x-kubernetes-action": "put", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" - } - }, - "delete": { - "tags": [ - "Investigation" - ], - "description": "delete an Investigation", - "operationId": "deleteInvestigation", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "gracePeriodSeconds", - "in": "query", - "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "ignoreStoreReadErrorWithClusterBreakingPotential", - "in": "query", - "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "orphanDependents", - "in": "query", - "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "propagationPolicy", - "in": "query", - "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - }, - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - } - }, - "x-kubernetes-action": "delete", - "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "patch": { "tags": [ - "Investigation" + "LogsDrilldownDefaultColumns" ], - "description": "partially update the specified Investigation", - "operationId": "updateInvestigation", + "description": "partially update status of the specified LogsDrilldownDefaultColumns", + "operationId": "updateLogsDrilldownDefaultColumnsStatus", "parameters": [ { "name": "dryRun", @@ -1620,17 +1080,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1640,17 +1100,17 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/vnd.kubernetes.protobuf": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } }, "application/yaml": { "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } } } @@ -1658,16 +1118,16 @@ }, "x-kubernetes-action": "patch", "x-kubernetes-group-version-kind": { - "group": "investigations.grafana.app", - "version": "v0alpha1", - "kind": "Investigation" + "group": "logsdrilldown.grafana.app", + "version": "v1beta1", + "kind": "LogsDrilldownDefaultColumns" } }, "parameters": [ { "name": "name", "in": "path", - "description": "name of the Investigation", + "description": "name of the LogsDrilldownDefaultColumns", "required": true, "schema": { "type": "string", @@ -1698,12 +1158,13 @@ }, "components": { "schemas": { - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns": { "type": "object", "required": [ + "kind", + "apiVersion", "metadata", - "spec", - "status" + "spec" ], "properties": { "apiVersion": { @@ -1723,269 +1184,21 @@ ] }, "spec": { - "description": "Spec is the spec of the Investigation", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationSpec" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsSpec" }, "status": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationStatus" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsStatus" } }, "x-kubernetes-group-version-kind": [ { - "group": "investigations.grafana.app", - "kind": "Investigation", - "version": "v0alpha1" + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumns", + "version": "v1beta1" } ] }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationCollectable": { - "description": "Collectable represents an item collected during investigation", - "type": "object", - "required": [ - "id", - "createdAt", - "title", - "origin", - "type", - "queries", - "timeRange", - "datasource", - "url", - "note", - "noteUpdatedAt", - "fieldConfig" - ], - "properties": { - "createdAt": { - "type": "string", - "default": "" - }, - "datasource": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationDatasourceRef" - } - ] - }, - "fieldConfig": { - "type": "string", - "default": "" - }, - "id": { - "type": "string", - "default": "" - }, - "logoPath": { - "type": "string" - }, - "note": { - "type": "string", - "default": "" - }, - "noteUpdatedAt": { - "type": "string", - "default": "" - }, - "origin": { - "type": "string", - "default": "" - }, - "queries": { - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "atomic" - }, - "timeRange": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationTimeRange" - } - ] - }, - "title": { - "type": "string", - "default": "" - }, - "type": { - "type": "string", - "default": "" - }, - "url": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationDatasourceRef": { - "description": "DatasourceRef is a reference to a datasource", - "type": "object", - "required": [ - "uid" - ], - "properties": { - "uid": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex": { - "type": "object", - "required": [ - "metadata", - "spec", - "status" - ], - "properties": { - "apiVersion": { - "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - "type": "string" - }, - "kind": { - "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - "type": "string" - }, - "metadata": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" - } - ] - }, - "spec": { - "description": "Spec is the spec of the InvestigationIndex", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexSpec" - } - ] - }, - "status": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexStatus" - } - ] - } - }, - "x-kubernetes-group-version-kind": [ - { - "group": "investigations.grafana.app", - "kind": "InvestigationIndex", - "version": "v0alpha1" - } - ] - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexCollectableSummary": { - "type": "object", - "required": [ - "id", - "title", - "logoPath", - "origin" - ], - "properties": { - "id": { - "type": "string", - "default": "" - }, - "logoPath": { - "type": "string", - "default": "" - }, - "origin": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexInvestigationSummary": { - "description": "Type definition for investigation summaries", - "type": "object", - "required": [ - "title", - "createdByProfile", - "hasCustomName", - "isFavorite", - "overviewNote", - "overviewNoteUpdatedAt", - "viewMode", - "collectableSummaries" - ], - "properties": { - "collectableSummaries": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexCollectableSummary" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "createdByProfile": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson" - } - ] - }, - "hasCustomName": { - "type": "boolean", - "default": false - }, - "isFavorite": { - "type": "boolean", - "default": false - }, - "overviewNote": { - "type": "string", - "default": "" - }, - "overviewNoteUpdatedAt": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - }, - "viewMode": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexViewMode" - } - ] - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexList": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsList": { "type": "object", "required": [ "metadata", @@ -2002,7 +1215,7 @@ "default": {}, "allOf": [ { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndex" + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumns" } ] } @@ -2022,122 +1235,60 @@ }, "x-kubernetes-group-version-kind": [ { - "group": "investigations.grafana.app", - "kind": "InvestigationIndexList", - "version": "v0alpha1" + "group": "logsdrilldown.grafana.app", + "kind": "LogsDrilldownDefaultColumnsList", + "version": "v1beta1" } ] }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson": { - "description": "Person represents a user profile with basic information", + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel": { "type": "object", "required": [ - "uid", - "name", - "gravatarUrl" + "key", + "value" ], "properties": { - "gravatarUrl": { - "description": "URL to user's Gravatar image", - "type": "string", - "default": "" + "key": { + "type": "string" }, - "name": { - "description": "Display name of the user", - "type": "string", - "default": "" - }, - "uid": { - "description": "Unique identifier for the user", - "type": "string", - "default": "" + "value": { + "type": "string" } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabel" } }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexSpec": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord": { "type": "object", "required": [ - "title", - "owner", - "investigationSummaries" + "columns", + "labels" ], "properties": { - "investigationSummaries": { - "description": "Array of investigation summaries", + "columns": { "type": "array", "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexInvestigationSummary" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "owner": { - "description": "The Person who owns this investigation index", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexPerson" - } - ] - }, - "title": { - "description": "Title of the index, e.g. 'Favorites' or 'My Investigations'", - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexStatus": { - "type": "object", - "properties": { - "additionalFields": { - "description": "additionalFields is reserved for future use", - "type": "object", - "additionalProperties": { - "type": "object" + "type": "string" } }, - "operatorStates": { - "description": "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", - "type": "object", - "additionalProperties": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexstatusOperatorState" - } - ] - } + "labels": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsLabels" } + }, + "additionalProperties": false + }, + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords": { + "type": "array", + "items": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecord" } }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexViewMode": { - "type": "object", - "required": [ - "mode", - "showComments", - "showTooltips" - ], - "properties": { - "mode": { - "type": "string", - "default": "" - }, - "showComments": { - "type": "boolean", - "default": false - }, - "showTooltips": { - "type": "boolean", - "default": false - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationIndexstatusOperatorState": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsOperatorState": { "type": "object", "required": [ "lastEvaluation", @@ -2152,273 +1303,58 @@ "description": "details contains any extra information that is operator-specific", "type": "object", "additionalProperties": { - "type": "object" + "type": "object", + "additionalProperties": {} } }, "lastEvaluation": { "description": "lastEvaluation is the ResourceVersion last evaluated", - "type": "string", - "default": "" + "type": "string" }, "state": { - "description": "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", + "description": "state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.", "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationList": { - "type": "object", - "required": [ - "metadata", - "items" - ], - "properties": { - "apiVersion": { - "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - "type": "string" - }, - "items": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.Investigation" - } - ] - } - }, - "kind": { - "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - "type": "string" - }, - "metadata": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" - } + "enum": [ + "success", + "in_progress", + "failed" ] } }, - "x-kubernetes-group-version-kind": [ - { - "group": "investigations.grafana.app", - "kind": "InvestigationList", - "version": "v0alpha1" - } - ] + "additionalProperties": false }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationPerson": { - "description": "Person represents a user profile with basic information", + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsSpec": { "type": "object", "required": [ - "uid", - "name", - "gravatarUrl" + "records" ], "properties": { - "gravatarUrl": { - "description": "URL to user's Gravatar image", - "type": "string", - "default": "" - }, - "name": { - "description": "Display name of the user", - "type": "string", - "default": "" - }, - "uid": { - "description": "Unique identifier for the user", - "type": "string", - "default": "" + "records": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsLogsDefaultColumnsRecords" } - } + }, + "additionalProperties": false }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationSpec": { - "description": "spec is the schema of our resource", - "type": "object", - "required": [ - "title", - "createdByProfile", - "hasCustomName", - "isFavorite", - "overviewNote", - "overviewNoteUpdatedAt", - "collectables", - "viewMode" - ], - "properties": { - "collectables": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationCollectable" - } - ] - }, - "x-kubernetes-list-type": "atomic" - }, - "createdByProfile": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationPerson" - } - ] - }, - "hasCustomName": { - "type": "boolean", - "default": false - }, - "isFavorite": { - "type": "boolean", - "default": false - }, - "overviewNote": { - "type": "string", - "default": "" - }, - "overviewNoteUpdatedAt": { - "type": "string", - "default": "" - }, - "title": { - "type": "string", - "default": "" - }, - "viewMode": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationViewMode" - } - ] - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationStatus": { + "com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsStatus": { "type": "object", "properties": { "additionalFields": { "description": "additionalFields is reserved for future use", "type": "object", "additionalProperties": { - "type": "object" + "type": "object", + "additionalProperties": {} } }, "operatorStates": { - "description": "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.", + "description": "operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.", "type": "object", "additionalProperties": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationstatusOperatorState" - } - ] + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.logsdrilldown.pkg.apis.logsdrilldown.v1beta1.LogsDrilldownDefaultColumnsOperatorState" } } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationTimeRange": { - "description": "TimeRange represents a time range with both absolute and relative values", - "type": "object", - "required": [ - "from", - "to", - "raw" - ], - "properties": { - "from": { - "type": "string", - "default": "" - }, - "raw": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationV0alpha1TimeRangeRaw" - } - ] - }, - "to": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationV0alpha1TimeRangeRaw": { - "type": "object", - "required": [ - "from", - "to" - ], - "properties": { - "from": { - "type": "string", - "default": "" - }, - "to": { - "type": "string", - "default": "" - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationViewMode": { - "type": "object", - "required": [ - "mode", - "showComments", - "showTooltips" - ], - "properties": { - "mode": { - "type": "string", - "default": "" - }, - "showComments": { - "type": "boolean", - "default": false - }, - "showTooltips": { - "type": "boolean", - "default": false - } - } - }, - "com.github.grafana.grafana.apps.investigations.pkg.apis.investigations.v0alpha1.InvestigationstatusOperatorState": { - "type": "object", - "required": [ - "lastEvaluation", - "state" - ], - "properties": { - "descriptiveState": { - "description": "descriptiveState is an optional more descriptive state field which has no requirements on format", - "type": "string" - }, - "details": { - "description": "details contains any extra information that is operator-specific", - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "lastEvaluation": { - "description": "lastEvaluation is the ResourceVersion last evaluated", - "type": "string", - "default": "" - }, - "state": { - "description": "state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation.", - "type": "string", - "default": "" - } - } + }, + "additionalProperties": false }, "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { "description": "APIResource specifies the name of a resource and whether it is namespaced.", diff --git a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json index d4764b742f1..fc8efbaabbb 100644 --- a/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/provisioning.grafana.app-v0alpha1.json @@ -866,6 +866,80 @@ } ] }, + "/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/repositories": { + "get": { + "tags": [ + "Connection" + ], + "summary": "List external repositories", + "description": "List repositories available from the external git provider through this connection", + "operationId": "getConnectionRepositories", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "description": "ExternalRepositoryList lists repositories from an external git provider", + "type": "object", + "required": [ + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {} + }, + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {} + } + } + } + } + } + } + }, + "x-kubernetes-action": "connect", + "x-kubernetes-group-version-kind": { + "group": "provisioning.grafana.app", + "version": "v0alpha1", + "kind": "ExternalRepositoryList" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the ExternalRepositoryList", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, "/apis/provisioning.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/status": { "get": { "tags": [ @@ -4485,7 +4559,7 @@ } ] }, - "webhook": { + "token": { "description": "Token is the reference of the token used to act as the Connection. This value is stored securely and cannot be read back", "default": {}, "allOf": [ @@ -4645,6 +4719,73 @@ } } }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepository": { + "type": "object", + "required": [ + "name", + "url" + ], + "properties": { + "name": { + "description": "Name of the repository", + "type": "string", + "default": "" + }, + "owner": { + "description": "Owner is the user, organization, or workspace that owns the repository For GitHub: organization or user For GitLab: namespace (user or group) For Bitbucket: workspace For pure Git: empty", + "type": "string" + }, + "url": { + "description": "URL of the repository", + "type": "string", + "default": "" + } + } + }, + "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepositoryList": { + "description": "ExternalRepositoryList lists repositories from an external git provider", + "type": "object", + "required": [ + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.ExternalRepository" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "provisioning.grafana.app", + "kind": "ExternalRepositoryList", + "version": "v0alpha1" + } + ] + }, "com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.FileItem": { "type": "object", "required": [ diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index f9d5cdb70e5..d73463a7daf 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -30,7 +30,6 @@ func TestIntegrationOpenAPIs(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagQueryService, // Query Library featuremgmt.FlagProvisioning, - featuremgmt.FlagInvestigationsBackend, featuremgmt.FlagGrafanaAdvisor, featuremgmt.FlagKubernetesAlertingRules, featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, // all datasources @@ -97,9 +96,6 @@ func TestIntegrationOpenAPIs(t *testing.T) { }, { Group: "iam.grafana.app", Version: "v0alpha1", - }, { - Group: "investigations.grafana.app", - Version: "v0alpha1", }, { Group: "advisor.grafana.app", Version: "v0alpha1", @@ -128,11 +124,11 @@ func TestIntegrationOpenAPIs(t *testing.T) { Group: "shorturl.grafana.app", Version: "v1beta1", }, { - Group: "testdata.datasource.grafana.app", + Group: "grafana-testdata-datasource.datasource.grafana.app", Version: "v0alpha1", }, { Group: "logsdrilldown.grafana.app", - Version: "v1alpha1", + Version: "v1beta1", }} for _, gv := range groups { VerifyOpenAPISnapshots(t, dir, gv, h) diff --git a/pkg/tests/apis/plugins/plugins_test.go b/pkg/tests/apis/plugins/plugins_test.go index d8c45713337..0844bda9757 100644 --- a/pkg/tests/apis/plugins/plugins_test.go +++ b/pkg/tests/apis/plugins/plugins_test.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" @@ -177,6 +178,9 @@ func setupHelper(t *testing.T) *apis.K8sTestHelper { AppModeProduction: true, DisableAnonymous: true, APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true", + EnableFeatureToggles: []string{ + featuremgmt.FlagPluginStoreServiceLoading, + }, }) t.Cleanup(func() { helper.Shutdown() }) return helper diff --git a/pkg/tests/apis/preferences/preferences_test.go b/pkg/tests/apis/preferences/preferences_test.go index 5de09e16fa8..63ab74e0eb8 100644 --- a/pkg/tests/apis/preferences/preferences_test.go +++ b/pkg/tests/apis/preferences/preferences_test.go @@ -67,7 +67,7 @@ func TestIntegrationPreferences(t *testing.T) { Path: fmt.Sprintf("/api/teams/%d/preferences", helper.Org1.Staff.ID), Body: []byte(`{ "weekStart": "sunday", - "timezone": "africa" + "timezone": "Africa/Johannesburg" }`), }, &raw) require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user") @@ -79,7 +79,7 @@ func TestIntegrationPreferences(t *testing.T) { Path: "/api/org/preferences", Body: []byte(`{ "weekStart": "sunday", - "timezone": "africa", + "timezone": "Africa/Accra", "theme": "dark" }`), }, &raw) @@ -144,7 +144,7 @@ func TestIntegrationPreferences(t *testing.T) { jj, _ = json.Marshal(bootdata.Result.User) require.JSONEq(t, `{ - "timezone":"africa", + "timezone":"Africa/Johannesburg", "weekStart":"saturday", "theme":"dark", "language":"en-US", `+ // FROM global default! @@ -157,10 +157,10 @@ func TestIntegrationPreferences(t *testing.T) { Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged", }, &preferences.Preferences{}) require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences") - require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user - require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team - require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org - require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini - require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update + require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user + require.Equal(t, "Africa/Johannesburg", *merged.Result.Spec.Timezone) // from team + require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org + require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini + require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update }) } diff --git a/pkg/tests/apis/provisioning/connection_repositories_test.go b/pkg/tests/apis/provisioning/connection_repositories_test.go new file mode 100644 index 00000000000..4def16377e3 --- /dev/null +++ b/pkg/tests/apis/provisioning/connection_repositories_test.go @@ -0,0 +1,170 @@ +package provisioning + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/util/testutil" +) + +func TestIntegrationProvisioning_ConnectionRepositories(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + // Create a connection for testing + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection-repositories-test", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) + + t.Run("endpoint returns not implemented", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Do(ctx). + StatusCode(&statusCode) + + require.Error(t, result.Error(), "should return error for not implemented endpoint") + require.Equal(t, http.StatusMethodNotAllowed, statusCode, "should return 405 Method Not Allowed") + require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported") + }) + + t.Run("admin can access endpoint (gets not implemented)", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Do(ctx).StatusCode(&statusCode) + + // Endpoint exists but returns not implemented + require.Error(t, result.Error(), "should return error") + require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported") + // Status code should be 405 (Method Not Allowed) for method not supported + require.Equal(t, http.StatusMethodNotAllowed, statusCode) + }) + + t.Run("editor cannot access endpoint", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "editor should not be able to access repositories endpoint") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("viewer cannot access endpoint", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Get(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to access repositories endpoint") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("non-GET methods are rejected", func(t *testing.T) { + configBytes, _ := json.Marshal(map[string]any{}) + + var statusCode int + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("connections"). + Name("connection-repositories-test"). + SubResource("repositories"). + Body(configBytes). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "POST should not be allowed") + require.True(t, apierrors.IsMethodNotSupported(result.Error()), "error should be MethodNotSupported") + }) +} + +func TestIntegrationProvisioning_ConnectionRepositoriesResponseType(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + // Create a connection for testing + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection-repositories-test", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) + + t.Run("verify ExternalRepositoryList type exists in API", func(t *testing.T) { + // Verify the type is registered and can be instantiated + list := &provisioning.ExternalRepositoryList{} + require.NotNil(t, list) + // Verify it has the expected structure (Items is a slice, nil by default is fine) + require.IsType(t, []provisioning.ExternalRepository{}, list.Items) + // Can create items + list.Items = []provisioning.ExternalRepository{ + {Name: "test", Owner: "owner", URL: "https://example.com/repo"}, + } + require.Len(t, list.Items, 1) + require.Equal(t, "test", list.Items[0].Name) + }) +} diff --git a/pkg/tests/apis/provisioning/connection_status_auth_test.go b/pkg/tests/apis/provisioning/connection_status_auth_test.go index fbddd85999a..0deaa3eeefe 100644 --- a/pkg/tests/apis/provisioning/connection_status_auth_test.go +++ b/pkg/tests/apis/provisioning/connection_status_auth_test.go @@ -2,12 +2,12 @@ package provisioning import ( "context" + "encoding/base64" "net/http" "testing" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/grafana/grafana/pkg/util/testutil" @@ -18,7 +18,7 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) { helper := runGrafana(t) ctx := context.Background() - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) // Create a connection for testing connection := &unstructured.Unstructured{Object: map[string]any{ @@ -37,13 +37,12 @@ func TestIntegrationProvisioning_ConnectionStatusAuthorization(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} - - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) - require.NoError(t, err, "failed to create connection") + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err) t.Run("admin can GET connection status", func(t *testing.T) { var statusCode int diff --git a/pkg/tests/apis/provisioning/connection_test.go b/pkg/tests/apis/provisioning/connection_test.go index ea28ac88359..95d03e0a03e 100644 --- a/pkg/tests/apis/provisioning/connection_test.go +++ b/pkg/tests/apis/provisioning/connection_test.go @@ -2,23 +2,79 @@ package provisioning import ( "context" + "encoding/base64" + "encoding/json" "errors" + "fmt" + "net/http" "testing" + "time" + "github.com/golang-jwt/jwt/v4" + "github.com/google/go-github/v70/github" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" + "github.com/grafana/grafana/pkg/extensions" "github.com/grafana/grafana/pkg/util/testutil" + ghmock "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned" ) +//nolint:gosec // Test RSA private key (generated for testing purposes only) +const testPrivateKeyPEM = `-----BEGIN RSA PRIVATE KEY----- +MIIEoQIBAAKCAQBn1MuM5hIfH6d3TNStI1ofWv/gcjQ4joi9cFijEwVLuPYkF1nD +KkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6m5T/sHfVSiEWAEUblh3cA34HVCmD +cqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyjpKOXuojJ9wN9a17D2cYU5WkXjoDC +4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7DA9T8yiZwIWPp5YesgsAPyQLCFPgM +s77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQohUID0BIxE7G5quL069RuuCZWZkoF +oPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmXAgMBAAECggEADSs4Bc7ITZo+Kytb +bfol3AQ2n8jcRrANN7mgBE7NRSVYUouDnvUlbnCC2t3QXPwLdxQa11GkygLSQ2bg +GeVDgq1o4GUJTcvxFlFCcpU/hEANI/DQsxNAQ/4wUGoLOlHaO3HPvwBblHA70gGe +Ux/xpG+lMAFAiB0EHEwZ4M0mClBEOQv3NzaFTWuBHtIMS8eid7M1q5qz9+rCgZSL +KBBHo0OvUbajG4CWl8SM6LUYapASGg+U17E+4xA3npwpIdsk+CbtX+vvX324n4kn +0EkrJqCjv8M1KiCKAP+UxwP00ywxOg4PN+x+dHI/I7xBvEKe/x6BltVSdGA+PlUK +02wagQKBgQDF7gdQLFIagPH7X7dBP6qEGxj/Ck9Qdz3S1gotPkVeq+1/UtQijYZ1 +j44up/0yB2B9P4kW091n+iWcyfoU5UwBua9dHvCZP3QH05LR1ZscUHxLGjDPBASt +l2xSq0hqqNWBspb1M0eCY0Yxi65iDkj3xsI2iN35BEb1FlWdR5KGvwKBgQCGS0ce +wASWbZIPU2UoKGOQkIJU6QmLy0KZbfYkpyfE8IxGttYVEQ8puNvDDNZWHNf+LP85 +c8iV6SfnWiLmu1XkG2YmJFBCCAWgJ8Mq2XQD8E+a/xcaW3NqlcC5+I2czX367j3r +69wZSxRbzR+DCfOiIkrekJImwN183ZYy2cBbKQKBgFj86IrSMmO6H5Ft+j06u5ZD +fJyF7Rz3T3NwSgkHWzbyQ4ggHEIgsRg/36P4YSzSBj6phyAdRwkNfUWdxXMJmH+a +FU7frzqnPaqbJAJ1cBRt10QI1XLtkpDdaJVObvONTtjOC3LYiEkGCzQRYeiyFXpZ +AU51gJ8JnkFotjtNR4KPAoGAehVREDlLcl0lnN0ZZspgyPk2Im6/iOA9KTH3xBZZ +ZwWu4FIyiHA7spgk4Ep5R0ttZ9oMI3SIcw/EgONGOy8uw/HMiPwWIhEc3B2JpRiO +CU6bb7JalFFyuQBudiHoyxVcY5PVovWF31CLr3DoJr4TR9+Y5H/U/XnzYCIo+w1N +exECgYBFAGKYTIeGAvhIvD5TphLpbCyeVLBIq5hRyrdRY+6Iwqdr5PGvLPKwin5+ ++4CDhWPW4spq8MYPCRiMrvRSctKt/7FhVGL2vE/0VY3TcLk14qLC+2+0lnPVgnYn +u5/wOyuHp1cIBnjeN41/pluOWFBHI9xLW3ExLtmYMiecJ8VdRA== +-----END RSA PRIVATE KEY-----` + +//nolint:gosec // Test RSA public key (generated for testing purposes only) +const testPublicKeyPem = `-----BEGIN PUBLIC KEY----- +MIIBITANBgkqhkiG9w0BAQEFAAOCAQ4AMIIBCQKCAQBn1MuM5hIfH6d3TNStI1of +Wv/gcjQ4joi9cFijEwVLuPYkF1nDKkSbaMGFUWiOTaB/H9fxmd/V2u04NlBY3av6 +m5T/sHfVSiEWAEUblh3cA34HVCmDcqyyVty5HLGJJlSs2C7W2x7yUc9ImzyDBsyj +pKOXuojJ9wN9a17D2cYU5WkXjoDC4BHid61jn9WBTtPZXSgOdirwahNzxZQSIP7D +A9T8yiZwIWPp5YesgsAPyQLCFPgMs77xz/CEUnEYQ35zI/k/mQrwKdQ/ZP8xLwQo +hUID0BIxE7G5quL069RuuCZWZkoFoPiZbp7HSryz1+19jD3rFT7eHGUYvAyCnXmX +AgMBAAE= +-----END PUBLIC KEY-----` + func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) helper := runGrafana(t) - createOptions := metav1.CreateOptions{FieldValidation: "Strict"} ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + decryptService := helper.GetEnv().DecryptService + require.NotNil(t, decryptService, "decrypt service not wired properly") t.Run("should perform CRUDL requests on connection", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ @@ -37,12 +93,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} // CREATE - _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + _, err := helper.CreateGithubConnection(t, ctx, connection) require.NoError(t, err, "failed to create resource") // READ @@ -60,6 +116,22 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { require.Contains(t, output.Object, "secure", "object should contain secure") assert.Contains(t, output.Object["secure"], "privateKey", "secure should contain PrivateKey") + // Verifying token + assert.Contains(t, output.Object["secure"], "token", "token should be created") + secretName, found, err := unstructured.NestedString(output.Object, "secure", "token", "name") + require.NoError(t, err, "error getting secret name") + require.True(t, found, "secret name should exist: %v", output.Object) + decrypted, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", output.GetNamespace(), secretName) + require.NoError(t, err, "decryption error") + require.Len(t, decrypted, 1) + + val := decrypted[secretName].Value() + require.NotNil(t, val) + k := val.DangerouslyExposeAndConsumeValue() + valid, err := verifyToken(t, "123456", testPublicKeyPem, k) + require.NoError(t, err, "error verifying token: %s", k) + require.True(t, valid, "token should be valid: %s", k) + // LIST list, err := helper.Connections.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err, "failed to list resource") @@ -77,25 +149,41 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { "spec": map[string]any{ "type": "github", "github": map[string]any{ - "appID": "456789", - "installationID": "454545", + "appID": "123456", + "installationID": "454546", }, }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} - res, err := helper.Connections.Resource.Update(ctx, updatedConnection, metav1.UpdateOptions{}) + res, err := helper.UpdateGithubConnection(t, ctx, updatedConnection) require.NoError(t, err, "failed to update resource") spec = res.Object["spec"].(map[string]any) require.Contains(t, spec, "github") githubInfo = spec["github"].(map[string]any) - assert.Equal(t, "456789", githubInfo["appID"], "appID should be updated") + assert.Equal(t, "454546", githubInfo["installationID"], "installationID should be updated") - // DELETE - require.NoError(t, helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}), "failed to delete resource") + // DELETE - Retry delete to handle resource version conflicts + // The controller may have updated the resource after our update, changing the resource version + require.Eventually(t, func() bool { + err := helper.Connections.Resource.Delete(ctx, "connection", metav1.DeleteOptions{}) + if err != nil { + if k8serrors.IsConflict(err) { + // Resource version conflict - retry + return false + } + if k8serrors.IsNotFound(err) { + // Already deleted - success + return true + } + // Other error - fail the test + require.NoError(t, err, "failed to delete resource") + } + return true + }, 5*time.Second, 100*time.Millisecond, "should successfully delete resource") list, err = helper.Connections.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err, "failed to list resources") assert.Equal(t, 0, len(list.Items), "should have no connections") @@ -118,7 +206,7 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} @@ -151,9 +239,12 @@ func TestIntegrationProvisioning_ConnectionCRUDL(t *testing.T) { } func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + helper := runGrafana(t) createOptions := metav1.CreateOptions{FieldValidation: "Strict"} ctx := context.Background() + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) t.Run("should fail when type is empty", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ @@ -168,13 +259,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "type must be specified") + assert.Contains(t, err.Error(), "connection type \"\" is not supported") }) t.Run("should fail when type is invalid", func(t *testing.T) { @@ -190,13 +281,57 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "spec.type: Unsupported value: \"some-invalid-type\"") + assert.Contains(t, err.Error(), "connection type \"some-invalid-type\" is not supported") + }) + + t.Run("should fail when type is 'git'", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "git", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "connection type \"git\" is not supported") + }) + + t.Run("should fail when type is 'local'", func(t *testing.T) { + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "local", + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "connection type \"local\" is not supported") }) t.Run("should fail when type is github but 'github' field is not there", func(t *testing.T) { @@ -212,13 +347,13 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, }, }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "github info must be specified for GitHub connection") + assert.Contains(t, err.Error(), "invalid github connection") }) t.Run("should fail when type is github but private key is not there", func(t *testing.T) { @@ -242,7 +377,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "privateKey must be specified for GitHub connection") }) - t.Run("should fail when type is github but a client Secret is specified", func(t *testing.T) { + t.Run("should fail when type is github but a client Secret is also specified", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "provisioning.grafana.app/v0alpha1", "kind": "Connection", @@ -259,7 +394,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }, "secure": map[string]any{ "privateKey": map[string]any{ - "create": "someSecret", + "create": privateKeyBase64, }, "clientSecret": map[string]any{ "create": "someSecret", @@ -271,6 +406,100 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "clientSecret is forbidden in GitHub connection") }) + t.Run("should fail when type is github and github API is unavailable", func(t *testing.T) { + connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatchHandler( + ghmock.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + require.NoError(t, json.NewEncoder(w).Encode(github.ErrorResponse{ + Response: &http.Response{ + StatusCode: http.StatusServiceUnavailable, + }, + Message: "Service unavailable", + })) + }), + ), + ) + helper.SetGithubConnectionFactory(connectionFactory) + + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "spec.token: Internal error: github is unavailable") + }) + + t.Run("should fail when type is github and returned app ID doesn't match given one", func(t *testing.T) { + var appID int64 = 123455 + appSlug := "appSlug" + connectionFactory := helper.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatch( + ghmock.GetApp, github.App{ + ID: &appID, + Slug: &appSlug, + }, + ), + ) + helper.SetGithubConnectionFactory(connectionFactory) + + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "connection", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "454545", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) + require.Error(t, err, "failed to create resource") + assert.Contains(t, err.Error(), "spec.appID: Invalid value: \"123456\": appID mismatch") + }) +} + +func TestIntegrationProvisioning_ConnectionEnterpriseValidation(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + if !extensions.IsEnterprise { + t.Skip("Skipping integration test when not enterprise") + } + + helper := runGrafana(t) + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + ctx := context.Background() + t.Run("should fail when type is bitbucket but 'bitbucket' field is not there", func(t *testing.T) { connection := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "provisioning.grafana.app/v0alpha1", @@ -290,7 +519,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "bitbucket info must be specified in Bitbucket connection") + assert.Contains(t, err.Error(), "invalid bitbucket connection") }) t.Run("should fail when type is bitbucket but client secret is not there", func(t *testing.T) { @@ -360,7 +589,7 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { }} _, err := helper.Connections.Resource.Create(ctx, connection, createOptions) require.Error(t, err, "failed to create resource") - assert.Contains(t, err.Error(), "gitlab info must be specified in Gitlab connection") + assert.Contains(t, err.Error(), "invalid gitlab connection") }) t.Run("should fail when type is gitlab but client secret is not there", func(t *testing.T) { @@ -411,3 +640,345 @@ func TestIntegrationProvisioning_ConnectionValidation(t *testing.T) { assert.Contains(t, err.Error(), "privateKey is forbidden in Gitlab connection") }) } + +func TestIntegrationConnectionController_HealthCheckUpdates(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + namespace := "default" + + // Create typed client from REST config + restConfig := helper.Org1.Admin.NewRestConfig() + provisioningClient, err := clientset.NewForConfig(restConfig) + require.NoError(t, err) + connClient := provisioningClient.ProvisioningV0alpha1().Connections(namespace) + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + t.Run("health check gets updated after initial creation", func(t *testing.T) { + // Create a connection using unstructured (like other connection tests) + connUnstructured := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-connection-health", + "namespace": namespace, + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "12345", + "installationID": "67890", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + + createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured) + require.NoError(t, err) + require.NotNil(t, createdUnstructured) + + connName := createdUnstructured.GetName() + + t.Cleanup(func() { + _ = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{}) + }) + + // Wait for initial reconciliation - controller should update status + require.Eventually(t, func() bool { + updated, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + return updated.Status.ObservedGeneration == updated.Generation && + updated.Status.Health.Checked > 0 && + updated.Status.State == provisioning.ConnectionStateConnected && + updated.Status.Health.Healthy + }, 10*time.Second, 500*time.Millisecond, "connection should be initially reconciled with health status") + + // Verify initial health check was set + initial, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + assert.True(t, initial.Status.Health.Healthy, "connection should be healthy") + assert.Equal(t, provisioning.ConnectionStateConnected, initial.Status.State, "connection should be connected") + assert.Greater(t, initial.Status.Health.Checked, int64(0), "health check timestamp should be set") + assert.Equal(t, initial.Generation, initial.Status.ObservedGeneration, "observed generation should match") + }) + + t.Run("health check updates when spec changes", func(t *testing.T) { + // Create a connection using unstructured + connUnstructured := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-connection-spec-change", + "namespace": namespace, + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "11111", + "installationID": "22222", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + + createdUnstructured, err := helper.CreateGithubConnection(t, ctx, connUnstructured) + require.NoError(t, err) + require.NotNil(t, createdUnstructured) + + connName := createdUnstructured.GetName() + + t.Cleanup(func() { + _ = helper.Connections.Resource.Delete(ctx, connName, metav1.DeleteOptions{}) + }) + + // Wait for initial reconciliation + var initialHealthChecked int64 + require.Eventually(t, func() bool { + updated, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + if updated.Status.ObservedGeneration == updated.Generation { + initialHealthChecked = updated.Status.Health.Checked + return true + } + return false + }, 10*time.Second, 500*time.Millisecond, "connection should be initially reconciled") + + // Get the latest version before updating to avoid conflicts with controller updates + latestUnstructured, err := helper.Connections.Resource.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + + // Update the connection spec using the latest version + updatedUnstructured := latestUnstructured.DeepCopy() + githubSpec := updatedUnstructured.Object["spec"].(map[string]any)["github"].(map[string]any) + githubSpec["appID"] = "99999" + _, err = helper.UpdateGithubConnection(t, ctx, updatedUnstructured) + require.NoError(t, err) + + // Wait for reconciliation after spec change + require.Eventually(t, func() bool { + reconciled, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + if err != nil { + return false + } + return reconciled.Status.ObservedGeneration == reconciled.Generation && + reconciled.Status.Health.Checked > initialHealthChecked + }, 10*time.Second, 500*time.Millisecond, "connection should be reconciled after spec change") + + // Verify health check was updated + final, err := connClient.Get(ctx, connName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, final.Generation, final.Status.ObservedGeneration, "observed generation should match generation") + assert.Greater(t, final.Status.Health.Checked, initialHealthChecked, "health check should be updated after spec change") + assert.True(t, final.Status.Health.Healthy, "connection should remain healthy") + }) +} + +func TestIntegrationProvisioning_RepositoryFieldSelectorByConnection(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + createOptions := metav1.CreateOptions{FieldValidation: "Strict"} + privateKeyBase64 := base64.StdEncoding.EncodeToString([]byte(testPrivateKeyPEM)) + + // Create a connection first + connection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Connection", + "metadata": map[string]any{ + "name": "test-conn-for-field-selector", + "namespace": "default", + }, + "spec": map[string]any{ + "type": "github", + "github": map[string]any{ + "appID": "123456", + "installationID": "789012", + }, + }, + "secure": map[string]any{ + "privateKey": map[string]any{ + "create": privateKeyBase64, + }, + }, + }} + + _, err := helper.CreateGithubConnection(t, ctx, connection) + require.NoError(t, err, "failed to create connection") + + t.Cleanup(func() { + // Clean up repositories first + _ = helper.Repositories.Resource.Delete(ctx, "repo-with-connection", metav1.DeleteOptions{}) + _ = helper.Repositories.Resource.Delete(ctx, "repo-without-connection", metav1.DeleteOptions{}) + _ = helper.Repositories.Resource.Delete(ctx, "repo-with-different-connection", metav1.DeleteOptions{}) + // Then clean up the connection + _ = helper.Connections.Resource.Delete(ctx, "test-conn-for-field-selector", metav1.DeleteOptions{}) + }) + + // Create a repository WITH the connection + repoWithConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-with-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo With Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "connection": map[string]any{ + "name": "test-conn-for-field-selector", + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithConnection, createOptions) + require.NoError(t, err, "failed to create repository with connection") + + // Create a repository WITHOUT the connection + repoWithoutConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-without-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo Without Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithoutConnection, createOptions) + require.NoError(t, err, "failed to create repository without connection") + + // Create a repository with a DIFFERENT connection name (non-existent) + repoWithDifferentConnection := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "provisioning.grafana.app/v0alpha1", + "kind": "Repository", + "metadata": map[string]any{ + "name": "repo-with-different-connection", + "namespace": "default", + }, + "spec": map[string]any{ + "title": "Repo With Different Connection", + "type": "local", + "sync": map[string]any{ + "enabled": false, + "target": "folder", + }, + "local": map[string]any{ + "path": helper.ProvisioningPath, + }, + "connection": map[string]any{ + "name": "some-other-connection", + }, + }, + }} + + _, err = helper.Repositories.Resource.Create(ctx, repoWithDifferentConnection, createOptions) + require.NoError(t, err, "failed to create repository with different connection") + + t.Run("filter repositories by spec.connection.name", func(t *testing.T) { + // List repositories with field selector for the specific connection + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=test-conn-for-field-selector", + }) + require.NoError(t, err, "failed to list repositories with field selector") + + // Should only return the repository with the matching connection + assert.Len(t, list.Items, 1, "should return exactly one repository") + assert.Equal(t, "repo-with-connection", list.Items[0].GetName(), "should return the correct repository") + }) + + t.Run("filter repositories by non-existent connection returns empty", func(t *testing.T) { + // List repositories with field selector for a non-existent connection + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=non-existent-connection", + }) + require.NoError(t, err, "failed to list repositories with field selector") + + // Should return empty list + assert.Len(t, list.Items, 0, "should return no repositories for non-existent connection") + }) + + t.Run("filter repositories by empty connection name", func(t *testing.T) { + // List repositories with field selector for empty connection (repos without connection) + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{ + FieldSelector: "spec.connection.name=", + }) + require.NoError(t, err, "failed to list repositories with empty connection field selector") + + // Should return the repository without a connection + assert.Len(t, list.Items, 1, "should return exactly one repository without connection") + assert.Equal(t, "repo-without-connection", list.Items[0].GetName(), "should return the repository without connection") + }) + + t.Run("list all repositories without field selector", func(t *testing.T) { + // List all repositories without field selector + list, err := helper.Repositories.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "failed to list all repositories") + + // Should return all three repositories + assert.Len(t, list.Items, 3, "should return all three repositories") + + names := make([]string, len(list.Items)) + for i, item := range list.Items { + names[i] = item.GetName() + } + assert.Contains(t, names, "repo-with-connection") + assert.Contains(t, names, "repo-without-connection") + assert.Contains(t, names, "repo-with-different-connection") + }) +} + +func verifyToken(t *testing.T, appID, publicKey, token string) (bool, error) { + t.Helper() + + // Parse the private key + key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(publicKey)) + if err != nil { + return false, err + } + + parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (any, error) { + return key, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})) + if err != nil { + return false, err + } + + claims, ok := parsedToken.Claims.(jwt.MapClaims) + if !ok || !parsedToken.Valid { + return false, fmt.Errorf("invalid token") + } + + return claims.VerifyIssuer(appID, true), nil +} diff --git a/pkg/tests/apis/provisioning/helper_test.go b/pkg/tests/apis/provisioning/helper_test.go index 791ac4b8a20..34b57afe3c6 100644 --- a/pkg/tests/apis/provisioning/helper_test.go +++ b/pkg/tests/apis/provisioning/helper_test.go @@ -10,11 +10,14 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "testing" "text/template" "time" + "github.com/google/go-github/v70/github" + "github.com/grafana/grafana/pkg/extensions" ghmock "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -30,6 +33,7 @@ import ( dashboardsV2beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1" folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" + githubConnection "github.com/grafana/grafana/apps/provisioning/pkg/connection/github" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -699,13 +703,18 @@ func runGrafana(t *testing.T, options ...grafanaOption) *provisioningTestHelper // (instance is needed for export jobs, folder for most operations) ProvisioningAllowedTargets: []string{"folder", "instance"}, } + + if extensions.IsEnterprise { + opts.ProvisioningRepositoryTypes = []string{"local", "github", "gitlab", "bitbucket"} + } + for _, o := range options { o(&opts) } helper := apis.NewK8sTestHelper(t, opts) - // FIXME: keeping this line here to keep the dependency around until we have tests which use this again. - helper.GetEnv().GitHubFactory.Client = ghmock.NewMockedHTTPClient() + // FIXME: keeping these lines here to keep the dependency around until we have tests which use this again. + helper.GetEnv().GithubRepoFactory.Client = ghmock.NewMockedHTTPClient() repositories := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, @@ -973,6 +982,79 @@ func (h *provisioningTestHelper) CleanupAllRepos(t *testing.T) { }, waitTimeoutDefault, waitIntervalDefault, "repositories should be cleaned up between subtests") } +func (h *provisioningTestHelper) CreateGithubConnection( + t *testing.T, + ctx context.Context, + connection *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { + t.Helper() + + err := h.setGithubClient(t, connection) + if err != nil { + return nil, err + } + + return h.Connections.Resource.Create(ctx, connection, metav1.CreateOptions{FieldValidation: "Strict"}) +} + +func (h *provisioningTestHelper) UpdateGithubConnection( + t *testing.T, + ctx context.Context, + connection *unstructured.Unstructured, +) (*unstructured.Unstructured, error) { + t.Helper() + + err := h.setGithubClient(t, connection) + if err != nil { + return nil, err + } + + return h.Connections.Resource.Update(ctx, connection, metav1.UpdateOptions{FieldValidation: "Strict"}) +} + +func (h *provisioningTestHelper) setGithubClient(t *testing.T, connection *unstructured.Unstructured) error { + t.Helper() + + objectSpec := connection.Object["spec"].(map[string]interface{}) + githubObj := objectSpec["github"].(map[string]interface{}) + appID := githubObj["appID"].(string) + id, err := strconv.ParseInt(appID, 10, 64) + if err != nil { + return err + } + + appSlug := "someSlug" + connectionFactory := h.GetEnv().GithubConnectionFactory.(*githubConnection.Factory) + connectionFactory.Client = ghmock.NewMockedHTTPClient( + ghmock.WithRequestMatchHandler( + ghmock.GetApp, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + app := github.App{ + ID: &id, + Slug: &appSlug, + } + _, _ = w.Write(ghmock.MustMarshal(app)) + }), + ), + ghmock.WithRequestMatchHandler( + ghmock.GetAppInstallationsByInstallationId, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("installation_id") + idInt, _ := strconv.ParseInt(id, 10, 64) + w.WriteHeader(http.StatusOK) + installation := github.Installation{ + ID: &idInt, + } + _, _ = w.Write(ghmock.MustMarshal(installation)) + }), + ), + ) + h.SetGithubConnectionFactory(connectionFactory) + + return nil +} + func postHelper(t *testing.T, helper apis.K8sTestHelper, path string, body interface{}, user apis.User) (map[string]interface{}, int, error) { return requestHelper(t, helper, http.MethodPost, path, body, user) } diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index f2447e71d23..0f3509602b7 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/extensions" provisioningAPIServer "github.com/grafana/grafana/pkg/registry/apis/provisioning" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -149,10 +150,19 @@ func TestIntegrationProvisioning_CreatingAndGetting(t *testing.T) { } } - assert.ElementsMatch(collect, []provisioning.RepositoryType{ - provisioning.LocalRepositoryType, - provisioning.GitHubRepositoryType, - }, settings.AvailableRepositoryTypes) + if extensions.IsEnterprise { + assert.ElementsMatch(collect, []provisioning.RepositoryType{ + provisioning.LocalRepositoryType, + provisioning.GitHubRepositoryType, + provisioning.BitbucketRepositoryType, + provisioning.GitLabRepositoryType, + }, settings.AvailableRepositoryTypes) + } else { + assert.ElementsMatch(collect, []provisioning.RepositoryType{ + provisioning.LocalRepositoryType, + provisioning.GitHubRepositoryType, + }, settings.AvailableRepositoryTypes) + } }, time.Second*10, time.Millisecond*100, "Expected settings to match") }) diff --git a/pkg/tests/apis/zanzana_reconcile.go b/pkg/tests/apis/zanzana_reconcile.go index f8a5673fed7..d63d46491fe 100644 --- a/pkg/tests/apis/zanzana_reconcile.go +++ b/pkg/tests/apis/zanzana_reconcile.go @@ -18,7 +18,10 @@ import ( const zanzanaReconcileLastSuccessMetric = "grafana_zanzana_reconcile_last_success_timestamp_seconds" -// AwaitZanzanaReconcileNext waits for the next Zanzana reconciliation cycle to complete. +// AwaitZanzanaReconcileNext waits for a Zanzana reconciliation cycle whose last-success timestamp +// has been incremented from its current value. This ensures a reconciliation has occurred after +// this function is called. +// // It is a no-op unless the `zanzana` feature toggle is enabled for the running test env. func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { t.Helper() @@ -31,18 +34,14 @@ func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { return } - prev, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) - if !ok { - prev = 0 - } - + baselineTimestamp, _ := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) require.EventuallyWithT(t, func(c *assert.CollectT) { ts, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) assert.True(c, ok, "expected to find %s in /metrics", zanzanaReconcileLastSuccessMetric) if !ok { return } - assert.Greater(c, ts, prev, "expected %s (%v) > %v", zanzanaReconcileLastSuccessMetric, ts, prev) + assert.Greater(c, ts, baselineTimestamp, "expected %s (%v) > baseline (%v)", zanzanaReconcileLastSuccessMetric, ts, baselineTimestamp) }, 30*time.Second, 50*time.Millisecond) } diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 13507b7987a..81e243f42b5 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -320,8 +320,9 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled)) require.NoError(t, err) - if !opts.OpenFeatureAPIEnabled { - _, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with goff type, but trying to make tests work + + if opts.OpenFeatureAPIEnabled { + _, err = openFeatureSect.NewKey("provider", "static") require.NoError(t, err) _, err = openFeatureSect.NewKey("targetingKey", "grafana") require.NoError(t, err) @@ -369,6 +370,39 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) } + if opts.DisableZanzanaServerCheckQueryCache { + zanzanaServerSect, err := cfg.NewSection("zanzana.server") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_cache_limit", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("cache_controller_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("cache_controller_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_query_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_query_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_max_results", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("check_iterator_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_max_results", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("list_objects_iterator_cache_ttl", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_enabled", "false") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_limit", "0") + require.NoError(t, err) + _, err = zanzanaServerSect.NewKey("shared_iterator_ttl", "0") + require.NoError(t, err) + } + analyticsSect, err := cfg.NewSection("analytics") require.NoError(t, err) _, err = analyticsSect.NewKey("intercom_secret", "intercom_secret_at_config") @@ -556,6 +590,8 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = section.NewKey("enableMigration", fmt.Sprintf("%t", v.EnableMigration)) require.NoError(t, err) + _, err = section.NewKey("autoMigrationThreshold", fmt.Sprintf("%d", v.AutoMigrationThreshold)) + require.NoError(t, err) } } if opts.UnifiedStorageEnableSearch { @@ -586,6 +622,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { _, err = provisioningSect.NewKey("allowed_targets", strings.Join(opts.ProvisioningAllowedTargets, "|")) require.NoError(t, err) } + if len(opts.ProvisioningRepositoryTypes) > 0 { + provisioningSect, err := getOrCreateSection("provisioning") + require.NoError(t, err) + _, err = provisioningSect.NewKey("repository_types", strings.Join(opts.ProvisioningRepositoryTypes, "|")) + require.NoError(t, err) + } if opts.EnableSCIM { scimSection, err := getOrCreateSection("auth.scim") require.NoError(t, err) @@ -638,9 +680,14 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) _, err = dbSection.NewKey("query_retries", fmt.Sprintf("%d", queryRetries)) require.NoError(t, err) - _, err = dbSection.NewKey("max_open_conn", "2") + maxConns := opts.DBMaxConns + if maxConns <= 0 { + maxConns = 2 + } + + _, err = dbSection.NewKey("max_open_conn", fmt.Sprintf("%d", maxConns)) require.NoError(t, err) - _, err = dbSection.NewKey("max_idle_conn", "2") + _, err = dbSection.NewKey("max_idle_conn", fmt.Sprintf("%d", maxConns)) require.NoError(t, err) cfgPath := filepath.Join(cfgDir, "test.ini") @@ -690,6 +737,7 @@ type GrafanaOpts struct { UnifiedStorageMaxPageSizeBytes int PermittedProvisioningPaths string ProvisioningAllowedTargets []string + ProvisioningRepositoryTypes []string GrafanaComSSOAPIToken string LicensePath string EnableRecordingRules bool @@ -703,6 +751,10 @@ type GrafanaOpts struct { DisableAuthZClientCache bool ZanzanaReconciliationInterval time.Duration DisableZanzanaCache bool + DisableZanzanaServerCheckQueryCache bool + + // If set to 0, the default (2) is used. + DBMaxConns int // Allow creating grafana dir beforehand Dir string diff --git a/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go new file mode 100644 index 00000000000..a0612e70be7 --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar.go @@ -0,0 +1,43 @@ +package exemplar + +import ( + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +type Exemplar struct { + Id string + Value float64 + Timestamp int64 +} + +func CreateExemplarFrame(labels map[string]string, exemplars []*Exemplar) *data.Frame { + frame := data.NewFrame("exemplar") + frame.Meta = &data.FrameMeta{ + DataTopic: data.DataTopicAnnotations, + } + fields := []*data.Field{ + data.NewField("Time", nil, []time.Time{}), + data.NewField("Value", labels, []float64{}), // add labels here? + data.NewField("Id", nil, []string{}), + } + fields[2].Config = &data.FieldConfig{ + DisplayName: "Profile ID", + } + for name := range labels { + fields = append(fields, data.NewField(name, nil, []string{})) + } + frame.Fields = fields + + for _, e := range exemplars { + frame.AppendRow(time.UnixMilli(e.Timestamp), e.Value, e.Id) + for name, value := range labels { + field, _ := frame.FieldByName(name) + if field != nil { + field.Append(value) + } + } + } + return frame +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go new file mode 100644 index 00000000000..c026e5a4cd7 --- /dev/null +++ b/pkg/tsdb/grafana-pyroscope-datasource/exemplar/exemplar_test.go @@ -0,0 +1,34 @@ +package exemplar + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateExemplarFrame(t *testing.T) { + exemplars := []*Exemplar{ + {Id: "1", Value: 1.0, Timestamp: 100}, + {Id: "2", Value: 2.0, Timestamp: 200}, + } + labels := map[string]string{ + "foo": "bar", + } + frame := CreateExemplarFrame(labels, exemplars) + + require.Equal(t, "exemplar", frame.Name) + require.Equal(t, 4, len(frame.Fields)) + require.Equal(t, "Time", frame.Fields[0].Name) + require.Equal(t, "Value", frame.Fields[1].Name) + require.Equal(t, "Id", frame.Fields[2].Name) + require.Equal(t, "foo", frame.Fields[3].Name) + + rows, err := frame.RowLen() + require.NoError(t, err) + require.Equal(t, 2, rows) + row := frame.RowCopy(0) + require.Equal(t, 4, len(row)) + require.Equal(t, 1.0, row[1]) + require.Equal(t, "1", row[2]) + require.Equal(t, "bar", row[3]) +} diff --git a/pkg/tsdb/grafana-pyroscope-datasource/instance.go b/pkg/tsdb/grafana-pyroscope-datasource/instance.go index 244481aacbd..0c2e7f29baa 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/instance.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/instance.go @@ -18,6 +18,8 @@ import ( "github.com/prometheus/prometheus/promql/parser" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + + typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" ) var ( @@ -31,7 +33,7 @@ type ProfilingClient interface { ProfileTypes(ctx context.Context, start int64, end int64) ([]*ProfileType, error) LabelNames(ctx context.Context, labelSelector string, start int64, end int64) ([]string, error) LabelValues(ctx context.Context, label string, labelSelector string, start int64, end int64) ([]string, error) - GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) + GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error) GetSpanProfile(ctx context.Context, profileTypeID string, labelSelector string, spanSelector []string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error) } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go index 29f27facdd8..7dabd28dc94 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery/types_dataquery_gen.go @@ -32,6 +32,8 @@ type GrafanaPyroscopeDataQuery struct { Limit *int64 `json:"limit,omitempty"` // Sets the maximum number of nodes in the flamegraph. MaxNodes *int64 `json:"maxNodes,omitempty"` + // If set to true, the response will contain annotations + Annotations *bool `json:"annotations,omitempty"` // A unique identifier for the query within the list of targets. // In server side expressions, the refId is used as a variable name to identify results. // By default, the UI will assign A->Z; however setting meaningful names may be useful. @@ -41,8 +43,8 @@ type GrafanaPyroscopeDataQuery struct { // Specify the query flavor // TODO make this required and give it a default QueryType *string `json:"queryType,omitempty"` - // If set to true, the response will contain annotations - Annotations *bool `json:"annotations,omitempty"` + // If set to true, exemplars will be requested + IncludeExemplars bool `json:"includeExemplars"` // For mixed data sources the selected datasource is on the query level. // For non mixed scenarios this is undefined. // TODO find a better way to do this ^ that's friendly to schema @@ -53,7 +55,8 @@ type GrafanaPyroscopeDataQuery struct { // NewGrafanaPyroscopeDataQuery creates a new GrafanaPyroscopeDataQuery object. func NewGrafanaPyroscopeDataQuery() *GrafanaPyroscopeDataQuery { return &GrafanaPyroscopeDataQuery{ - LabelSelector: "{}", - GroupBy: []string{}, + LabelSelector: "{}", + GroupBy: []string{}, + IncludeExemplars: false, } } diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go index aa6af21d6c3..1029b178f42 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go @@ -8,14 +8,16 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" + typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" "connectrpc.com/connect" - querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" - "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" + + querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" + "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect" ) type ProfileType struct { @@ -49,6 +51,13 @@ type Point struct { // Milliseconds unix timestamp Timestamp int64 Annotations []*typesv1.ProfileAnnotation + Exemplars []*Exemplar +} + +type Exemplar struct { + Id string + Value uint64 + Timestamp int64 } type ProfileResponse struct { @@ -99,7 +108,7 @@ func (c *PyroscopeClient) ProfileTypes(ctx context.Context, start int64, end int } } -func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) { +func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetSeries", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector))) defer span.End() req := connect.NewRequest(&querierv1.SelectSeriesRequest{ @@ -110,6 +119,7 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l Step: step, GroupBy: groupBy, Limit: limit, + ExemplarType: exemplarType, }) resp, err := c.connectClient.SelectSeries(ctx, req) @@ -137,6 +147,16 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l Timestamp: p.Timestamp, Annotations: p.Annotations, } + if len(p.Exemplars) > 0 { + points[i].Exemplars = make([]*Exemplar, len(p.Exemplars)) + for j, e := range p.Exemplars { + points[i].Exemplars[j] = &Exemplar{ + Id: e.ProfileId, + Value: e.Value, + Timestamp: e.Timestamp, + } + } + } } series[i] = &Series{ diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go index dbcf94aee94..c6bb80dcc2f 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go @@ -5,10 +5,11 @@ import ( "testing" "connectrpc.com/connect" + "github.com/stretchr/testify/require" + googlev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1" querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1" typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" - "github.com/stretchr/testify/require" ) func Test_PyroscopeClient(t *testing.T) { @@ -19,7 +20,7 @@ func Test_PyroscopeClient(t *testing.T) { t.Run("GetSeries", func(t *testing.T) { limit := int64(42) - resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15) + resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15, typesv1.ExemplarType_EXEMPLAR_TYPE_NONE) require.Nil(t, err) series := &SeriesResponse{ @@ -32,6 +33,21 @@ func Test_PyroscopeClient(t *testing.T) { require.Equal(t, series, resp) }) + t.Run("GetSeriesWithExemplars", func(t *testing.T) { + limit := int64(42) + resp, err := client.GetSeries(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, []string{}, &limit, 15, typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL) + require.Nil(t, err) + + series := &SeriesResponse{ + Series: []*Series{ + {Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30, Exemplars: []*Exemplar{{Id: "id1", Value: 3, Timestamp: 1000}}}, {Timestamp: int64(2000), Value: 10, Exemplars: []*Exemplar{{Id: "id2", Value: 1, Timestamp: 2000}}}}}, + }, + Units: "short", + Label: "alloc_objects", + } + require.Equal(t, series, resp) + }) + t.Run("GetProfile", func(t *testing.T) { maxNodes := int64(-1) resp, err := client.GetProfile(context.Background(), "memory:alloc_objects:count:space:bytes", "{}", 0, 100, &maxNodes) @@ -115,6 +131,21 @@ func (f *FakePyroscopeConnectClient) SelectMergeStacktraces(ctx context.Context, func (f *FakePyroscopeConnectClient) SelectSeries(ctx context.Context, req *connect.Request[querierv1.SelectSeriesRequest]) (*connect.Response[querierv1.SelectSeriesResponse], error) { f.Req = req + if req.Msg.ExemplarType == typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL { + return &connect.Response[querierv1.SelectSeriesResponse]{ + Msg: &querierv1.SelectSeriesResponse{ + Series: []*typesv1.Series{ + { + Labels: []*typesv1.LabelPair{{Name: "foo", Value: "bar"}}, + Points: []*typesv1.Point{ + {Timestamp: int64(1000), Value: 30, Exemplars: []*typesv1.Exemplar{{Timestamp: int64(1000), Value: 3, ProfileId: "id1"}}}, + {Timestamp: int64(2000), Value: 10, Exemplars: []*typesv1.Exemplar{{Timestamp: int64(2000), Value: 1, ProfileId: "id2"}}}, + }, + }, + }, + }, + }, nil + } return &connect.Response[querierv1.SelectSeriesResponse]{ Msg: &querierv1.SelectSeriesResponse{ Series: []*typesv1.Series{ diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query.go b/pkg/tsdb/grafana-pyroscope-datasource/query.go index 1acf3bcee37..be662cef4fd 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/live" + "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/exemplar" "github.com/xlab/treeprint" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" @@ -21,6 +22,8 @@ import ( "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation" "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery" + + typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" ) type queryModel struct { @@ -36,8 +39,12 @@ const ( queryTypeProfile = string(dataquery.PyroscopeQueryTypeProfile) queryTypeMetrics = string(dataquery.PyroscopeQueryTypeMetrics) queryTypeBoth = string(dataquery.PyroscopeQueryTypeBoth) + + exemplarsFeatureToggle = "profilesExemplars" ) +var identityTransformation = func(value float64) float64 { return value } + // query processes single Pyroscope query transforming the response to data.Frame packaged in DataResponse func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse { ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.query", trace.WithAttributes(attribute.String("query_type", query.QueryType))) @@ -77,6 +84,10 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont logger.Error("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep, "function", logEntrypoint()) } } + exemplarType := typesv1.ExemplarType_EXEMPLAR_TYPE_NONE + if qm.IncludeExemplars && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(exemplarsFeatureToggle) { + exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL + } seriesResp, err := d.client.GetSeries( gCtx, profileTypeId, @@ -86,6 +97,7 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont qm.GroupBy, qm.Limit, math.Max(query.Interval.Seconds(), parsedInterval.Seconds()), + exemplarType, ) if err != nil { span.RecordError(err) @@ -475,6 +487,7 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration annotations := make([]*annotation.TimedAnnotation, 0) for _, series := range resp.Series { + exemplars := make([]*exemplar.Exemplar, 0) // We create separate data frames as the series may not have the same length frame := data.NewFrame("series") frameMeta := &data.FrameMeta{PreferredVisualization: "graph"} @@ -516,14 +529,20 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration // Apply rate calculation for cumulative profiles value := point.Value + transformation := identityTransformation if isCumulativeProfile(profileTypeID) && stepDurationSec > 0 { - value = value / stepDurationSec + transformation = func(value float64) float64 { + return value / stepDurationSec + } // Convert CPU nanoseconds to cores if isCPUTimeProfile(profileTypeID) { - value = value / 1e9 + transformation = func(value float64) float64 { + return value / stepDurationSec / 1e9 + } } } + value = transformation(value) valueField.Append(value) if withAnnotations { for _, a := range point.Annotations { @@ -533,10 +552,22 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration }) } } + for _, e := range point.Exemplars { + exemplars = append(exemplars, &exemplar.Exemplar{ + Id: e.Id, + Value: transformation(float64(e.Value)), + Timestamp: e.Timestamp, + }) + } } frame.Fields = fields frames = append(frames, frame) + + if len(exemplars) > 0 { + frame := exemplar.CreateExemplarFrame(labels, exemplars) + frames = append(frames, frame) + } } if len(annotations) > 0 { diff --git a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go index 2f6d7185d7a..a9a7cd41351 100644 --- a/pkg/tsdb/grafana-pyroscope-datasource/query_test.go +++ b/pkg/tsdb/grafana-pyroscope-datasource/query_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" + typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1" "github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation" @@ -487,10 +488,21 @@ func Test_seriesToDataFrame(t *testing.T) { require.Nil(t, frames[0].Meta.Custom) }) - t.Run("CPU time conversion to cores", func(t *testing.T) { + t.Run("CPU time conversion to cores with exemplars", func(t *testing.T) { series := &SeriesResponse{ Series: []*Series{ - {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 3000000000}, {Timestamp: int64(2000), Value: 1500000000}}}, // 3s and 1.5s in nanoseconds + { + Labels: []*LabelPair{}, Points: []*Point{ + { + Timestamp: int64(1000), Value: 3000000000, // 3s in nanoseconds + Exemplars: []*Exemplar{{Value: 300000000, Timestamp: 1000}}, // 0.3s in nanoseconds + }, + { + Timestamp: int64(2000), Value: 1500000000, // 1.5s in nanoseconds + Exemplars: []*Exemplar{{Value: 150000000, Timestamp: 1000}}, // 0.15s in nanoseconds + }, + }, + }, }, Units: "ns", Label: "cpu", @@ -498,19 +510,32 @@ func Test_seriesToDataFrame(t *testing.T) { // should convert nanoseconds to cores and set unit to "cores" frames, err := seriesToDataFrames(series, false, 15.0, "process_cpu:cpu:nanoseconds:cpu:nanoseconds") require.NoError(t, err) - require.Equal(t, 1, len(frames)) + require.Equal(t, 2, len(frames)) require.Equal(t, "cores", frames[0].Fields[1].Config.Unit) // Check values were converted: 3000000000/15/1e9 = 0.2 cores/sec, 1500000000/15/1e9 = 0.1 cores/sec values := fieldValues[float64](frames[0].Fields[1]) require.Equal(t, []float64{0.2, 0.1}, values) + // Check exemplar values were converted: 300000000/15/1e9 = 0.02 cores/sec, 150000000/15/1e9 = 0.01 cores/sec + exemplarValues := fieldValues[float64](frames[1].Fields[1]) + require.Equal(t, []float64{0.02, 0.01}, exemplarValues) }) t.Run("Memory allocation unit conversion to bytes/sec", func(t *testing.T) { series := &SeriesResponse{ Series: []*Series{ - {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 150000000}, {Timestamp: int64(2000), Value: 300000000}}}, // 150 MB, 300 MB + { + Labels: []*LabelPair{}, Points: []*Point{ + { + Timestamp: int64(1000), Value: 150000000, // 150 MB + Exemplars: []*Exemplar{{Value: 15000000, Timestamp: 1000}}, // 15 MB + }, { + Timestamp: int64(2000), Value: 300000000, // 300 MB + Exemplars: []*Exemplar{{Value: 30000000, Timestamp: 1000}}, // 30 MB + }, + }, + }, }, Units: "bytes", Label: "memory_alloc", @@ -518,19 +543,33 @@ func Test_seriesToDataFrame(t *testing.T) { // should convert bytes to binBps and apply rate calculation frames, err := seriesToDataFrames(series, false, 15.0, "memory:alloc_space:bytes:space:bytes") require.NoError(t, err) - require.Equal(t, 1, len(frames)) + require.Equal(t, 2, len(frames)) require.Equal(t, "binBps", frames[0].Fields[1].Config.Unit) // Check values were rate calculated: 150000000/15 = 10000000, 300000000/15 = 20000000 values := fieldValues[float64](frames[0].Fields[1]) require.Equal(t, []float64{10000000, 20000000}, values) + // Check exemplar values were rate calculated: 15000000/15 = 1000000, 30000000/15 = 2000000 + exemplarValues := fieldValues[float64](frames[1].Fields[1]) + require.Equal(t, []float64{1000000, 2000000}, exemplarValues) }) t.Run("Count-based profile unit conversion to ops/sec", func(t *testing.T) { series := &SeriesResponse{ Series: []*Series{ - {Labels: []*LabelPair{}, Points: []*Point{{Timestamp: int64(1000), Value: 1500}, {Timestamp: int64(2000), Value: 3000}}}, // 1500, 3000 contentions + { + Labels: []*LabelPair{}, Points: []*Point{ + { + Timestamp: int64(1000), Value: 1500, // 1500 contentions + Exemplars: []*Exemplar{{Value: 150, Timestamp: 1000}}, // 150 contentions + + }, { + Timestamp: int64(2000), Value: 3000, // 3000 contentions + Exemplars: []*Exemplar{{Value: 300, Timestamp: 1000}}, // 300 contentions + }, + }, + }, }, Units: "short", Label: "contentions", @@ -538,13 +577,16 @@ func Test_seriesToDataFrame(t *testing.T) { // should convert short to ops and apply rate calculation frames, err := seriesToDataFrames(series, false, 15.0, "mutex:contentions:count:contentions:count") require.NoError(t, err) - require.Equal(t, 1, len(frames)) + require.Equal(t, 2, len(frames)) require.Equal(t, "ops", frames[0].Fields[1].Config.Unit) // Check values were rate calculated: 1500/15 = 100, 3000/15 = 200 values := fieldValues[float64](frames[0].Fields[1]) require.Equal(t, []float64{100, 200}, values) + // Check exemplar values were rate calculated: 150/15 = 10, 300/15 = 20 + exemplarValues := fieldValues[float64](frames[1].Fields[1]) + require.Equal(t, []float64{10, 20}, exemplarValues) }) } @@ -605,7 +647,7 @@ func (f *FakeClient) GetSpanProfile(ctx context.Context, profileTypeID, labelSel }, nil } -func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, limit *int64, step float64) (*SeriesResponse, error) { +func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error) { f.Args = []any{profileTypeID, labelSelector, start, end, groupBy, step} return &SeriesResponse{ Series: []*Series{ diff --git a/pkg/util/testutil/context_test.go b/pkg/util/testutil/context_test.go index 4d7ecf670f6..ca5c5abee4f 100644 --- a/pkg/util/testutil/context_test.go +++ b/pkg/util/testutil/context_test.go @@ -15,7 +15,10 @@ import ( func TestMain(m *testing.M) { // make sure we don't leak goroutines after tests in this package have // finished, which means we haven't leaked contexts either - goleak.VerifyTestMain(m) + // (Except for goroutines running specific functions. If possible we should fix this.) + goleak.VerifyTestMain(m, + goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"), + ) } func TestTestContextFunc(t *testing.T) { diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index 9f09c0135d7..4cb15addb0a 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -6002,6 +6002,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, @@ -6152,11 +6156,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -6990,6 +6991,9 @@ "ReportOptions": { "type": "object", "properties": { + "csvEncoding": { + "type": "string" + }, "layout": { "type": "string" }, @@ -8654,11 +8658,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -8906,6 +8907,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, diff --git a/public/api-merged.json b/public/api-merged.json index 323a545e0cf..d511139c7bd 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -6167,10 +6167,16 @@ }, { "type": "string", - "description": "A comma separated list of folder ID(s) to filter the elements by.", + "description": "A comma separated list of folder ID(s) to filter the elements by.\nDeprecated: Use FolderFilterUIDs instead.", "name": "folderFilter", "in": "query" }, + { + "type": "string", + "description": "A comma separated list of folder UID(s) to filter the elements by.", + "name": "folderFilterUIDs", + "in": "query" + }, { "type": "integer", "format": "int64", @@ -13829,12 +13835,6 @@ "BacktestConfig": { "type": "object", "properties": { - "annotations": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, "condition": { "type": "string" }, @@ -13844,8 +13844,16 @@ "$ref": "#/definitions/AlertQuery" } }, + "exec_err_state": { + "type": "string", + "enum": [ + "OK", + "Alerting", + "Error" + ] + }, "for": { - "$ref": "#/definitions/Duration" + "type": "string" }, "from": { "type": "string", @@ -13854,12 +13862,22 @@ "interval": { "$ref": "#/definitions/Duration" }, + "keep_firing_for": { + "type": "string" + }, "labels": { "type": "object", "additionalProperties": { "type": "string" } }, + "missing_series_evals_to_resolve": { + "type": "integer", + "format": "int64" + }, + "namespace_uid": { + "type": "string" + }, "no_data_state": { "type": "string", "enum": [ @@ -13868,12 +13886,18 @@ "OK" ] }, + "rule_group": { + "type": "string" + }, "title": { "type": "string" }, "to": { "type": "string", "format": "date-time" + }, + "uid": { + "type": "string" } } }, @@ -16778,6 +16802,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -16788,6 +16818,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -18453,6 +18489,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, @@ -18699,11 +18739,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -19263,6 +19300,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -19273,6 +19316,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -20310,6 +20359,14 @@ } } }, + "RemoteWriteConfig": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + } + }, "Report": { "type": "object", "properties": { @@ -20454,6 +20511,9 @@ "ReportOptions": { "type": "object", "properties": { + "csvEncoding": { + "type": "string" + }, "layout": { "type": "string" }, @@ -21009,6 +21069,12 @@ "interval": { "$ref": "#/definitions/Duration" }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "limit": { "type": "integer", "format": "int64" @@ -21019,6 +21085,12 @@ "query_offset": { "type": "string" }, + "remote_write": { + "type": "array", + "items": { + "$ref": "#/definitions/RemoteWriteConfig" + } + }, "rules": { "type": "array", "items": { @@ -23055,11 +23127,8 @@ ] }, "timezone": { - "type": "string", - "enum": [ - "utc", - "browser" - ] + "description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string", + "type": "string" }, "weekStart": { "type": "string" @@ -23345,6 +23414,10 @@ "avatarUrl": { "type": "string" }, + "created": { + "type": "string", + "format": "date-time" + }, "email": { "type": "string" }, diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index d7e87d7f5c3..6b46365149f 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -57,7 +57,7 @@ export class AppWrapper extends Component { async componentDidMount() { this.setState({ ready: true }); - $('.preloader').remove(); + this.removePreloader(); // clear any old icon caches const cacheKeys = (await window.caches?.keys()) ?? []; @@ -68,6 +68,15 @@ export class AppWrapper extends Component { } } + removePreloader() { + const preloader = document.querySelector('.preloader'); + if (preloader) { + preloader.remove(); + } else { + console.warn('Preloader element not found'); + } + } + renderRoute = (route: RouteDescriptor) => { return ( { + if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) { + if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') { + const statusError: Status = e.error.data; + dispatch(notifyApp(createErrorNotification(title, new Error(statusError.message || 'Unknown error')))); + return; + } + + if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) { + const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field); + if (nonFieldErrors.length > 0) { + dispatch(notifyApp(createErrorNotification(title))); + } + return; + } + } + + handleError(e, dispatch, title); +}; + export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { listJob: { @@ -37,6 +58,17 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ }), onCacheEntryAdded: createOnCacheEntryAdded('repositories'), }, + listConnection: { + providesTags: (result) => + result + ? [ + { type: 'Connection', id: 'LIST' }, + ...result.items + .map((connection) => ({ type: 'Connection' as const, id: connection.metadata?.name })) + .filter(Boolean), + ] + : [{ type: 'Connection', id: 'LIST' }], + }, deleteRepository: { onQueryStarted: async (_, { queryFulfilled, dispatch }) => { try { @@ -104,34 +136,7 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ try { await queryFulfilled; } catch (e) { - // Handle special cases first - if (typeof e === 'object' && e && 'error' in e && isFetchError(e.error)) { - // Handle Status error responses (Kubernetes style) - if (e.error.data.kind === 'Status' && e.error.data.status === 'Failure') { - const statusError: Status = e.error.data; - dispatch( - notifyApp( - createErrorNotification( - 'Error validating repository', - new Error(statusError.message || 'Unknown error') - ) - ) - ); - return; - } - // Handle TestResults error responses with field errors - if (Array.isArray(e.error.data.errors) && e.error.data.errors.length) { - const nonFieldErrors = e.error.data.errors.filter((err: ErrorDetails) => !err.field); - // Only show notification if there are errors that don't have a field, field errors are handled by the form - if (nonFieldErrors.length > 0) { - dispatch(notifyApp(createErrorNotification('Error validating repository'))); - } - return; - } - } - - // For all other cases, use handleError - handleError(e, dispatch, 'Error validating repository'); + handleProvisioningFormError(e, dispatch, 'Error validating repository'); } }, }, @@ -240,6 +245,70 @@ export const provisioningAPIv0alpha1 = generatedAPI.enhanceEndpoints({ } }, }, + createConnection: { + onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + try { + await queryFulfilled; + dispatch( + notifyApp( + createSuccessNotification(t('provisioning.connection-form.alert-connection-saved', 'Connection saved')) + ) + ); + } catch (e) { + handleProvisioningFormError( + e, + dispatch, + t('provisioning.connection-form.error-save-connection', 'Failed to save connection') + ); + } + }, + }, + replaceConnection: { + onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + try { + await queryFulfilled; + dispatch( + notifyApp( + createSuccessNotification( + t('provisioning.connection-form.alert-connection-updated', 'Connection updated') + ) + ) + ); + } catch (e) { + handleProvisioningFormError( + e, + dispatch, + t('provisioning.connection-form.error-save-connection', 'Failed to save connection') + ); + } + }, + }, + deleteConnection: { + invalidatesTags: (result, error) => (error ? [] : [{ type: 'Connection', id: 'LIST' }]), + onQueryStarted: async (_, { queryFulfilled, dispatch }) => { + try { + await queryFulfilled; + dispatch( + notifyApp( + createSuccessNotification( + t('provisioning.connection-form.alert-connection-deleted', 'Connection deleted') + ) + ) + ); + } catch (e) { + if (e instanceof Error) { + dispatch( + notifyApp( + createErrorNotification( + t('provisioning.connection-form.error-delete-connection', 'Failed to delete connection'), + e + ) + ) + ); + } + } + }, + }, }, }); diff --git a/public/app/api/utils.ts b/public/app/api/utils.ts index 3866bbe977d..9efa6940650 100644 --- a/public/app/api/utils.ts +++ b/public/app/api/utils.ts @@ -1,8 +1,8 @@ import { normalizeError } from '@grafana/api-clients'; import { ThunkDispatch } from 'app/types/store'; -import { notifyApp } from '../core/actions'; import { createErrorNotification } from '../core/copy/appNotification'; +import { notifyApp } from '../core/reducers/appNotification'; /** * Handle an error from a k8s API call diff --git a/public/app/core/actions/index.ts b/public/app/core/actions/index.ts deleted file mode 100644 index d73c489b33e..00000000000 --- a/public/app/core/actions/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { hideAppNotification, notifyApp } from '../reducers/appNotification'; -import { updateNavIndex, updateConfigurationSubtitle } from '../reducers/navModel'; - -export { updateNavIndex, updateConfigurationSubtitle, notifyApp, hideAppNotification }; diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index e340e8276b8..131b2f48ea2 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -10,11 +10,8 @@ import { isShallowEqual } from 'app/core/utils/isShallowEqual'; import { KioskMode } from 'app/types/dashboard'; import { RouteDescriptor } from '../../navigation/types'; -import { buildBreadcrumbs } from '../Breadcrumbs/utils'; -import { logDuplicateUnifiedHistoryEntryEvent } from './History/eventsTracking'; import { ReturnToPreviousProps } from './ReturnToPrevious/ReturnToPrevious'; -import { HistoryEntry } from './types'; export interface AppChromeState { chromeless?: boolean; @@ -34,7 +31,6 @@ export interface AppChromeState { export const DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.docked'; export const DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY = 'grafana.navigation.open'; -export const HISTORY_LOCAL_STORAGE_KEY = 'grafana.navigation.history'; export class AppChromeService { searchBarStorageKey = 'SearchBar_Hidden'; @@ -88,8 +84,6 @@ export class AppChromeService { newState.chromeless = newState.kioskMode === KioskMode.Full || this.currentRoute?.chromeless; if (!this.ignoreStateUpdate(newState, current)) { - config.featureToggles.unifiedHistory && - store.setObject(HISTORY_LOCAL_STORAGE_KEY, this.getUpdatedHistory(newState)); this.state.next(newState); } } @@ -118,40 +112,6 @@ export class AppChromeService { window.sessionStorage.removeItem('returnToPrevious'); }; - private getUpdatedHistory(newState: AppChromeState): HistoryEntry[] { - const breadcrumbs = buildBreadcrumbs(newState.sectionNav.node, newState.pageNav, { text: 'Home', url: '/' }); - const newPageNav = newState.pageNav || newState.sectionNav.node; - - let entries = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []); - const clickedHistory = store.getObject('CLICKING_HISTORY'); - if (clickedHistory) { - store.setObject('CLICKING_HISTORY', false); - return entries; - } - if (!newPageNav) { - return entries; - } - - const lastEntry = entries[0]; - const newEntry = { name: newPageNav.text, views: [], breadcrumbs, time: Date.now(), url: window.location.href }; - const isSamePath = lastEntry && newEntry.url.split('?')[0] === lastEntry.url.split('?')[0]; - - // To avoid adding an entry with the same path twice, we always use the latest one - if (isSamePath) { - entries[0] = newEntry; - } else { - if (lastEntry && lastEntry.name === newEntry.name) { - logDuplicateUnifiedHistoryEntryEvent({ - entryName: newEntry.name, - lastEntryURL: lastEntry.url, - newEntryURL: newEntry.url, - }); - } - entries = [newEntry, ...entries]; - } - - return entries; - } private ignoreStateUpdate(newState: AppChromeState, current: AppChromeState) { if (isShallowEqual(newState, current)) { return true; diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx index c6c8ee37239..3efee947d5b 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.test.tsx @@ -26,7 +26,7 @@ const mockDifferentComponent = { } as ExtensionInfo; const mockPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent, mockDifferentComponent], addedLinks: [], }; @@ -187,7 +187,7 @@ describe('ExtensionSidebarProvider', () => { it('should only include permitted plugins in available components', () => { const permittedPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent], addedLinks: [], }; @@ -256,7 +256,7 @@ describe('ExtensionSidebarProvider', () => { // Call it directly with the test event subscriberFn( new OpenExtensionSidebarEvent({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', props: { testProp: 'test value' }, }) @@ -266,7 +266,7 @@ describe('ExtensionSidebarProvider', () => { expect(screen.getByTestId('is-open')).toHaveTextContent('true'); expect(screen.getByTestId('props')).toHaveTextContent('{"testProp":"test value"}'); const expectedComponentId = JSON.stringify({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', }); expect(screen.getByTestId('docked-component-id')).toHaveTextContent(expectedComponentId); @@ -381,7 +381,7 @@ describe('ExtensionSidebarProvider', () => { subscriberFn( new ToggleExtensionSidebarEvent({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', props: { testProp: 'test value' }, }) @@ -392,7 +392,7 @@ describe('ExtensionSidebarProvider', () => { expect(screen.getByTestId('is-open')).toHaveTextContent('true'); expect(screen.getByTestId('props')).toHaveTextContent('{"testProp":"test value"}'); const expectedComponentId = JSON.stringify({ - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', componentTitle: 'Test Component', }); expect(screen.getByTestId('docked-component-id')).toHaveTextContent(expectedComponentId); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx index 51d73d2e8e5..07f716d1a7d 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionSidebarProvider.tsx @@ -11,7 +11,6 @@ import { DEFAULT_EXTENSION_SIDEBAR_WIDTH, MAX_EXTENSION_SIDEBAR_WIDTH } from './ export const EXTENSION_SIDEBAR_DOCKED_LOCAL_STORAGE_KEY = 'grafana.navigation.extensionSidebarDocked'; export const EXTENSION_SIDEBAR_WIDTH_LOCAL_STORAGE_KEY = 'grafana.navigation.extensionSidebarWidth'; const PERMITTED_EXTENSION_SIDEBAR_PLUGINS = [ - 'grafana-investigations-app', 'grafana-assistant-app', 'grafana-dash-app', // The docs plugin ID is transitioning from grafana-grafanadocsplugin-app to grafana-pathfinder-app. diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx index 8fdb8b5eccb..cb717322af7 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItem.test.tsx @@ -44,7 +44,7 @@ const mockComponent = { }; const mockPluginMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [mockComponent], }; @@ -109,7 +109,7 @@ describe('ExtensionToolbarItem', () => { it('should render a dropdown menu when multiple components are available', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -141,7 +141,7 @@ describe('ExtensionToolbarItem', () => { it('should show menu items when clicking the dropdown button', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -165,7 +165,7 @@ describe('ExtensionToolbarItem', () => { it('should toggle the sidebar when clicking a menu item', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -192,7 +192,7 @@ describe('ExtensionToolbarItem', () => { it('should close the sidebar when clicking an active menu item', async () => { const multipleComponentsMeta = { - pluginId: 'grafana-investigations-app', + pluginId: 'grafana-assistant-app', addedComponents: [ { ...mockComponent, title: 'Component 1' }, { ...mockComponent, title: 'Component 2' }, @@ -218,13 +218,13 @@ describe('ExtensionToolbarItem', () => { it('should render individual buttons when multiple plugins are available', async () => { const plugin1Meta = { - pluginId: 'grafana-investigations-app', - addedComponents: [{ ...mockComponent, title: 'Investigations' }], + pluginId: 'grafana-assistant-app', + addedComponents: [{ ...mockComponent, title: 'Assistant' }], }; const plugin2Meta = { - pluginId: 'grafana-assistant-app', - addedComponents: [{ ...mockComponent, title: 'Assistant' }], + pluginId: 'grafana-dash-app', + addedComponents: [{ ...mockComponent, title: 'Dash' }], }; (usePluginLinks as jest.Mock).mockReturnValue({ @@ -249,7 +249,7 @@ describe('ExtensionToolbarItem', () => { expect(buttons).toHaveLength(2); // Each button should have the correct title - expect(buttons[0]).toHaveAttribute('aria-label', 'Open Investigations'); - expect(buttons[1]).toHaveAttribute('aria-label', 'Open Assistant'); + expect(buttons[0]).toHaveAttribute('aria-label', 'Open Assistant'); + expect(buttons[1]).toHaveAttribute('aria-label', 'Open Dash'); }); }); diff --git a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx index 3145909e7f8..d819b0fa499 100644 --- a/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx +++ b/public/app/core/components/AppChrome/ExtensionSidebar/ExtensionToolbarItemButton.tsx @@ -17,8 +17,6 @@ function getPluginIcon(pluginId?: string): string { case 'grafana-grafanadocsplugin-app': case 'grafana-pathfinder-app': return 'book'; - case 'grafana-investigations-app': - return 'eye'; default: return 'ai-sparkle'; } diff --git a/public/app/core/components/AppChrome/History/HistoryContainer.tsx b/public/app/core/components/AppChrome/History/HistoryContainer.tsx deleted file mode 100644 index 88cbde02856..00000000000 --- a/public/app/core/components/AppChrome/History/HistoryContainer.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { css } from '@emotion/css'; -import { useEffect } from 'react'; -import { useToggle } from 'react-use'; - -import { GrafanaTheme2, store } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { Drawer, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { appEvents } from 'app/core/app_events'; -import { RecordHistoryEntryEvent } from 'app/types/events'; - -import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService'; -import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; -import { HistoryEntry } from '../types'; - -import { HistoryWrapper } from './HistoryWrapper'; -import { logUnifiedHistoryDrawerInteractionEvent } from './eventsTracking'; - -export function HistoryContainer() { - const [showHistoryDrawer, onToggleShowHistoryDrawer] = useToggle(false); - const styles = useStyles2(getStyles); - - useEffect(() => { - const sub = appEvents.subscribe(RecordHistoryEntryEvent, (ev) => { - const clickedHistory = store.getObject('CLICKING_HISTORY'); - if (clickedHistory) { - store.setObject('CLICKING_HISTORY', false); - return; - } - const history = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []); - let lastEntry = history[0]; - const newUrl = ev.payload.url; - const lastUrl = lastEntry.views[0]?.url; - if (lastUrl !== newUrl) { - lastEntry.views = [ - { - name: ev.payload.name, - description: ev.payload.description, - url: newUrl, - time: Date.now(), - }, - ...lastEntry.views, - ]; - store.setObject(HISTORY_LOCAL_STORAGE_KEY, [...history]); - } - return () => { - sub.unsubscribe(); - }; - }); - }, []); - - return ( - <> - { - onToggleShowHistoryDrawer(); - logUnifiedHistoryDrawerInteractionEvent({ type: 'open' }); - }} - iconOnly - icon="history" - aria-label={t('nav.history-container.drawer-tittle', 'History')} - /> - - {showHistoryDrawer && ( - { - onToggleShowHistoryDrawer(); - logUnifiedHistoryDrawerInteractionEvent({ type: 'close' }); - }} - size="sm" - > - onToggleShowHistoryDrawer(false)} /> - - )} - - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - separator: css({ - [theme.breakpoints.down('sm')]: { - display: 'none', - }, - }), - }; -}; diff --git a/public/app/core/components/AppChrome/History/HistoryWrapper.tsx b/public/app/core/components/AppChrome/History/HistoryWrapper.tsx deleted file mode 100644 index d25c86d6f3d..00000000000 --- a/public/app/core/components/AppChrome/History/HistoryWrapper.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { css, cx } from '@emotion/css'; -import moment from 'moment'; -import { useState } from 'react'; - -import { FieldType, GrafanaTheme2, store } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { Box, Button, Card, Icon, IconButton, Space, Sparkline, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui'; -import { formatDate } from 'app/core/internationalization/dates'; - -import { HISTORY_LOCAL_STORAGE_KEY } from '../AppChromeService'; -import { HistoryEntry } from '../types'; - -import { logClickUnifiedHistoryEntryEvent, logUnifiedHistoryShowMoreEvent } from './eventsTracking'; - -export function HistoryWrapper({ onClose }: { onClose: () => void }) { - const history = store.getObject(HISTORY_LOCAL_STORAGE_KEY, []).filter((entry) => { - return moment(entry.time).isAfter(moment().subtract(2, 'day').startOf('day')); - }); - const [numItemsToShow, setNumItemsToShow] = useState(5); - - const selectedTime = history.find((entry) => { - return entry.url === window.location.href || entry.views.some((view) => view.url === window.location.href); - })?.time; - - const hist = history.slice(0, numItemsToShow).reduce((acc: { [key: string]: HistoryEntry[] }, entry) => { - const date = moment(entry.time); - let key = ''; - if (date.isSame(moment(), 'day')) { - key = t('nav.history-wrapper.today', 'Today'); - } else if (date.isSame(moment().subtract(1, 'day'), 'day')) { - key = t('nav.history-wrapper.yesterday', 'Yesterday'); - } else { - key = date.format('YYYY-MM-DD'); - } - acc[key] = [...(acc[key] || []), entry]; - return acc; - }, {}); - const styles = useStyles2(getStyles); - return ( - - - {Object.keys(hist).map((entries, date) => { - return ( - - - {entries} - -
- {hist[entries].map((entry, index) => { - return ( - onClose()} - /> - ); - })} -
-
- ); - })} -
- {history.length > numItemsToShow && ( - - - - )} -
- ); -} -interface ItemProps { - entry: HistoryEntry; - isSelected: boolean; - onClick: () => void; -} - -function HistoryEntryAppView({ entry, isSelected, onClick }: ItemProps) { - const styles = useStyles2(getStyles); - const theme = useTheme2(); - const [isExpanded, setIsExpanded] = useState(isSelected && entry.views.length > 0); - - const { breadcrumbs, views, time, url, sparklineData } = entry; - const expandedLabel = isExpanded - ? t('nav.history-wrapper.collapse', 'Collapse') - : t('nav.history-wrapper.expand', 'Expand'); - const entryIconLabel = isExpanded - ? t('nav.history-wrapper.icon-selected', 'Selected Entry') - : t('nav.history-wrapper.icon-unselected', 'Normal Entry'); - const selectedViewTime = - isSelected && - entry.views.find((entry) => { - return entry.url === window.location.href; - })?.time; - - return ( - - - - {views.length > 0 ? ( - setIsExpanded(!isExpanded)} - aria-label={expandedLabel} - className={styles.iconButton} - /> - ) : ( - - )} - - { - store.setObject('CLICKING_HISTORY', true); - onClick(); - logClickUnifiedHistoryEntryEvent({ entryURL: url }); - }} - href={url} - isCompact={true} - className={isSelected ? styles.card : cx(styles.card, styles.cardSelected)} - > - -
- {breadcrumbs.map((breadcrumb, index) => ( - - {breadcrumb.text}{' '} - {index !== breadcrumbs.length - 1 - ? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings - '> ' - : ''} - - ))} -
- - {formatDate(time, { timeStyle: 'short' })} - - {sparklineData && ( - - )} -
-
-
- {isExpanded && ( -
- {views.map((view, index) => { - return ( - { - store.setObject('CLICKING_HISTORY', true); - onClick(); - logClickUnifiedHistoryEntryEvent({ entryURL: view.url, subEntry: 'timeRange' }); - }} - isCompact={true} - className={view.time === selectedViewTime ? undefined : styles.subCard} - > - - {view.name} - {view.description && ( - - {view.description} - - )} - - - ); - })} -
- )} -
-
- ); -} -const getStyles = (theme: GrafanaTheme2) => { - return { - card: css({ - label: 'card', - background: 'none', - margin: theme.spacing(0.5, 0), - }), - cardSelected: css({ - label: 'card-selected', - background: 'none', - }), - subCard: css({ - label: 'subcard', - background: 'none', - margin: 0, - }), - iconButton: css({ - label: 'expand-button', - margin: 0, - }), - iconButtonCircle: css({ - label: 'blue-circle-icon', - margin: 0, - background: theme.colors.background.primary, - fill: theme.colors.primary.main, - cursor: 'default', - '&:hover:before': { - background: 'none', - }, - //Need this to place the icon on the line, otherwise the line will appear on top of the icon - zIndex: 0, - }), - iconButtonDot: css({ - label: 'blue-dot-icon', - margin: 0, - color: theme.colors.primary.main, - border: theme.shape.radius.circle, - cursor: 'default', - '&:hover:before': { - background: 'none', - }, - //Need this to place the icon on the line, otherwise the line will appear on top of the icon - zIndex: 0, - }), - expanded: css({ - label: 'expanded', - display: 'flex', - flexDirection: 'column', - marginLeft: theme.spacing(6), - gap: theme.spacing(1), - position: 'relative', - '&:before': { - content: '""', - position: 'absolute', - left: 0, - top: 0, - height: '100%', - width: '1px', - background: theme.colors.border.weak, - }, - }), - timeline: css({ - label: 'timeline', - position: 'relative', - height: '100%', - width: '100%', - paddingLeft: theme.spacing(2), - '&:before': { - content: '""', - position: 'absolute', - left: theme.spacing(5.75), - top: 0, - height: '100%', - width: '1px', - borderLeft: `1px dashed ${theme.colors.border.strong}`, - }, - }), - }; -}; diff --git a/public/app/core/components/AppChrome/History/eventsTracking.ts b/public/app/core/components/AppChrome/History/eventsTracking.ts deleted file mode 100644 index 5d49bd1c978..00000000000 --- a/public/app/core/components/AppChrome/History/eventsTracking.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { reportInteraction } from '@grafana/runtime'; - -const UNIFIED_HISTORY_ENTRY_CLICKED = 'grafana_unified_history_entry_clicked'; -const UNIFIED_HISTORY_ENTRY_DUPLICATED = 'grafana_unified_history_duplicated_entry_rendered'; -const UNIFIED_HISTORY_DRAWER_INTERACTION = 'grafana_unified_history_drawer_interaction'; -const UNIFIED_HISTORY_DRAWER_SHOW_MORE = 'grafana_unified_history_show_more'; - -//Currently just 'timeRange' is supported -//in short term, we could add 'templateVariables' for example -type subEntryTypes = 'timeRange'; - -//Whether the user opens or closes the `HistoryDrawer` -type UnifiedHistoryDrawerInteraction = 'open' | 'close'; - -interface UnifiedHistoryEntryClicked { - //We will also work with the current URL but we will get this from Rudderstack data - //URL to return to - entryURL: string; - //In the case we want to go back to a specific query param, currently just a specific time range - subEntry?: subEntryTypes; -} - -interface UnifiedHistoryEntryDuplicated { - // Common name of the history entries - entryName: string; - // URL of the last entry - lastEntryURL: string; - // URL of the new entry - newEntryURL: string; -} - -//Event triggered when a user clicks on an entry of the `HistoryDrawer` -export const logClickUnifiedHistoryEntryEvent = ({ entryURL, subEntry }: UnifiedHistoryEntryClicked) => { - reportInteraction(UNIFIED_HISTORY_ENTRY_CLICKED, { - entryURL, - subEntry, - }); -}; - -//Event triggered when history entry name matches the previous one -//so we keep track of duplicated entries and be able to analyze them -export const logDuplicateUnifiedHistoryEntryEvent = ({ - entryName, - lastEntryURL, - newEntryURL, -}: UnifiedHistoryEntryDuplicated) => { - reportInteraction(UNIFIED_HISTORY_ENTRY_DUPLICATED, { - entryName, - lastEntryURL, - newEntryURL, - }); -}; - -//We keep track of users open and closing the drawer -export const logUnifiedHistoryDrawerInteractionEvent = ({ type }: { type: UnifiedHistoryDrawerInteraction }) => { - reportInteraction(UNIFIED_HISTORY_DRAWER_INTERACTION, { - type, - }); -}; - -//We keep track of users clicking on the `Show more` button -export const logUnifiedHistoryShowMoreEvent = () => { - reportInteraction(UNIFIED_HISTORY_DRAWER_SHOW_MORE); -}; diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index d5179570ef3..23c1624dc05 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -6,7 +6,6 @@ import { Components } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { ScopesContextValue } from '@grafana/runtime'; import { Icon, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; -import { config } from 'app/core/config'; import { MEGA_MENU_TOGGLE_ID } from 'app/core/constants'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth'; @@ -19,7 +18,6 @@ import { HomeLink } from '../../Branding/Branding'; import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; import { ExtensionToolbarItem } from '../ExtensionSidebar/ExtensionToolbarItem'; -import { HistoryContainer } from '../History/HistoryContainer'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; import { QuickAdd } from '../QuickAdd/QuickAdd'; @@ -60,7 +58,6 @@ export const SingleTopBar = memo(function SingleTopBar({ const profileNode = useSelector((state) => state.navIndex['profile']); const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); - const unifiedHistoryEnabled = config.featureToggles.unifiedHistory; const isSmallScreen = !useMediaQueryMinWidth('sm'); const isLargeScreen = useMediaQueryMinWidth('lg'); const topLevelScopes = !showToolbarLevel && isLargeScreen && scopes?.state.enabled; @@ -96,7 +93,6 @@ export const SingleTopBar = memo(function SingleTopBar({ > - {unifiedHistoryEnabled && !isSmallScreen && } {!isSmallScreen && } diff --git a/public/app/core/components/AppChrome/types.ts b/public/app/core/components/AppChrome/types.ts index 6cf72c936f5..de9183423c3 100644 --- a/public/app/core/components/AppChrome/types.ts +++ b/public/app/core/components/AppChrome/types.ts @@ -4,28 +4,3 @@ export interface ToolbarUpdateProps { pageNav?: NavModelItem; actions?: React.ReactNode; } - -export interface HistoryEntryView { - name: string; - description: string; - url: string; - time: number; -} - -export interface HistoryEntrySparkline { - values: number[]; - range: { - min: number; - max: number; - delta: number; - }; -} - -export interface HistoryEntry { - name: string; - time: number; - breadcrumbs: NavModelItem[]; - url: string; - views: HistoryEntryView[]; - sparklineData?: HistoryEntrySparkline; -} diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 8d5a1d3161e..e194ada32ed 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -4,10 +4,9 @@ import { useLocation } from 'react-router-dom'; import { AlertErrorPayload, AlertPayload, AppEvents, GrafanaTheme2 } from '@grafana/data'; import { useStyles2, Stack } from '@grafana/ui'; -import { notifyApp, hideAppNotification } from 'app/core/actions'; import { appEvents } from 'app/core/app_events'; import { useGrafana } from 'app/core/context/GrafanaContext'; -import { selectVisible } from 'app/core/reducers/appNotification'; +import { hideAppNotification, notifyApp, selectVisible } from 'app/core/reducers/appNotification'; import { useSelector, useDispatch } from 'app/types/store'; import { diff --git a/public/app/core/components/BouncingLoader/BouncingLoader.tsx b/public/app/core/components/BouncingLoader/BouncingLoader.tsx index 025f0469cdf..bb4f12306bb 100644 --- a/public/app/core/components/BouncingLoader/BouncingLoader.tsx +++ b/public/app/core/components/BouncingLoader/BouncingLoader.tsx @@ -3,7 +3,8 @@ import { css, keyframes } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; -import grafanaIconSvg from 'img/grafana_icon.svg'; + +import { Branding } from '../Branding/Branding'; export function BouncingLoader() { const styles = useStyles2(getStyles); @@ -16,7 +17,7 @@ export function BouncingLoader() { aria-label={t('bouncing-loader.label', 'Loading')} >
- +
); diff --git a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx index 9ff66c518ee..a6a9041f6c1 100644 --- a/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx +++ b/public/app/core/components/SplitPaneWrapper/SplitPaneWrapper.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import SplitPane, { Split } from 'react-split-pane'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { getDragStyles } from '@grafana/ui'; -import { config } from 'app/core/config'; interface Props { splitOrientation?: Split; diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 6c757c9436a..f725aab5472 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -1,6 +1,5 @@ import { PluginState } from '@grafana/data'; import { config, GrafanaBootConfig } from '@grafana/runtime'; -export { config, type GrafanaBootConfig as Settings }; let grafanaConfig: GrafanaBootConfig = config; diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 3f1dd8f484f..8dc671d303f 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -6,7 +6,7 @@ import { dispatch as storeDispatch } from 'app/store/store'; import { AppNotificationSeverity, AppNotification } from 'app/types/appNotifications'; import { useDispatch } from 'app/types/store'; -import { notifyApp } from '../actions'; +import { notifyApp } from '../reducers/appNotification'; const defaultSuccessNotification = { title: '', diff --git a/public/app/core/icons/cached.json b/public/app/core/icons/cached.json index 6e35e64dd0c..9987d867738 100644 --- a/public/app/core/icons/cached.json +++ b/public/app/core/icons/cached.json @@ -29,6 +29,7 @@ "unicons/bookmark", "unicons/book-open", "unicons/brackets-curly", + "unicons/brain", "unicons/bug", "unicons/building", "unicons/calculator-alt", diff --git a/public/app/core/internationalization/dates.ts b/public/app/core/internationalization/dates.ts index 9ef7bbbdb82..c151e505487 100644 --- a/public/app/core/internationalization/dates.ts +++ b/public/app/core/internationalization/dates.ts @@ -2,7 +2,7 @@ import deepEqual from 'fast-deep-equal'; import memoize from 'micro-memoize'; import { getLanguage } from '@grafana/i18n/internal'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; const deepMemoize: typeof memoize = (fn) => memoize(fn, { isEqual: deepEqual }); diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index b73e12e2f22..d22835ac69e 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -1,7 +1,6 @@ import { ReducersMapObject } from '@reduxjs/toolkit'; import { AnyAction, combineReducers } from 'redux'; -import { notificationsAPIv0alpha1, rulesAPIv0alpha1 } from '@grafana/alerting/unstable'; import { allReducers as allApiClientReducers } from '@grafana/api-clients/rtkq'; import { generatedAPI as legacyAPI } from '@grafana/api-clients/rtkq/legacy'; import sharedReducers from 'app/core/reducers'; @@ -51,8 +50,6 @@ const rootReducers = { [legacyAPI.reducerPath]: legacyAPI.reducer, plugins: pluginsReducer, [alertingApi.reducerPath]: alertingApi.reducer, - [notificationsAPIv0alpha1.reducerPath]: notificationsAPIv0alpha1.reducer, - [rulesAPIv0alpha1.reducerPath]: rulesAPIv0alpha1.reducer, [publicDashboardApi.reducerPath]: publicDashboardApi.reducer, [browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer, ...allApiClientReducers, diff --git a/public/app/core/services/theme.ts b/public/app/core/services/theme.ts index 542aa1ab743..82bd949904f 100644 --- a/public/app/core/services/theme.ts +++ b/public/app/core/services/theme.ts @@ -1,8 +1,7 @@ import { getThemeById } from '@grafana/data/internal'; -import { ThemeChangedEvent } from '@grafana/runtime'; +import { config, ThemeChangedEvent } from '@grafana/runtime'; import { appEvents } from '../app_events'; -import { config } from '../config'; import { contextSrv } from '../services/context_srv'; import { PreferencesService } from './PreferencesService'; diff --git a/public/app/core/utils/richHistory.ts b/public/app/core/utils/richHistory.ts index e94929939f4..f8944dcd7d6 100644 --- a/public/app/core/utils/richHistory.ts +++ b/public/app/core/utils/richHistory.ts @@ -10,7 +10,6 @@ import { } from '@grafana/data'; import { t } from '@grafana/i18n'; import { getDataSourceSrv } from '@grafana/runtime'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createWarningNotification } from 'app/core/copy/appNotification'; import { dispatch } from 'app/store/store'; import { RichHistoryQuery } from 'app/types/explore'; @@ -23,6 +22,7 @@ import { } from '../history/RichHistoryStorage'; import { createRetentionPeriodBoundary } from '../history/richHistoryLocalStorageUtils'; import { getLocalRichHistoryStorage, getRichHistoryStorage } from '../history/richHistoryStorageProvider'; +import { notifyApp } from '../reducers/appNotification'; import { contextSrv } from '../services/context_srv'; import { diff --git a/public/app/core/utils/shortLinks.ts b/public/app/core/utils/shortLinks.ts index 208f2ae0fcc..e6e260e6ccc 100644 --- a/public/app/core/utils/shortLinks.ts +++ b/public/app/core/utils/shortLinks.ts @@ -5,7 +5,6 @@ import { t } from '@grafana/i18n'; import { getBackendSrv, config, locationService } from '@grafana/runtime'; import { sceneGraph, SceneTimeRangeLike, VizPanel } from '@grafana/scenes'; import { shortURLAPIv1beta1 } from 'app/api/clients/shorturl/v1beta1'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification, createSuccessNotification } from 'app/core/copy/appNotification'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl'; @@ -14,6 +13,7 @@ import { dispatch } from 'app/store/store'; import { ShortURL } from '../../../../apps/shorturl/plugin/src/generated/shorturl/v1beta1/shorturl_object_gen'; import { extractErrorMessage } from '../../api/utils'; import { ShareLinkConfiguration } from '../../features/dashboard-scene/sharing/ShareButton/utils'; +import { notifyApp } from '../reducers/appNotification'; import { copyStringToClipboard } from './explore'; diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 3debbd886a8..a3d93b12bad 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -115,12 +115,15 @@ export const OrgUsersTable = ({ { id: 'lastSeenAtAge', header: 'Last active', - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + cell: ({ cell: { value }, row: { original } }: Cell<'lastSeenAtAge'>) => { + // If lastSeenAt is before created, user has never logged in + const neverLoggedIn = + original.lastSeenAt && original.created && new Date(original.lastSeenAt) < new Date(original.created); return ( <> {value && ( <> - {value === '10 years' ? ( + {neverLoggedIn ? ( Never diff --git a/public/app/features/admin/Users/UsersTable.tsx b/public/app/features/admin/Users/UsersTable.tsx index a3a1f6be297..b4d34f0d438 100644 --- a/public/app/features/admin/Users/UsersTable.tsx +++ b/public/app/features/admin/Users/UsersTable.tsx @@ -135,12 +135,19 @@ export const UsersTable = ({ content: 'Time since user was seen using Grafana', iconName: 'question-circle', }, - cell: ({ cell: { value } }: Cell<'lastSeenAtAge'>) => { + cell: ({ + cell: { value }, + row: { + original: { lastSeenAt, created }, + }, + }: Cell<'lastSeenAtAge'>) => { + // The user has never logged in if lastSeenAt is before its creation date. + const neverLoggedIn = lastSeenAt && created && new Date(lastSeenAt) < new Date(created); return ( <> {value && ( <> - {value === '10 years' ? ( + {neverLoggedIn ? ( Never diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 34459b9581e..418327face5 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -1,7 +1,7 @@ import { Navigate } from 'react-router-dom-v5-compat'; +import { config } from '@grafana/runtime'; import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport'; -import { config } from 'app/core/config'; import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types'; import { AccessControlAction } from 'app/types/accessControl'; diff --git a/public/app/features/alerting/state/ThresholdMapper.ts b/public/app/features/alerting/state/ThresholdMapper.ts index 48b9b7c656a..140330b9d06 100644 --- a/public/app/features/alerting/state/ThresholdMapper.ts +++ b/public/app/features/alerting/state/ThresholdMapper.ts @@ -1,4 +1,4 @@ -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; export const hiddenReducerTypes = ['percent_diff', 'percent_diff_abs']; diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx index ad6ee94d73e..b57242f0e49 100644 --- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx @@ -79,6 +79,9 @@ describe('new receiver', () => { // click test await user.click(ui.testContactPoint.get()); + // close the modal + await user.click(screen.getByRole('button', { name: 'Close' })); + // we shouldn't be testing implementation details but when the request is successful // it can't seem to assert on the success toast await user.click(ui.saveContactButton.get()); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index d19ac4bb619..fd6cbf40fae 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -36,7 +36,11 @@ import { isExpressionQueryInAlert, } from '../../../rule-editor/formProcessing'; import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; -import { GRAFANA_RULES_SOURCE_NAME, getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; +import { + GRAFANA_RULES_SOURCE_NAME, + getDefaultOrFirstCompatibleDataSource, + getRulesDataSources, +} from '../../../utils/datasource'; import { PromOrLokiQuery, isPromOrLokiQuery } from '../../../utils/rule-form'; import { isCloudAlertingRuleByType, @@ -417,7 +421,9 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod ]); const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana]; - + // Only show the data source managed option if there are data sources with manageAlerts enabled + const hasAlertEnabledDataSources = useMemo(() => getRulesDataSources().length > 0, []); + const canSelectDataSourceManaged = onlyOneDSInQueries(queries) && hasAlertEnabledDataSources; if (!type) { return null; } @@ -437,8 +443,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange, mod } : undefined; - const canSelectDataSourceManaged = onlyOneDSInQueries(queries); - return ( <> - {mode === 'edit' && ( + {mode === 'edit' && hasAlertEnabledDataSources && ( <> { + setupBackendSrv(); +}); + describe('render contact point link', () => { it('should render correctly', async () => { server.use(...listContactPointsScenario); diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index 0d8636c98bc..fcd9edebb2e 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -3,7 +3,8 @@ import { render, screen, userEvent, waitFor } from 'test/test-utils'; import { byLabelText, byRole, byText } from 'testing-library-selector'; import { setPluginLinksHook } from '@grafana/runtime'; -import { setupMswServer } from 'app/features/alerting/unified/mockApi'; +import server from '@grafana/test-utils/server'; +import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types/accessControl'; import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting'; @@ -22,6 +23,7 @@ import { mockPluginLinkExtension, mockPromAlertingRule, mockRulerGrafanaRecordingRule, + mockRulerGrafanaRule, } from '../../mocks'; import { grafanaRulerRule } from '../../mocks/grafanaRulerApi'; import { grantPermissionsHelper } from '../../test/test-utils'; @@ -130,6 +132,8 @@ const dataSources = { }; describe('RuleViewer', () => { + const api = mockAlertRuleApi(server); + beforeEach(() => { setupDataSources(...Object.values(dataSources)); }); @@ -249,19 +253,22 @@ describe('RuleViewer', () => { expect(screen.getAllByRole('row')).toHaveLength(7); expect(screen.getAllByRole('row')[1]).toHaveTextContent(/6Provisioning2025-01-18 04:35:17/i); - expect(screen.getAllByRole('row')[1]).toHaveTextContent('+3-3Latest'); + expect(screen.getAllByRole('row')[1]).toHaveTextContent('Updated by provisioning service'); + expect(screen.getAllByRole('row')[1]).toHaveTextContent('+4-3Latest'); expect(screen.getAllByRole('row')[2]).toHaveTextContent(/5Alerting2025-01-17 04:35:17/i); - expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-5'); + expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-6'); expect(screen.getAllByRole('row')[3]).toHaveTextContent(/4different user2025-01-16 04:35:17/i); - expect(screen.getAllByRole('row')[3]).toHaveTextContent('+5-5'); + expect(screen.getAllByRole('row')[3]).toHaveTextContent('Changed alert title and thresholds'); + expect(screen.getAllByRole('row')[3]).toHaveTextContent('+6-5'); expect(screen.getAllByRole('row')[4]).toHaveTextContent(/3user12025-01-15 04:35:17/i); - expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-9'); + expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-10'); expect(screen.getAllByRole('row')[5]).toHaveTextContent(/2User ID foo2025-01-14 04:35:17/i); - expect(screen.getAllByRole('row')[5]).toHaveTextContent('+11-7'); + expect(screen.getAllByRole('row')[5]).toHaveTextContent('Updated evaluation interval and routing'); + expect(screen.getAllByRole('row')[5]).toHaveTextContent('+12-7'); expect(screen.getAllByRole('row')[6]).toHaveTextContent(/1Unknown 2025-01-13 04:35:17/i); @@ -275,9 +282,10 @@ describe('RuleViewer', () => { await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled(); - expect(screen.getByRole('cell', { name: /provisioning/i })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: /alerting/i })).toBeInTheDocument(); - expect(screen.getByRole('cell', { name: /Unknown/i })).toBeInTheDocument(); + // Check for special updated_by values - use getAllByRole since some text appears in multiple columns + expect(screen.getAllByRole('cell', { name: /provisioning/i }).length).toBeGreaterThan(0); + expect(screen.getByRole('cell', { name: /^alerting$/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /^Unknown$/i })).toBeInTheDocument(); expect(screen.getByRole('cell', { name: /user id foo/i })).toBeInTheDocument(); }); @@ -321,6 +329,47 @@ describe('RuleViewer', () => { await renderRuleViewer(rule, ruleIdentifier); expect(screen.queryByText('Labels')).not.toBeInTheDocument(); }); + + it('shows Notes column when versions have messages', async () => { + await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); + + expect(await screen.findByRole('columnheader', { name: /Notes/i })).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(7); // 1 header + 6 data rows + expect(screen.getByRole('cell', { name: /Updated by provisioning service/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /Changed alert title and thresholds/i })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: /Updated evaluation interval and routing/i })).toBeInTheDocument(); + }); + + it('does not show Notes column when no versions have messages', async () => { + const versionsWithoutMessages = [ + mockRulerGrafanaRule( + {}, + { + uid: grafanaRulerRule.grafana_alert.uid, + version: 2, + updated: '2025-01-14T09:35:17.000Z', + updated_by: { uid: 'foo', name: '' }, + } + ), + mockRulerGrafanaRule( + {}, + { + uid: grafanaRulerRule.grafana_alert.uid, + version: 1, + updated: '2025-01-13T09:35:17.000Z', + updated_by: null, + } + ), + ]; + api.getAlertRuleVersionHistory(grafanaRulerRule.grafana_alert.uid, versionsWithoutMessages); + + await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory); + + await screen.findByRole('button', { name: /Compare versions/i }); + + expect(screen.getAllByRole('row')).toHaveLength(3); // 1 header + 2 data rows + expect(screen.queryByRole('columnheader', { name: /Notes/i })).not.toBeInTheDocument(); + }); }); }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx index c445eaa027b..7e1b3826080 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/Details.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from 'test/test-utils'; import { setupMockServer } from '@grafana/test-utils/server'; +import { setupBackendSrv } from '../../../mockApi'; import { mockCombinedRule } from '../../../mocks'; import { alertingFactory } from '../../../mocks/server/db'; import { setupDataSources } from '../../../testSetup/datasources'; @@ -12,6 +13,7 @@ import { Details } from './Details'; const server = setupMockServer(); beforeAll(() => { + setupBackendSrv(); setupDataSources(); }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx index 74dabe18449..ac6c75b93ef 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx @@ -1,8 +1,9 @@ +import { css } from '@emotion/css'; import { useMemo, useState } from 'react'; import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text } from '@grafana/ui'; +import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text, useStyles2 } from '@grafana/ui'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { computeVersionDiff } from 'app/features/alerting/unified/utils/diff'; import { RuleIdentifier } from 'app/types/unified-alerting'; @@ -33,6 +34,7 @@ export function VersionHistoryTable({ onRestoreError, canRestore, }: VersionHistoryTableProps) { + const styles = useStyles2(getStyles); const [showConfirmModal, setShowConfirmModal] = useState(false); const [ruleToRestore, setRuleToRestore] = useState>(); const ruleToRestoreUid = ruleToRestore?.grafana_alert?.uid ?? ''; @@ -41,6 +43,8 @@ export function VersionHistoryTable({ [ruleToRestoreUid] ); + const hasAnyNotes = useMemo(() => ruleVersions.some((v) => v.grafana_alert.message), [ruleVersions]); + const showConfirmation = (ruleToRestore: RulerGrafanaRuleDTO) => { setShowConfirmModal(true); setRuleToRestore(ruleToRestore); @@ -52,6 +56,15 @@ export function VersionHistoryTable({ const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown'); + const notesColumn: Column> = { + id: 'notes', + header: t('core.versionHistory.table.notes', 'Notes'), + cell: ({ row }) => { + const message = row.original.grafana_alert.message; + return message || null; + }, + }; + const columns: Array>> = [ { disableGrow: true, @@ -91,9 +104,12 @@ export function VersionHistoryTable({ if (!value) { return unknown; } - return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'; + return ( + {dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'} + ); }, }, + ...(hasAnyNotes ? [notesColumn] : []), { id: 'diff', disableGrow: true, @@ -179,3 +195,9 @@ export function VersionHistoryTable({ ); } + +const getStyles = () => ({ + nowrap: css({ + whiteSpace: 'nowrap', + }), +}); diff --git a/public/app/features/alerting/unified/components/silences/utils.ts b/public/app/features/alerting/unified/components/silences/utils.ts index a0909f7e647..af3ba2cbfcb 100644 --- a/public/app/features/alerting/unified/components/silences/utils.ts +++ b/public/app/features/alerting/unified/components/silences/utils.ts @@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields => startsAt: interval.start.toISOString(), endsAt: interval.end.toISOString(), comment: silence.comment, - createdBy: silence.createdBy, + createdBy: isExpired ? contextSrv.user.name : silence.createdBy, duration: intervalToAbbreviatedDurationString(interval), isRegex: false, matchers: silence.matchers?.map(matcherToMatcherField) || [], diff --git a/public/app/features/alerting/unified/mockApi.ts b/public/app/features/alerting/unified/mockApi.ts index 67053ade5f6..f89e0a18098 100644 --- a/public/app/features/alerting/unified/mockApi.ts +++ b/public/app/features/alerting/unified/mockApi.ts @@ -242,6 +242,10 @@ export function mockDashboardApi(server: SetupServer) { }; } +export function setupBackendSrv() { + setBackendSrv(backendSrv); +} + /** * Sets up MSW server with additional handlers for Alerting tests */ @@ -249,7 +253,7 @@ export function setupMswServer() { setupMockServer(allHandlers); beforeAll(() => { - setBackendSrv(backendSrv); + setupBackendSrv(); }); afterEach(() => { diff --git a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts index b0f58408306..85905d480b3 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts @@ -154,6 +154,7 @@ export const rulerRuleVersionHistoryHandler = () => { uid: 'service', name: '', }; + draft.grafana_alert.message = 'Updated by provisioning service'; }), produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { draft.grafana_alert.version = 5; @@ -171,6 +172,7 @@ export const rulerRuleVersionHistoryHandler = () => { uid: 'different', name: 'different user', }; + draft.grafana_alert.message = 'Changed alert title and thresholds'; }), produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { draft.grafana_alert.version = 3; @@ -193,6 +195,7 @@ export const rulerRuleVersionHistoryHandler = () => { uid: 'foo', name: '', }; + draft.grafana_alert.message = 'Updated evaluation interval and routing'; }), produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { draft.grafana_alert.version = 1; diff --git a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx index d5ed0d39b6b..857cdc28769 100644 --- a/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/rule-editor/RuleEditorGrafanaRules.test.tsx @@ -194,4 +194,53 @@ describe('RuleEditor grafana managed rules', () => { ]), }); }); + + it('should not show rule type switch when no data sources have manageAlerts enabled', async () => { + // Setup data source with manageAlerts explicitly disabled + setupDataSources( + mockDataSource( + { + type: 'prometheus', + name: 'Prom-disabled', + uid: 'prometheus-disabled', + isDefault: true, + jsonData: { manageAlerts: false }, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ) + ); + + renderRuleEditor(); + + // Wait for the form to load + await screen.findByRole('textbox', { name: 'name' }); + + // The rule type switch should NOT be visible + expect(screen.queryByText('Rule type')).not.toBeInTheDocument(); + expect(screen.queryByTestId('rule-type-radio-group')).not.toBeInTheDocument(); + }); + + it('should show rule type switch when data sources have manageAlerts enabled', async () => { + // Setup data source with manageAlerts enabled + setupDataSources( + mockDataSource( + { + type: 'prometheus', + name: 'Prom-enabled', + uid: 'prometheus-enabled', + isDefault: true, + jsonData: { manageAlerts: true }, + }, + { alerting: true, module: 'core:plugin/prometheus' } + ) + ); + + renderRuleEditor(); + + // Wait for the form to load + await screen.findByRole('textbox', { name: 'name' }); + + // The rule type section should be visible + expect(await screen.findByText('Rule type')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index 7985791cfa2..1f33e3476ff 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -7,10 +7,11 @@ import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../mockApi'; -import { grantUserPermissions, grantUserRole } from '../mocks'; +import { grantUserPermissions, grantUserRole, mockDataSource } from '../mocks'; import { setGrafanaRuleGroupExportResolver } from '../mocks/server/configure'; import { alertingFactory } from '../mocks/server/db'; import { RulesFilter } from '../search/rulesSearchParser'; +import { setupDataSources } from '../testSetup/datasources'; import RuleListPage, { RuleListActions } from './RuleList.v2'; import { loadDefaultSavedSearch } from './filter/useSavedSearches'; @@ -365,6 +366,51 @@ describe('RuleListActions', () => { expect(ui.exportDrawer.query()).toBeInTheDocument(); }); }); + + describe('Data source options visibility', () => { + it('should not show "New Data source recording rule" option when no data sources have manageAlerts enabled', async () => { + // Set up only data sources with manageAlerts explicitly set to false + // This replaces the default data sources that have manageAlerts defaulting to true + setupDataSources( + mockDataSource({ + name: 'Prometheus-disabled', + uid: 'prometheus-disabled', + type: 'prometheus', + jsonData: { manageAlerts: false }, + }) + ); + + grantUserPermissions([AccessControlAction.AlertingRuleExternalWrite]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).not.toBeInTheDocument(); + }); + + it('should show "New Data source recording rule" option when data sources have manageAlerts enabled', async () => { + // Set up data source with manageAlerts enabled + setupDataSources( + mockDataSource({ + name: 'Prometheus-enabled', + uid: 'prometheus-enabled', + type: 'prometheus', + jsonData: { manageAlerts: true }, + }) + ); + + grantUserPermissions([AccessControlAction.AlertingRuleExternalWrite]); + + const { user } = render(); + + await user.click(ui.moreButton.get()); + const menu = await ui.moreMenu.find(); + + expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument(); + }); + }); }); describe('RuleListPage v2 - View switching', () => { diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 284bd6ad757..b90663200ce 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -13,6 +13,7 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { useRulesFilter } from '../hooks/useFilteredRules'; +import { getRulesDataSources } from '../utils/datasource'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; @@ -41,8 +42,11 @@ export function RuleListActions() { const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule); const [exportRulesSupported, exportRulesAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); + // Check if there are any data sources with manageAlerts enabled + const hasAlertEnabledDataSources = useMemo(() => getRulesDataSources().length > 0, []); + const canCreateGrafanaRules = createGrafanaRuleSupported && createGrafanaRuleAllowed; - const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; + const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed && hasAlertEnabledDataSources; const canExportRules = exportRulesSupported && exportRulesAllowed; const canCreateRules = canCreateGrafanaRules || canCreateCloudRules; diff --git a/public/app/features/alerting/unified/utils/routeAdapter.ts b/public/app/features/alerting/unified/utils/routeAdapter.ts index c5dda65a3fa..4d9f5c2fd0b 100644 --- a/public/app/features/alerting/unified/utils/routeAdapter.ts +++ b/public/app/features/alerting/unified/utils/routeAdapter.ts @@ -143,8 +143,7 @@ function convertToMatcherOperator(type: LabelMatcher['type']): MatcherOperator { case '!~': return MatcherOperator.notRegex; default: - const exhaustiveCheck: never = type; - throw new Error(`Unknown matcher type: ${exhaustiveCheck}`); + throw new Error(`Unknown matcher type: ${type}`); } } diff --git a/public/app/features/annotations/standardAnnotationSupport.ts b/public/app/features/annotations/standardAnnotationSupport.ts index 90d28715c7f..cd258e5b0ee 100644 --- a/public/app/features/annotations/standardAnnotationSupport.ts +++ b/public/app/features/annotations/standardAnnotationSupport.ts @@ -18,7 +18,7 @@ import { standardTransformers, } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { config } from 'app/core/config'; +import { config } from '@grafana/runtime'; export const standardAnnotationSupport: AnnotationSupport = { /** diff --git a/public/app/features/apiserver/client.test.ts b/public/app/features/apiserver/client.test.ts index 01787b4a18d..02f6463b8f0 100644 --- a/public/app/features/apiserver/client.test.ts +++ b/public/app/features/apiserver/client.test.ts @@ -28,18 +28,17 @@ describe('DatasourceAPIVersions', () => { it('get', async () => { const getMock = jest.fn().mockResolvedValue({ groups: [ - { name: 'testdata.datasource.grafana.app', preferredVersion: { version: 'v1' } }, + { name: 'grafana-testdata-datasource.datasource.grafana.app', preferredVersion: { version: 'v1' } }, { name: 'prometheus.datasource.grafana.app', preferredVersion: { version: 'v2' } }, { name: 'myorg-myplugin.datasource.grafana.app', preferredVersion: { version: 'v3' } }, ], }); getBackendSrv().get = getMock; const apiVersions = new DatasourceAPIVersions(); - expect(await apiVersions.get('testdata')).toBe('v1'); expect(await apiVersions.get('grafana-testdata-datasource')).toBe('v1'); expect(await apiVersions.get('prometheus')).toBe('v2'); expect(await apiVersions.get('graphite')).toBeUndefined(); - expect(await apiVersions.get('myorg-myplugin-datasource')).toBe('v3'); + expect(await apiVersions.get('myorg-myplugin')).toBe('v3'); expect(getMock).toHaveBeenCalledTimes(1); expect(getMock).toHaveBeenCalledWith('/apis'); }); diff --git a/public/app/features/apiserver/client.ts b/public/app/features/apiserver/client.ts index 2907fc9e80b..7966d0fa56b 100644 --- a/public/app/features/apiserver/client.ts +++ b/public/app/features/apiserver/client.ts @@ -162,17 +162,6 @@ export class DatasourceAPIVersions { if (group.name.includes('datasource.grafana.app')) { const id = group.name.split('.')[0]; apiVersions[id] = group.preferredVersion.version; - // workaround for plugins that don't append '-datasource' for the group name - // e.g. org-plugin-datasource uses org-plugin.datasource.grafana.app - if (!id.endsWith('-datasource')) { - if (!id.includes('-')) { - // workaroud for Grafana plugins that don't include the org either - // e.g. testdata uses testdata.datasource.grafana.app - apiVersions[`grafana-${id}-datasource`] = group.preferredVersion.version; - } else { - apiVersions[`${id}-datasource`] = group.preferredVersion.version; - } - } } }); this.apiVersions = apiVersions; diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx index af05c498ccf..413d78c75a4 100644 --- a/public/app/features/auth-config/AuthProvidersListPage.tsx +++ b/public/app/features/auth-config/AuthProvidersListPage.tsx @@ -3,10 +3,9 @@ import { connect, ConnectedProps } from 'react-redux'; import { GrafanaEdition } from '@grafana/data/internal'; import { Trans } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { Grid, TextLink, ToolbarButton } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; -import { config } from 'app/core/config'; import { StoreState } from 'app/types/store'; import { isOpenSourceBuildOrUnlicenced } from '../admin/EnterpriseAuthFeaturesCard'; diff --git a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx index 8109de32145..f0c2d4d3a13 100644 --- a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx @@ -4,6 +4,7 @@ import { useAsyncRetry } from 'react-use'; import { GrafanaTheme2, store } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; import { evaluateBooleanFlag } from '@grafana/runtime/internal'; import { Button, CollapsableSection, Spinner, Stack, Text, useStyles2, Grid } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; @@ -35,11 +36,19 @@ export function RecentlyViewedDashboards() { const { foldersByUid } = useDashboardLocationInfo(recentDashboards.length > 0); const handleClearHistory = () => { + reportInteraction('grafana_recently_viewed_dashboards_clear_history'); store.set(recentDashboardsKey, JSON.stringify([])); retry(); }; - if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) { + const handleSectionToggle = () => { + reportInteraction('grafana_recently_viewed_dashboards_toggle_section', { + expanded: !isOpen, + }); + setIsOpen(!isOpen); + }; + + if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) { return null; } @@ -48,7 +57,7 @@ export function RecentlyViewedDashboards() { headerDataTestId="browseDashboardsRecentlyViewedTitle" label={ - setIsOpen(!isOpen)}> + Recently viewed @@ -136,6 +147,7 @@ export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose editPreview={editPreview} data={data} searchQuery={searchQuery} + isNewPanel={isNewPanel} /> )} {listMode === VisualizationSelectPaneTab.Visualizations && ( diff --git a/public/app/features/dashboard-scene/panel-edit/interaction.ts b/public/app/features/dashboard-scene/panel-edit/interaction.ts index 4ba515b5223..da147a60807 100644 --- a/public/app/features/dashboard-scene/panel-edit/interaction.ts +++ b/public/app/features/dashboard-scene/panel-edit/interaction.ts @@ -4,4 +4,5 @@ export const INTERACTION_ITEM = { SELECT_PANEL_PLUGIN: 'select_panel_plugin', CHANGE_TAB: 'change_tab', // for ref - PanelVizTypePicker SEARCH: 'search', // for ref - PanelVizTypePicker + BACK_BUTTON: 'back_button', // for ref - PanelVizTypePicker }; diff --git a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts index fc7039bb075..7d11d1518be 100644 --- a/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts +++ b/public/app/features/dashboard-scene/scene/AlertStatesDataLayer.ts @@ -9,8 +9,8 @@ import { sceneGraph, SceneTimeRangeLike, } from '@grafana/scenes'; -import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; +import { notifyApp } from 'app/core/reducers/appNotification'; import { contextSrv } from 'app/core/services/context_srv'; import { getMessageFromError } from 'app/core/utils/errors'; import { alertRuleApi } from 'app/features/alerting/unified/api/alertRuleApi'; diff --git a/public/app/features/dashboard-scene/scene/VariableControls.test.tsx b/public/app/features/dashboard-scene/scene/VariableControls.test.tsx new file mode 100644 index 00000000000..c65639edfa6 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/VariableControls.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; + +import { VariableHide } from '@grafana/data'; +import { SceneGridLayout, SceneVariable, SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes'; + +import { DashboardScene } from './DashboardScene'; +import { VariableControls } from './VariableControls'; +import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; + +jest.mock('@grafana/runtime', () => { + const runtime = jest.requireActual('@grafana/runtime'); + return { + ...runtime, + config: { + ...runtime.config, + featureToggles: { + dashboardNewLayouts: true, + }, + }, + }; +}); + +describe('VariableControls', () => { + it('should not render scopes variable', () => { + const variables = [new ScopesVariable({})]; + const dashboard = buildScene(variables); + dashboard.activate(); + + render(); + + expect(screen.queryByText('__scopes')).not.toBeInTheDocument(); + }); + + it('should not render regular hidden variables', () => { + const hiddenVariable = new TextBoxVariable({ + name: 'HiddenVar', + hide: VariableHide.hideVariable, + }); + const variables = [hiddenVariable]; + const dashboard = buildScene(variables); + dashboard.activate(); + + render(); + + expect(screen.queryByText('HiddenVar')).not.toBeInTheDocument(); + }); + + it('should render regular hidden variables in edit mode', async () => { + const hiddenVariable = new TextBoxVariable({ + name: 'HiddenVar', + hide: VariableHide.hideVariable, + }); + const variables = [hiddenVariable]; + const dashboard = buildScene(variables); + dashboard.activate(); + + dashboard.setState({ isEditing: true }); + render(); + + expect(await screen.findByText('HiddenVar')).toBeInTheDocument(); + }); + + it('should not render variables hidden in controls menu in edit mode', async () => { + const dashboard = buildScene([new TextBoxVariable({ name: 'TextVarControls', hide: VariableHide.inControlsMenu })]); + dashboard.activate(); + + dashboard.setState({ isEditing: true }); + render(); + + expect(screen.queryByText('TextVarControls')).not.toBeInTheDocument(); + }); +}); + +function buildScene(variables: SceneVariable[] = []) { + const dashboard = new DashboardScene({ + $variables: new SceneVariableSet({ variables }), + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [], + }), + }), + }); + return dashboard; +} diff --git a/public/app/features/dashboard-scene/scene/VariableControls.tsx b/public/app/features/dashboard-scene/scene/VariableControls.tsx index 73dd2614472..4cd92d34614 100644 --- a/public/app/features/dashboard-scene/scene/VariableControls.tsx +++ b/public/app/features/dashboard-scene/scene/VariableControls.tsx @@ -19,6 +19,8 @@ import { AddVariableButton } from './VariableControlsAddButton'; export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { const { variables } = sceneGraph.getVariables(dashboard)!.useState(); + const { isEditing } = dashboard.useState(); + const isEditingNewLayouts = isEditing && config.featureToggles.dashboardNewLayouts; // Get visible variables for drilldown layout const visibleVariables = variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu); @@ -35,13 +37,23 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { // Variables to render (exclude adhoc/groupby when drilldown controls are shown in top row) const variablesToRender = hasDrilldownControls ? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu) - : variables.filter((v) => v.state.hide !== VariableHide.inControlsMenu); + : variables.filter( + (v) => + // used for scopes variables, should always be hidden + // if we're editing in dynamic dashboards, still shows hidden variable but greyed out + (!v.UNSAFE_renderAsHidden && isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) || + v.state.hide !== VariableHide.inControlsMenu + ); return ( <> {variablesToRender.length > 0 && variablesToRender.map((variable) => ( - + ))} {config.featureToggles.dashboardNewLayouts ? : null} @@ -52,14 +64,17 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) { interface VariableSelectProps { variable: SceneVariable; inMenu?: boolean; + isEditingNewLayouts?: boolean; } -export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectProps) { +export function VariableValueSelectWrapper({ variable, inMenu, isEditingNewLayouts }: VariableSelectProps) { const state = useSceneObjectState(variable, { shouldActivateOrKeepAlive: true }); const { isSelected, onSelect, isSelectable } = useElementSelection(variable.state.key); + const isHidden = state.hide === VariableHide.hideVariable; + const shouldShowHiddenVariables = isEditingNewLayouts && isHidden; const styles = useStyles2(getStyles); - if (state.hide === VariableHide.hideVariable) { + if (isHidden && !isEditingNewLayouts) { if (variable.UNSAFE_renderAsHidden) { return ; } @@ -97,6 +112,7 @@ export function VariableValueSelectWrapper({ variable, inMenu }: VariableSelectP
({ display: 'flex', alignItems: 'center', }), + hidden: css({ + opacity: 0.6, + '&:hover': css({ + opacity: 1, + }), + label: css({ + textDecoration: 'line-through', + }), + }), }); diff --git a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx index f3a98f2e9b0..399cd8f13f6 100644 --- a/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx +++ b/public/app/features/dashboard-scene/scene/dashboard-controls-menu/DashboardControlsMenuButton.tsx @@ -1,6 +1,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { t } from '@grafana/i18n'; import { Dropdown, ToolbarButton, useStyles2 } from '@grafana/ui'; @@ -33,6 +34,7 @@ export function DashboardControlsButton({ dashboard }: { dashboard: DashboardSce implements this.setState({ repeatedPanels, repeatedConditionalRendering }); this._prevRepeatValues = values; + this.publishEvent(new RepeatsUpdatedEvent(this), true); } public getPanelCount() { diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index 2a2c741c425..8eed7b8fdf1 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -17,7 +17,7 @@ import { import { GRID_COLUMN_COUNT } from 'app/core/constants'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; -import { DashboardStateChangedEvent } from '../../edit-pane/shared'; +import { DashboardStateChangedEvent, RepeatsUpdatedEvent } from '../../edit-pane/shared'; import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; @@ -219,6 +219,7 @@ export class DashboardGridItem } this._prevRepeatValues = values; + this.publishEvent(new RepeatsUpdatedEvent(this), true); } public handleVariableName() { diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx index d363a95ddf0..a8c2cbb6ec0 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.test.tsx @@ -7,10 +7,13 @@ import { SceneGridLayout, VizPanel, SceneVariableSet } from '@grafana/scenes'; import { activateFullSceneTree } from '../../utils/test-utils'; import { DashboardScene } from '../DashboardScene'; +import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowItem } from '../layout-rows/RowItem'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { TabItem } from '../layout-tabs/TabItem'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; import { LayoutParent } from '../types/LayoutParent'; import { DashboardLayoutSelector } from './DashboardLayoutSelector'; @@ -40,6 +43,27 @@ describe('DashboardLayoutSelector', () => { await user.click(confirmButton); expect(switchLayoutMock).toHaveBeenCalled(); }); + + it('should disable tabs option when a row contains tabs layout and show correct message', async () => { + const scene = buildTestSceneWithNestedTabs(); + const layoutManager = scene.state.body; + + render(); + + const tabsOption = screen.getByLabelText('layout-selection-option-Tabs'); + expect(tabsOption).toBeDisabled(); + expect(screen.getByTitle('Cannot change to tabs because a row already contains tabs')).toBeInTheDocument(); + }); + + it('should not disable tabs option when rows do not contain tabs', async () => { + const scene = buildTestScene(); + const layoutManager = scene.state.body; + + render(); + + const tabsOption = screen.getByLabelText('layout-selection-option-Tabs'); + expect(tabsOption).not.toBeDisabled(); + }); }); const buildTestScene = () => { @@ -70,3 +94,43 @@ const buildTestScene = () => { activateFullSceneTree(scene); return scene; }; + +const buildTestSceneWithNestedTabs = () => { + const scene = new DashboardScene({ + title: 'testScene', + editable: true, + $variables: new SceneVariableSet({ + variables: [], + }), + body: new RowsLayoutManager({ + rows: [ + new RowItem({ + title: 'Row 1', + layout: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [ + new DashboardGridItem({ + body: new VizPanel({ key: 'panel-1', pluginId: 'text' }), + }), + ], + }), + }), + }), + new RowItem({ + title: 'Row with Tabs', + layout: new TabsLayoutManager({ + tabs: [ + new TabItem({ + title: 'Tab 1', + layout: AutoGridLayoutManager.createEmpty(), + }), + ], + }), + }), + ], + }), + }); + + activateFullSceneTree(scene); + return scene; +}; diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx index b32555b32f8..ee902d195ad 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx @@ -11,6 +11,7 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { isLayoutParent } from '../types/LayoutParent'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; +import { containsTabsLayout } from './findAllGridTypes'; import { layoutRegistry } from './layoutRegistry'; export interface Props { @@ -22,19 +23,26 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout); const [newLayout, setNewLayout] = useState(); - const disableTabs = useMemo(() => { + const disableTabsReason = useMemo(() => { if (config.featureToggles.unlimitedLayoutsNesting) { - return false; + return undefined; } + + // Check parent hierarchy let parent = layoutManager.parent; while (parent) { if (parent instanceof TabsLayoutManager) { - return true; + return 'parent'; } parent = parent.parent; } - return false; + // Check child hierarchy + if (containsTabsLayout(layoutManager)) { + return 'child'; + } + + return undefined; }, [layoutManager]); const onChangeLayout = useCallback((newLayout: LayoutRegistryItem) => setNewLayout(newLayout), []); @@ -59,8 +67,15 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { const radioOptions = options.map((opt) => { let description = opt.description; - if (disableTabs && opt.id === TabsLayoutManager.descriptor.id) { - description = t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs'); + if (disableTabsReason && opt.id === TabsLayoutManager.descriptor.id) { + if (disableTabsReason === 'parent') { + description = t('dashboard.canvas-actions.disabled-nested-tabs', 'Tabs cannot be nested inside other tabs'); + } else { + description = t( + 'dashboard.canvas-actions.disabled-child-contains-tabs', + 'Cannot change to tabs because a row already contains tabs' + ); + } disabledOptions.push(opt); } diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts new file mode 100644 index 00000000000..b2c925fb26e --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.test.ts @@ -0,0 +1,93 @@ +import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager'; +import { RowItem } from '../layout-rows/RowItem'; +import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; +import { TabItem } from '../layout-tabs/TabItem'; +import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager'; + +import { containsTabsLayout, findAllGridTypes } from './findAllGridTypes'; + +describe('findAllGridTypes', () => { + it('should return grid type for a grid layout', () => { + const layout = AutoGridLayoutManager.createEmpty(); + expect(findAllGridTypes(layout)).toEqual([AutoGridLayoutManager.descriptor.id]); + }); + + it('should return grid types from tabs', () => { + const layout = new TabsLayoutManager({ + tabs: [ + new TabItem({ layout: AutoGridLayoutManager.createEmpty() }), + new TabItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(findAllGridTypes(layout)).toEqual([ + AutoGridLayoutManager.descriptor.id, + AutoGridLayoutManager.descriptor.id, + ]); + }); + + it('should return grid types from rows', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(findAllGridTypes(layout)).toEqual([ + AutoGridLayoutManager.descriptor.id, + AutoGridLayoutManager.descriptor.id, + ]); + }); +}); + +describe('containsTabsLayout', () => { + it('should return true when layout is TabsLayoutManager', () => { + const layout = new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); + + it('should return false when layout is a grid layout', () => { + const layout = AutoGridLayoutManager.createEmpty(); + expect(containsTabsLayout(layout)).toBe(false); + }); + + it('should return false when layout is RowsLayoutManager with no tabs in rows', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(containsTabsLayout(layout)).toBe(false); + }); + + it('should return true when RowsLayoutManager contains a row with tabs layout', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ + layout: new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }), + }), + ], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); + + it('should return true when any row contains tabs layout', () => { + const layout = new RowsLayoutManager({ + rows: [ + new RowItem({ + layout: new TabsLayoutManager({ + tabs: [new TabItem({ layout: AutoGridLayoutManager.createEmpty() })], + }), + }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + new RowItem({ layout: AutoGridLayoutManager.createEmpty() }), + ], + }); + expect(containsTabsLayout(layout)).toBe(true); + }); +}); diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts index 03dec1482fb..6050e25a94f 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/findAllGridTypes.ts @@ -15,3 +15,15 @@ export function findAllGridTypes(layout: DashboardLayoutManager): string[] { return []; } + +export function containsTabsLayout(layout: DashboardLayoutManager): boolean { + if (layout instanceof TabsLayoutManager) { + return true; + } + + if (layout instanceof RowsLayoutManager) { + return layout.state.rows.some((row) => containsTabsLayout(row.getLayout())); + } + + return false; +} diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx index 8bce6364a34..a31927a3e92 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx @@ -128,7 +128,7 @@ describe('PanelTimeRange', () => { expect(panelTime.state.value.to.format('Z')).toBe('+00:00'); // UTC }); - it('should handle invalid time reference in timeShift', () => { + it('should handle invalid time reference in timeShift with relative time range', () => { const panelTime = new PanelTimeRange({ timeShift: 'now-1d' }); buildAndActivateSceneFor(panelTime); @@ -139,6 +139,22 @@ describe('PanelTimeRange', () => { expect(panelTime.state.to).toBe('now'); }); + it('should handle invalid time reference in timeShift with absolute time range', () => { + const panelTime = new PanelTimeRange({ timeShift: 'now-1d' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const absoluteFrom = '2019-02-11T10:00:00.000Z'; + const absoluteTo = '2019-02-11T16:00:00.000Z'; + const scene = new SceneFlexLayout({ + $timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }), + children: [new SceneFlexItem({ body: panel })], + }); + activateFullSceneTree(scene); + + expect(panelTime.state.timeInfo).toBe('invalid timeshift'); + expect(panelTime.state.from).toBe(absoluteFrom); + expect(panelTime.state.to).toBe(absoluteTo); + }); + it('should handle invalid time reference in timeShift combined with timeFrom', () => { const panelTime = new PanelTimeRange({ timeFrom: 'now-2h', @@ -153,6 +169,66 @@ describe('PanelTimeRange', () => { expect(panelTime.state.to).toBe('now'); }); + describe('from/to state format for liveNow compatibility', () => { + it('should store relative strings in from/to when timeShift is applied to relative time range', () => { + const panelTime = new PanelTimeRange({ timeShift: '2h' }); + + buildAndActivateSceneFor(panelTime); + + expect(panelTime.state.from).toBe('now-6h-2h'); + expect(panelTime.state.to).toBe('now-2h'); + expect(panelTime.state.value.raw.from).toBe('now-6h-2h'); + expect(panelTime.state.value.raw.to).toBe('now-2h'); + }); + + it('should store relative strings when both timeFrom and timeShift are applied', () => { + const panelTime = new PanelTimeRange({ timeFrom: '2h', timeShift: '1h' }); + + buildAndActivateSceneFor(panelTime); + + expect(panelTime.state.from).toBe('now-2h-1h'); + expect(panelTime.state.to).toBe('now-1h'); + }); + + it('should store ISO strings when timeShift is applied to absolute time range', () => { + const panelTime = new PanelTimeRange({ timeShift: '1h' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const absoluteFrom = '2019-02-11T10:00:00.000Z'; + const absoluteTo = '2019-02-11T16:00:00.000Z'; + const scene = new SceneFlexLayout({ + $timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }), + children: [new SceneFlexItem({ body: panel })], + }); + activateFullSceneTree(scene); + + expect(panelTime.state.from).toBe('2019-02-11T09:00:00.000Z'); + expect(panelTime.state.to).toBe('2019-02-11T15:00:00.000Z'); + }); + + it('should update from/to when ancestor time range changes', () => { + const panelTime = new PanelTimeRange({ timeShift: '1h' }); + const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' }); + const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime }); + const scene = new SceneFlexLayout({ + $timeRange: sceneTimeRange, + children: [new SceneFlexItem({ body: panel })], + }); + activateFullSceneTree(scene); + + expect(panelTime.state.from).toBe('now-6h-1h'); + expect(panelTime.state.to).toBe('now-1h'); + + sceneTimeRange.onTimeRangeChange({ + from: dateTime('2019-02-11T12:00:00.000Z'), + to: dateTime('2019-02-11T18:00:00.000Z'), + raw: { from: 'now-12h', to: 'now' }, + }); + + expect(panelTime.state.from).toBe('now-12h-1h'); + expect(panelTime.state.to).toBe('now-1h'); + }); + }); + describe('onTimeRangeChange', () => { it('should reverse timeShift when updating time range', () => { const oneHourShift = '1h'; diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx index 5f8e2e3350e..7fe4133a7fa 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx @@ -70,8 +70,8 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase { type: 'textbox', name: 'search', }, + { + name: 'custom_csv', + type: 'custom', + valuesFormat: 'csv', + }, + { + name: 'custom_json', + type: 'custom', + valuesFormat: 'json', + }, ], }, }); @@ -359,6 +369,9 @@ describe('DashboardSceneSerializer', () => { panel_type_row_count: 1, variable_type_query_count: 2, variable_type_textbox_count: 1, + variable_type_custom_count: 2, + variable_type_custom_csv_count: 1, + variable_type_custom_json_count: 1, settings_nowdelay: undefined, settings_livenow: true, varsWithDataSource: [ @@ -701,7 +714,9 @@ describe('DashboardSceneSerializer', () => { panel_type_timeseries_count: 6, variable_type_adhoc_count: 1, variable_type_datasource_count: 1, - variable_type_custom_count: 1, + variable_type_custom_count: 3, + variable_type_custom_csv_count: 2, + variable_type_custom_json_count: 1, variable_type_query_count: 1, varsWithDataSource: [ { type: 'query', datasource: 'cloudwatch' }, @@ -714,7 +729,7 @@ describe('DashboardSceneSerializer', () => { panelCount: 6, rowCount: 6, tabCount: 4, - templateVariableCount: 4, + templateVariableCount: 6, maxNestingLevel: 3, dashStructure: '[{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"},{"kind":"panel"},{"kind":"panel"}]},{"kind":"tab","children":[]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"panel"}]}]}]},{"kind":"row","children":[{"kind":"row","children":[{"kind":"tab","children":[{"kind":"panel"}]},{"kind":"tab","children":[{"kind":"panel"}]}]}]}]', @@ -866,6 +881,7 @@ describe('DashboardSceneSerializer', () => { query: 'app1', skipUrlSync: false, allowCustomValue: true, + valuesFormat: 'csv', }, }, ]); diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index 512ec28dd77..f0a1ef59584 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -294,6 +294,7 @@ exports[`Given a scene with custom quick ranges should save quick ranges to save "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -680,6 +681,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "options": [], "query": "A,B,C,D,E,F,E,G,H,I,J,K,L", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -698,6 +700,7 @@ exports[`transformSceneToSaveModel Given a scene with rows Should transform back "options": [], "query": "Bob : 1, Rob : 2,Sod : 3, Hod : 4, Cod : 5", "type": "custom", + "valuesFormat": "csv", }, ], }, @@ -1021,6 +1024,7 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { @@ -1381,6 +1385,7 @@ exports[`transformSceneToSaveModel Given a simple scene with variables Should tr "options": [], "query": "a, b, c", "type": "custom", + "valuesFormat": "csv", }, { "current": { diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap index b0133a8eb92..23f11bf3f71 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModelSchemaV2.test.ts.snap @@ -196,6 +196,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model "options": [], "query": "option1, option2", "skipUrlSync": false, + "valuesFormat": "csv", }, }, { diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts index ad644f92294..3902567b76d 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.test.ts @@ -1,6 +1,13 @@ -import { defaultDataQueryKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { + defaultDataQueryKind, + defaultPanelSpec, + PanelKind, + PanelQueryKind, +} from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; +import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; -import { ensureUniqueRefIds, getRuntimePanelDataSource } from './utils'; +import { ensureUniqueRefIds, getPanelDataSource, getRuntimePanelDataSource } from './utils'; describe('getRuntimePanelDataSource', () => { it('should return uid and type when explicit datasource UID is provided', () => { @@ -141,6 +148,159 @@ describe('getRuntimePanelDataSource', () => { }); }); +describe('getPanelDataSource', () => { + const createPanelWithQueries = (queries: PanelQueryKind[]): PanelKind => ({ + kind: 'Panel', + spec: { + ...defaultPanelSpec(), + id: 1, + title: 'Test Panel', + data: { + kind: 'QueryGroup', + spec: { + queries, + queryOptions: {}, + transformations: [], + }, + }, + }, + }); + + const createQuery = (datasourceName: string, group: string, refId = 'A'): PanelQueryKind => ({ + kind: 'PanelQuery', + spec: { + refId, + hidden: false, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group, + datasource: { + name: datasourceName, + }, + spec: {}, + }, + }, + }); + + const createQueryWithoutDatasourceName = (group: string, refId = 'A'): PanelQueryKind => ({ + kind: 'PanelQuery', + spec: { + refId, + hidden: false, + query: { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group, + spec: {}, + }, + }, + }); + + it('should return undefined when panel has no queries', () => { + const panel = createPanelWithQueries([]); + + const result = getPanelDataSource(panel); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for a single query with specific datasource (not mixed)', () => { + const panel = createPanelWithQueries([createQuery('prometheus-uid', 'prometheus')]); + + const result = getPanelDataSource(panel); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for multiple queries with the same datasource', () => { + const panel = createPanelWithQueries([ + createQuery('prometheus-uid', 'prometheus', 'A'), + createQuery('prometheus-uid', 'prometheus', 'B'), + createQuery('prometheus-uid', 'prometheus', 'C'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toBeUndefined(); + }); + + it('should return mixed datasource when queries use different datasource UIDs', () => { + const panel = createPanelWithQueries([ + createQuery('prometheus-uid', 'prometheus', 'A'), + createQuery('loki-uid', 'loki', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); + + it('should return mixed datasource when queries use different datasource types', () => { + const panel = createPanelWithQueries([ + createQuery('ds-uid', 'prometheus', 'A'), + createQuery('ds-uid', 'loki', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); + + it('should return mixed datasource when multiple queries use Dashboard datasource', () => { + const panel = createPanelWithQueries([ + createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'), + createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'B'), + createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'C'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); + + it('should return Dashboard datasource when single query uses Dashboard datasource', () => { + const panel = createPanelWithQueries([createQuery(SHARED_DASHBOARD_QUERY, 'datasource')]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'datasource', uid: SHARED_DASHBOARD_QUERY }); + }); + + it('should return mixed when Dashboard datasource is mixed with other datasources', () => { + const panel = createPanelWithQueries([ + createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'), + createQuery('prometheus-uid', 'prometheus', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); + + it('should return undefined when queries have no explicit datasource name but same type', () => { + const panel = createPanelWithQueries([ + createQueryWithoutDatasourceName('prometheus', 'A'), + createQueryWithoutDatasourceName('prometheus', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toBeUndefined(); + }); + + it('should return mixed when queries have no explicit datasource name but different types', () => { + const panel = createPanelWithQueries([ + createQueryWithoutDatasourceName('prometheus', 'A'), + createQueryWithoutDatasourceName('loki', 'B'), + ]); + + const result = getPanelDataSource(panel); + + expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME }); + }); +}); + describe('ensureUniqueRefIds', () => { const createQuery = (refId: string): PanelQueryKind => ({ kind: 'PanelQuery', diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts index 2256206aaee..ee63a5d7f52 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/utils.ts @@ -23,6 +23,7 @@ import { DataQueryKind, defaultPanelQueryKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants'; import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource'; import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; @@ -228,29 +229,45 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider * This ensures v2→Scene→v1 conversion produces the same output as the Go backend, * which does NOT add panel-level datasource for non-mixed panels. */ -function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { - if (!panel.spec.data?.spec.queries?.length) { +export function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined { + const queries = panel.spec.data?.spec.queries; + if (!queries?.length) { return undefined; } - let firstDatasource: DataSourceRef | undefined = undefined; - let isMixedDatasource = false; + // Check if multiple queries use Dashboard datasource - this needs mixed mode + const dashboardDsQueryCount = queries.filter((q) => q.spec.query.datasource?.name === SHARED_DASHBOARD_QUERY).length; + if (dashboardDsQueryCount > 1) { + return { type: 'mixed', uid: MIXED_DATASOURCE_NAME }; + } - panel.spec.data.spec.queries.forEach((query) => { - const queryDs = query.spec.query.datasource?.name + // Get all datasources from queries + const datasources = queries.map((query) => + query.spec.query.datasource?.name ? { uid: query.spec.query.datasource.name, type: query.spec.query.group } - : getRuntimePanelDataSource(query.spec.query); + : getRuntimePanelDataSource(query.spec.query) + ); - if (!firstDatasource) { - firstDatasource = queryDs; - } else if (firstDatasource.uid !== queryDs?.uid || firstDatasource.type !== queryDs?.type) { - isMixedDatasource = true; - } - }); + const firstDatasource = datasources[0]; + + // Check if queries use different datasources + const isMixedDatasource = datasources.some( + (ds) => ds?.uid !== firstDatasource?.uid || ds?.type !== firstDatasource?.type + ); + + if (isMixedDatasource) { + return { type: 'mixed', uid: MIXED_DATASOURCE_NAME }; + } + + // Handle case where all queries use Dashboard datasource - needs to set datasource for proper data fetching + // See DashboardDatasourceBehaviour.tsx for more details + if (firstDatasource?.uid === SHARED_DASHBOARD_QUERY) { + return { type: 'datasource', uid: SHARED_DASHBOARD_QUERY }; + } // Only return mixed datasource - for non-mixed panels, each query already has its own datasource // This matches the Go backend behavior which doesn't add panel.datasource for non-mixed panels - return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined; + return undefined; } /** diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts index 33f06a7accc..d60d0b85b0e 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.test.ts @@ -376,6 +376,7 @@ describe('sceneVariablesSetToVariables', () => { "options": [], "query": "test,test1,test2", "type": "custom", + "valuesFormat": "csv", } `); }); @@ -1180,6 +1181,7 @@ describe('sceneVariablesSetToVariables', () => { "options": [], "query": "test,test1,test2", "skipUrlSync": false, + "valuesFormat": "csv", }, } `); diff --git a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts index fe7b56e22b5..cc83c131fed 100644 --- a/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts +++ b/public/app/features/dashboard-scene/serialization/sceneVariablesSetToVariables.ts @@ -120,6 +120,9 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio allValue: variable.state.allValue, includeAll: variable.state.includeAll, ...(variable.state.allowCustomValue !== undefined && { allowCustomValue: variable.state.allowCustomValue }), + // Ensure we persist the backend default when not specified to stay aligned with + // transformSaveModelSchemaV2ToScene which injects 'csv' on load. + valuesFormat: variable.state.valuesFormat ?? 'csv', }; variables.push(customVariable); } else if (sceneUtils.isDataSourceVariable(variable)) { @@ -408,6 +411,7 @@ export function sceneVariablesSetToSchemaV2Variables( allValue: variable.state.allValue, includeAll: variable.state.includeAll ?? false, allowCustomValue: variable.state.allowCustomValue ?? true, + valuesFormat: variable.state.valuesFormat ?? 'csv', }, }; variables.push(customVariable); diff --git a/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json b/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json index 8a39daf6155..7cb0537b49e 100644 --- a/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json +++ b/public/app/features/dashboard-scene/serialization/testfiles/nested_dashboard.json @@ -1169,6 +1169,57 @@ "skipUrlSync": false } }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "test", + "value": "test" + }, + "hide": "dontHide", + "includeAll": false, + "multi": false, + "name": "custom0", + "options": [ + { + "selected": true, + "text": "test", + "value": "test" + } + ], + "valuesFormat": "csv", + "query": "test", + "skipUrlSync": false + } + }, + { + "kind": "CustomVariable", + "spec": { + "allowCustomValue": true, + "current": { + "text": "test", + "value": "test" + }, + "hide": "dontHide", + "includeAll": false, + "multi": false, + "name": "custom0", + "options": [ + { + "selected": true, + "text": "test", + "value": "test", + "properties": { + "testProp": "test" + } + } + ], + "valuesFormat": "json", + "query": "test", + "skipUrlSync": false + } + }, { "kind": "DatasourceVariable", "spec": { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts index f343cbce00d..be2aa2f98f3 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.ts @@ -343,12 +343,12 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S } return new AdHocFiltersVariable(adhocVariableState); } + if (variable.kind === defaultCustomVariableKind().kind) { return new CustomVariable({ ...commonProperties, value: variable.spec.current?.value ?? '', text: variable.spec.current?.text ?? '', - query: variable.spec.query, isMulti: variable.spec.multi, allValue: variable.spec.allValue || undefined, @@ -357,6 +357,7 @@ function createSceneVariableFromVariableModel(variable: TypedVariableModelV2): S skipUrlSync: variable.spec.skipUrlSync, hide: transformVariableHideToEnumV1(variable.spec.hide), ...(variable.spec.allowCustomValue !== undefined && { allowCustomValue: variable.spec.allowCustomValue }), + valuesFormat: variable.spec.valuesFormat || 'csv', }); } else if (variable.kind === defaultQueryVariableKind().kind) { return new QueryVariable({ diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index 0bcea1e0ecd..bfb5095f3cf 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -27,6 +27,7 @@ import { createPanelSaveModel } from 'app/features/dashboard/state/__fixtures__/ import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants'; import { DashboardDataDTO } from 'app/types/dashboard'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; @@ -822,10 +823,14 @@ describe('transformSaveModelToScene', () => { }); it('Should convert legacy rows to new rows', () => { - const scene = transformSaveModelToScene({ - dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO, - meta: {}, - }); + const scene = transformSaveModelToScene( + { + dashboard: repeatingRowsAndPanelsDashboardJson as DashboardDataDTO, + meta: {}, + }, + undefined, + getSceneCreationOptions() + ); const layout = scene.state.body as RowsLayoutManager; const row1 = layout.state.rows[0]; @@ -857,10 +862,14 @@ describe('transformSaveModelToScene', () => { }); it('Should convert legacy rows to new rows with free panels before first row', () => { - const scene = transformSaveModelToScene({ - dashboard: rowsAfterFreePanels as DashboardDataDTO, - meta: {}, - }); + const scene = transformSaveModelToScene( + { + dashboard: rowsAfterFreePanels as DashboardDataDTO, + meta: {}, + }, + undefined, + getSceneCreationOptions() + ); const layout = scene.state.body as RowsLayoutManager; const row1 = layout.state.rows[0]; diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 3da62d6cdf4..8f80dcc526e 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -29,11 +29,11 @@ import { } from 'app/features/dashboard/services/DashboardProfiler'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { DashboardDTO, DashboardDataDTO, DashboardRoutes } from 'app/types/dashboard'; +import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard'; import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior'; import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior'; -import { LoadDashboardOptions, shouldForceV2API } from '../pages/DashboardScenePageStateManager'; +import { LoadDashboardOptions } from '../pages/DashboardScenePageStateManager'; import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; @@ -76,11 +76,53 @@ export interface SaveModelToSceneOptions { isEmbedded?: boolean; } -export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashboardOptions): DashboardScene { +type LayoutCreator = (panels: PanelModel[], preload?: boolean) => DashboardLayoutManager; + +export interface SceneCreationOptions { + /** + * When provided, this function is used to create the dashboard body/layout instead of the default v1 behavior. + * This allows callers to inject v2 layout strategy. + */ + createLayout?: LayoutCreator; + /** + * Determines how the dashboard scene is serialized. + * @default 'v1' + */ + targetVersion?: 'v1' | 'v2'; +} + +// Rows as SceneGridRow within the grid. +const createDefaultGridLayout: LayoutCreator = (panels, preload) => { + return new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + isLazy: getIsLazy(preload), + children: createSceneObjectsForPanels(panels), + }), + }); +}; + +/** + * V2 layout creator - uses RowsLayoutManager when dashboard has rows. + * This creates a layout that can be properly serialized to v2 format. + */ +export const createV2RowsLayout: LayoutCreator = (panels, preload) => { + const hasRows = panels.some((p) => p.type === 'row'); + if (hasRows) { + return createRowsFromPanels(panels); + } + // Fall back to default grid layout when no rows + return createDefaultGridLayout(panels, preload); +}; + +export function transformSaveModelToScene( + rsp: DashboardDTO, + options?: LoadDashboardOptions, + sceneOptions?: SceneCreationOptions +): DashboardScene { // Just to have migrations run const oldModel = new DashboardModel(rsp.dashboard, rsp.meta); - const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options); + const scene = createDashboardSceneFromDashboardModel(oldModel, rsp.dashboard, options, sceneOptions); // TODO: refactor createDashboardSceneFromDashboardModel to work on Dashboard schema model const apiVersion = config.featureToggles.kubernetesDashboards @@ -92,7 +134,7 @@ export function transformSaveModelToScene(rsp: DashboardDTO, options?: LoadDashb return scene; } -export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager { +function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager { const rowItems: RowItem[] = []; let currentLegacyRow: PanelModel | null = null; @@ -143,7 +185,7 @@ export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager }); } -export function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { +function createSceneObjectsForPanels(oldPanels: PanelModel[]): SceneGridItemLike[] { // collects all panels and rows const panels: SceneGridItemLike[] = []; @@ -259,14 +301,14 @@ function createRowItemFromLegacyRow(row: PanelModel, panels: DashboardGridItem[] export function createDashboardSceneFromDashboardModel( oldModel: DashboardModel, dto: DashboardDataDTO, - options?: LoadDashboardOptions + options?: LoadDashboardOptions, + sceneOptions?: SceneCreationOptions ) { let variables: SceneVariableSet | undefined; let annotationLayers: SceneDataLayerProvider[] = []; let alertStatesLayer: AlertStatesDataLayer | undefined; const uid = oldModel.uid; - const isReport = options?.route === DashboardRoutes.Report; - const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot && !isReport ? 'v2' : 'v1'; + const targetVersion = sceneOptions?.targetVersion ?? 'v1'; if (oldModel.meta.isSnapshot) { variables = createVariablesForSnapshot(oldModel); @@ -354,9 +396,11 @@ export function createDashboardSceneFromDashboardModel( let body: DashboardLayoutManager; - if (serializerVersion === 'v2' && oldModel.panels.some((p) => p.type === 'row')) { - body = createRowsFromPanels(oldModel.panels); + if (sceneOptions?.createLayout) { + // Use injected layout creator (allows callers to specify v2 or custom layout strategy) + body = sceneOptions.createLayout(oldModel.panels, dto.preload); } else { + // Default v1 layout: DefaultGridLayoutManager body = new DefaultGridLayoutManager({ grid: new SceneGridLayout({ isLazy: getIsLazy(dto.preload), @@ -404,7 +448,7 @@ export function createDashboardSceneFromDashboardModel( hideTimeControls: oldModel.timepicker.hidden, }), }, - serializerVersion + targetVersion ); // Enable panel profiling for this dashboard using the composed SceneRenderProfiler diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts index 9dbf8e36f0a..a870f4feef5 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV1ToV2.test.ts @@ -1,6 +1,8 @@ import { readdirSync, readFileSync } from 'fs'; import path from 'path'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; + import { normalizeBackendOutputForFrontendComparison } from './serialization-test-utils'; import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; @@ -207,22 +209,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); @@ -279,22 +285,26 @@ describe('V1 to V2 Dashboard Transformation Comparison', () => { delete dashboardSpec.snapshot; // Wrap in DashboardDTO structure that transformSaveModelToScene expects - const scene = transformSaveModelToScene({ - dashboard: dashboardSpec, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboardSpec, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); const frontendOutput = transformSceneToSaveModelSchemaV2(scene, false); diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts index 74208ff8a8f..af6116b9adf 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelV2ToV1.test.ts @@ -6,6 +6,8 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types'; import { DashboardDataDTO } from 'app/types/dashboard'; +import { getSceneCreationOptions } from '../pages/DashboardScenePageStateManager'; + import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene'; import { transformSaveModelToScene } from './transformSaveModelToScene'; import { transformSceneToSaveModel } from './transformSceneToSaveModel'; @@ -228,22 +230,26 @@ function removeMetadata(spec: Dashboard): Partial { * identical processing. */ function loadAndSerializeV1SaveModel(dashboard: Dashboard): Dashboard { - const scene = transformSaveModelToScene({ - dashboard: dashboard as DashboardDataDTO, - meta: { - isNew: false, - isFolder: false, - canSave: true, - canEdit: true, - canDelete: false, - canShare: false, - canStar: false, - canAdmin: false, - isSnapshot: false, - provisioned: false, - version: 1, + const scene = transformSaveModelToScene( + { + dashboard: dashboard as DashboardDataDTO, + meta: { + isNew: false, + isFolder: false, + canSave: true, + canEdit: true, + canDelete: false, + canShare: false, + canStar: false, + canAdmin: false, + isSnapshot: false, + provisioned: false, + version: 1, + }, }, - }); + undefined, + getSceneCreationOptions() + ); return transformSceneToSaveModel(scene, false); } diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx index 61193d003f6..eb78f34a45b 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx @@ -9,7 +9,7 @@ import { Trans, t } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; import { SceneVariable } from '@grafana/scenes'; import { VariableHide, defaultVariableModel } from '@grafana/schema'; -import { Button, LoadingPlaceholder, ConfirmModal, ModalsController, Stack, useStyles2 } from '@grafana/ui'; +import { Button, ConfirmModal, LoadingPlaceholder, ModalsController, Stack, useStyles2 } from '@grafana/ui'; import { VariableDisplaySelect } from 'app/features/dashboard-scene/settings/variables/components/VariableDisplaySelect'; import { VariableLegend } from 'app/features/dashboard-scene/settings/variables/components/VariableLegend'; import { VariableTextAreaField } from 'app/features/dashboard-scene/settings/variables/components/VariableTextAreaField'; @@ -68,6 +68,8 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete const onDisplayChange = (display: VariableHide) => variable.setState({ hide: display }); const isHasVariableOptions = hasVariableOptions(variable); + const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : []; + const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json'; const onDeleteVariable = (hideModal: () => void) => () => { reportInteraction('Delete variable'); @@ -123,7 +125,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete {EditorToRender && } - {isHasVariableOptions && } + {isHasVariableOptions && }
diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx index 924f9fa5702..8b0674b24d7 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.test.tsx @@ -1,9 +1,16 @@ -import { render, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { selectors } from '@grafana/e2e-selectors'; import { CustomVariableForm } from './CustomVariableForm'; +jest.mock('@grafana/runtime', () => { + const actual = jest.requireActual('@grafana/runtime'); + actual.config.featureToggles = { multiPropsVariables: true }; + return actual; +}); + describe('CustomVariableForm', () => { const onQueryChange = jest.fn(); const onMultiChange = jest.fn(); @@ -130,4 +137,71 @@ describe('CustomVariableForm', () => { expect(onMultiChange).not.toHaveBeenCalled(); expect(onIncludeAllChange).not.toHaveBeenCalled(); }); + + describe('JSON values format', () => { + test('should render the form fields correctly', async () => { + const { getByTestId, queryByTestId } = render( + + ); + + await userEvent.click(screen.getByText('JSON')); + + const multiCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ); + const allowCustomValueCheckbox = queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch + ); + const includeAllCheckbox = getByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ); + const allValueInput = queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ); + + expect(multiCheckbox).toBeInTheDocument(); + expect(multiCheckbox).toBeChecked(); + expect(includeAllCheckbox).toBeInTheDocument(); + expect(includeAllCheckbox).toBeChecked(); + + expect(allowCustomValueCheckbox).not.toBeInTheDocument(); + expect(allValueInput).not.toBeInTheDocument(); + }); + + test('should display validation error', async () => { + const validationError = new Error('Ooops! Validation error.'); + + const { findByText } = render( + + ); + + await userEvent.click(screen.getByText('JSON')); + + const errorEl = await findByText(validationError.message); + expect(errorEl).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx index b3c78330156..662cf639b00 100644 --- a/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/components/CustomVariableForm.tsx @@ -1,7 +1,10 @@ import { FormEvent } from 'react'; +import { CustomVariableModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { Alert, FieldValidationMessage, Icon, RadioButtonGroup, Stack, TextLink, Tooltip } from '@grafana/ui'; import { SelectionOptionsForm } from './SelectionOptionsForm'; import { VariableLegend } from './VariableLegend'; @@ -9,10 +12,12 @@ import { VariableTextAreaField } from './VariableTextAreaField'; interface CustomVariableFormProps { query: string; + valuesFormat?: CustomVariableModel['valuesFormat']; multi: boolean; allValue?: string | null; includeAll: boolean; allowCustomValue?: boolean; + queryValidationError?: Error; onQueryChange: (event: FormEvent) => void; onMultiChange: (event: FormEvent) => void; onIncludeAllChange: (event: FormEvent) => void; @@ -20,9 +25,137 @@ interface CustomVariableFormProps { onQueryBlur?: (event: FormEvent) => void; onAllValueBlur?: (event: FormEvent) => void; onAllowCustomValueChange?: (event: FormEvent) => void; + onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void; } export function CustomVariableForm({ + query, + valuesFormat, + multi, + allValue, + includeAll, + allowCustomValue, + queryValidationError, + onQueryChange, + onMultiChange, + onIncludeAllChange, + onAllValueChange, + onAllowCustomValueChange, + onValuesFormatChange, +}: CustomVariableFormProps) { + if (!config.featureToggles.multiPropsVariables) { + return ( + + ); + } + + return ( + <> + + Custom options + + + + + + {queryValidationError && {queryValidationError.message}} + + + Selection options + + + + ); +} + +interface ValuesFormatSelectorProps { + valuesFormat?: CustomVariableModel['valuesFormat']; + onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void; +} + +export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) { + return ( + + + {valuesFormat === 'json' && ( + + Provide a JSON representing an array of objects, where each object can have any number of properties. +
+ Check{' '} + + our docs + {' '} + for more information. + + } + placement="top" + interactive + > + +
+ )} +
+ ); +} + +function CustomVariableFormNonMultiProps({ + displayMultiPropsWarningBanner, query, multi, allValue, @@ -33,13 +166,23 @@ export function CustomVariableForm({ onIncludeAllChange, onAllValueChange, onAllowCustomValueChange, -}: CustomVariableFormProps) { +}: CustomVariableFormProps & { displayMultiPropsWarningBanner: boolean }) { return ( <> Custom options + {displayMultiPropsWarningBanner && ( +
+ {/* eslint-disable-next-line @grafana/i18n/no-untranslated-strings */} + + This feature is temporarily disabled, sorry for any inconvenience. Please recreate these options without + multi-properties. + +
+ )} + ) => void; onAllowCustomValueChange?: (event: ChangeEvent) => void; onIncludeAllChange: (event: ChangeEvent) => void; @@ -20,8 +22,10 @@ interface SelectionOptionsFormProps { export function SelectionOptionsForm({ multi, allowCustomValue, + disableAllowCustomValue, includeAll, allValue, + disableCustomAllValue, onMultiChange, onAllowCustomValueChange, onIncludeAllChange, @@ -39,18 +43,19 @@ export function SelectionOptionsForm({ onChange={onMultiChange} testId={selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch} /> - {onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup - - )} + {!disableAllowCustomValue && + onAllowCustomValueChange && ( // backwards compat with old arch, remove on cleanup + + )} - {includeAll && ( + {!disableCustomAllValue && includeAll && ( { +export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => { + const styles = useStyles2(getStyles); + const hasOptions = options.length > 0; + const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasMultiProps; + + return ( +
+ + + Preview of values ({'{{count}}'}) + + {hasOptions && displayMultiPropsPreview && } + {hasOptions && !displayMultiPropsPreview && } + +
+ ); +}; + +function VariableValuesWithPropsPreview({ options }: { options: VariableValueOption[] }) { + const styles = useStyles2(getStyles); + + const { data, columns } = useMemo(() => { + const data = options.map(({ label, value, properties }) => ({ + label: String(label), + value: String(value), + ...flattenProperties(properties), + })); + + return { + data, + columns: Object.keys(data[0] ?? {}).map((id) => ({ + id, + // see https://github.com/TanStack/table/issues/1671 + header: unsanitizeKey(id), + sortType: 'alphanumeric' as const, + })), + }; + }, [options]); + + return ( + String(r.value)} + pageSize={8} + /> + ); +} + +const sanitizeKey = (key: string) => key.replace(/\./g, '__dot__'); +const unsanitizeKey = (key: string) => key.replace(/__dot__/g, '.'); + +function flattenProperties(properties?: VariableValueOptionProperties, path = ''): Record { + if (properties === undefined) { + return {}; + } + + const result: Record = {}; + + for (const [key, value] of Object.entries(properties)) { + const newPath = path ? `${path}.${key}` : key; + + if (typeof value === 'object') { + Object.assign(result, flattenProperties(value, newPath)); + } else { + // see https://github.com/TanStack/table/issues/1671 + result[sanitizeKey(newPath)] = value; + } + } + + return result; +} + +function VariableValuesWithoutPropsPreview({ options }: { options: VariableValueOption[] }) { + const styles = useStyles2(getStyles); const [previewLimit, setPreviewLimit] = useState(20); const [previewOptions, setPreviewOptions] = useState([]); const showMoreOptions = useCallback( @@ -21,18 +98,10 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) = }, [previewLimit, setPreviewLimit] ); - const styles = useStyles2(getStyles); useEffect(() => setPreviewOptions(options.slice(0, previewLimit)), [previewLimit, options]); - if (!previewOptions.length) { - return null; - } - return ( -
- - Preview of values - + <> {previewOptions.map((o, index) => ( @@ -49,16 +118,17 @@ export const VariableValuesPreview = ({ options }: VariableValuesPreviewProps) = )} -
+ ); -}; -VariableValuesPreview.displayName = 'VariableValuesPreview'; +} +VariableValuesWithoutPropsPreview.displayName = 'VariableValuesWithoutPropsPreview'; function getStyles(theme: GrafanaTheme2) { return { - wrapper: css({ + previewContainer: css({ display: 'flex', flexDirection: 'column', + gap: theme.spacing(1), marginTop: theme.spacing(2), }), optionContainer: css({ @@ -71,5 +141,10 @@ function getStyles(theme: GrafanaTheme2) { textOverflow: 'ellipsis', maxWidth: '50vw', }), + table: css({ + td: css({ + padding: theme.spacing(0.5, 1), + }), + }), }; } diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx index a7315d37a76..1be01c10f56 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.test.tsx @@ -5,117 +5,231 @@ import { CustomVariable } from '@grafana/scenes'; import { CustomVariableEditor } from './CustomVariableEditor'; +jest.mock('@grafana/runtime', () => { + const actual = jest.requireActual('@grafana/runtime'); + actual.config.featureToggles = { multiPropsVariables: true }; + return actual; +}); + +function setup(options: Partial[0]> = {}) { + return { + variable: new CustomVariable({ + name: 'customVar', + ...options, + }), + onRunQuery: jest.fn(), + }; +} + +function renderEditor(ui: React.ReactNode) { + const renderResult = render(ui); + + const elements = { + formatButton: (label: string) => renderResult.queryByLabelText(label) as HTMLElement, + queryInput: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput + ) as HTMLTextAreaElement, + multiValueCheckbox: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch + ) as HTMLInputElement, + allowCustomValueCheckbox: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch + ) as HTMLInputElement, + includeAllCheckbox: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch + ) as HTMLInputElement, + customAllValueInput: () => + renderResult.queryByTestId( + selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput + ) as HTMLInputElement, + }; + + return { + ...renderResult, + elements, + actions: { + updateValuesInput(newQuery: string) { + fireEvent.change(elements.queryInput(), { target: { value: newQuery } }); + fireEvent.blur(elements.queryInput()); + }, + changeValuesFormat(newFormat: 'csv' | 'json') { + const targetLabel = newFormat === 'json' ? 'JSON' : 'CSV'; + + const formatButton = elements.formatButton(targetLabel); + if (formatButton === null) { + throw new Error(`Unable to fire a "click" event - button with label "${targetLabel}" not found in DOM`); + } + + fireEvent.click(formatButton); + }, + }, + }; +} + describe('CustomVariableEditor', () => { - it('should render the CustomVariableForm with correct initial values', () => { - const variable = new CustomVariable({ - name: 'customVar', - query: 'test, test2', - value: 'test', - isMulti: true, - includeAll: true, - allValue: 'test', + describe('CSV values format', () => { + it('should render CustomVariableForm with the correct initial values', () => { + const { variable, onRunQuery } = setup({ + query: 'test, test2', + value: 'test', + isMulti: true, + includeAll: true, + allowCustomValue: true, + allValue: 'all', + }); + + const { elements } = renderEditor(); + + expect(elements.queryInput().value).toBe('test, test2'); + expect(elements.multiValueCheckbox().checked).toBe(true); + expect(elements.allowCustomValueCheckbox().checked).toBe(true); + expect(elements.includeAllCheckbox().checked).toBe(true); + expect(elements.customAllValueInput().value).toBe('all'); }); - const onRunQuery = jest.fn(); - const { getByTestId } = render(); + it('should update the variable state when some input values change ("Multi-value", "Allow custom values" & "Include All option")', () => { + const { variable, onRunQuery } = setup({ + query: 'test, test2', + value: 'test', + isMulti: false, + allowCustomValue: false, + includeAll: false, + }); - const queryInput = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput - ) as HTMLInputElement; - const allValueInput = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput - ) as HTMLInputElement; - const multiCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch - ) as HTMLInputElement; - const includeAllCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch - ) as HTMLInputElement; + const { elements } = renderEditor(); - expect(queryInput.value).toBe('test, test2'); - expect(allValueInput.value).toBe('test'); - expect(multiCheckbox.checked).toBe(true); - expect(includeAllCheckbox.checked).toBe(true); + expect(elements.multiValueCheckbox().checked).toBe(false); + expect(elements.allowCustomValueCheckbox().checked).toBe(false); + expect(elements.includeAllCheckbox().checked).toBe(false); + // include-all-custom input appears after include-all checkbox is checked only + expect(elements.customAllValueInput()).not.toBeInTheDocument(); + + fireEvent.click(elements.multiValueCheckbox()); + fireEvent.click(elements.allowCustomValueCheckbox()); + fireEvent.click(elements.includeAllCheckbox()); + + expect(variable.state.isMulti).toBe(true); + expect(variable.state.allowCustomValue).toBe(true); + expect(variable.state.includeAll).toBe(true); + expect(elements.customAllValueInput()).toBeInTheDocument(); + }); + + describe('when the values textarea loses focus after its value has changed', () => { + it('should update the query in the variable state and call the onRunQuery callback', async () => { + const { variable, onRunQuery } = setup({ query: 'test, test2', value: 'test' }); + + const { actions } = renderEditor(); + + actions.updateValuesInput('test3, test4'); + + expect(variable.state.query).toBe('test3, test4'); + expect(onRunQuery).toHaveBeenCalled(); + }); + }); + + describe('when the "Custom all value" input loses focus after its value has changed', () => { + it('should update the variable state', () => { + const { variable, onRunQuery } = setup({ + query: 'test, test2', + value: 'test', + isMulti: true, + includeAll: true, + }); + + const { elements } = renderEditor(); + + fireEvent.change(elements.customAllValueInput(), { target: { value: 'new custom all' } }); + fireEvent.blur(elements.customAllValueInput()); + + expect(variable.state.allValue).toBe('new custom all'); + }); + }); }); - it('should update the variable state when input values change', () => { - const variable = new CustomVariable({ - name: 'customVar', - query: 'test, test2', - value: 'test', + describe('JSON values format', () => { + const initialJsonQuery = `[ + {"value":1,"text":"Development","aws":"dev","azure":"development"}, + {"value":2,"text":"Production","aws":"prod","azure":"production"} + ]`; + + it('should render CustomVariableForm with the correct initial values', () => { + const { variable, onRunQuery } = setup({ + valuesFormat: 'json', + query: initialJsonQuery, + isMulti: true, + includeAll: true, + }); + + const { elements } = renderEditor(); + + expect(elements.queryInput().value).toBe(initialJsonQuery); + expect(elements.multiValueCheckbox().checked).toBe(true); + expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument(); + expect(elements.includeAllCheckbox().checked).toBe(true); + expect(elements.customAllValueInput()).not.toBeInTheDocument(); }); - const onRunQuery = jest.fn(); - const { getByTestId } = render(); + describe('when the values textarea loses focus after its value has changed', () => { + describe('if the value is valid JSON', () => { + it('should update the query in the variable state and call the onRunQuery callback', async () => { + const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery }); - const multiCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsMultiSwitch - ); - const includeAllCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsIncludeAllSwitch - ); + const { actions } = renderEditor(); - const allowCustomValueCheckbox = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsAllowCustomValueSwitch - ); + actions.updateValuesInput('[]'); - // It include-all-custom input appears after include-all checkbox is checked only - expect(() => - getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput) - ).toThrow('Unable to find an element'); + expect(variable.state.query).toBe('[]'); + expect(onRunQuery).toHaveBeenCalled(); + }); + }); - fireEvent.click(allowCustomValueCheckbox); + describe('if the value is NOT valid JSON', () => { + it('should display a validation error message and neither update the query in the variable state nor call the onRunQuery callback', async () => { + const { variable, onRunQuery } = setup({ valuesFormat: 'json', query: initialJsonQuery }); - fireEvent.click(multiCheckbox); + const { actions, getByRole } = renderEditor( + + ); - fireEvent.click(includeAllCheckbox); - const allValueInput = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput - ); + actions.updateValuesInput('[x]'); - expect(variable.state.isMulti).toBe(true); - expect(variable.state.includeAll).toBe(true); - expect(variable.state.allowCustomValue).toBe(false); - expect(allValueInput).toBeInTheDocument(); + expect(getByRole('alert')).toHaveTextContent(`Unexpected token 'x', "[x]" is not valid JSON`); + expect(variable.state.query).toBe(initialJsonQuery); + expect(onRunQuery).not.toHaveBeenCalled(); + }); + }); + }); }); - it('should call update query and re-run query when input loses focus', async () => { - const variable = new CustomVariable({ - name: 'customVar', - query: 'test, test2', - value: 'test', + describe('when switching values format', () => { + it('should switch the visibility of the proper form inputs ("Allow custom values" and "Custom all value")', () => { + const { variable, onRunQuery } = setup({ + valuesFormat: 'csv', + query: '', + isMulti: true, + includeAll: true, + allowCustomValue: true, + allValue: '', + }); + + const { elements, actions } = renderEditor(); + + expect(elements.allowCustomValueCheckbox()).toBeInTheDocument(); + expect(elements.customAllValueInput()).toBeInTheDocument(); + + actions.changeValuesFormat('json'); + + expect(elements.allowCustomValueCheckbox()).not.toBeInTheDocument(); + expect(elements.customAllValueInput()).not.toBeInTheDocument(); + + actions.changeValuesFormat('csv'); + + expect(elements.allowCustomValueCheckbox()).toBeInTheDocument(); + expect(elements.customAllValueInput()).toBeInTheDocument(); }); - const onRunQuery = jest.fn(); - - const { getByTestId } = render(); - - const queryInput = getByTestId(selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput); - fireEvent.change(queryInput, { target: { value: 'test3, test4' } }); - fireEvent.blur(queryInput); - - expect(onRunQuery).toHaveBeenCalled(); - expect(variable.state.query).toBe('test3, test4'); - }); - - it('should update the variable state when all-custom-value input loses focus', () => { - const variable = new CustomVariable({ - name: 'customVar', - query: 'test, test2', - value: 'test', - isMulti: true, - includeAll: true, - }); - const onRunQuery = jest.fn(); - - const { getByTestId } = render(); - - const allValueInput = getByTestId( - selectors.pages.Dashboard.Settings.Variables.Edit.General.selectionOptionsCustomAllInput - ) as HTMLInputElement; - - fireEvent.change(allValueInput, { target: { value: 'new custom all' } }); - fireEvent.blur(allValueInput); - - expect(variable.state.allValue).toBe('new custom all'); }); }); diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx index 1d73d5a6f2b..048fe383890 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/CustomVariableEditor.tsx @@ -1,16 +1,42 @@ -import { FormEvent, useCallback } from 'react'; +import { isObject } from 'lodash'; +import { FormEvent, useCallback, useState } from 'react'; -import { CustomVariable } from '@grafana/scenes'; +import { CustomVariableModel, shallowCompare } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { CustomVariable, SceneVariable } from '@grafana/scenes'; +import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { CustomVariableForm } from '../../components/CustomVariableForm'; +import { PaneItem } from './PaneItem'; + interface CustomVariableEditorProps { variable: CustomVariable; onRunQuery: () => void; } export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEditorProps) { - const { query, isMulti, allValue, includeAll, allowCustomValue } = variable.useState(); + const { query, valuesFormat, isMulti, allValue, includeAll, allowCustomValue } = variable.useState(); + const [queryValidationError, setQueryValidationError] = useState(); + + const [prevQuery, setPrevQuery] = useState(''); + const onValuesFormatChange = useCallback( + (format: CustomVariableModel['valuesFormat']) => { + variable.setState({ query: prevQuery }); + variable.setState({ value: isMulti ? [] : undefined }); + variable.setState({ valuesFormat: format }); + variable.setState({ allowCustomValue: false }); + variable.setState({ allValue: undefined }); + onRunQuery(); + + setQueryValidationError(undefined); + if (query !== prevQuery) { + setPrevQuery(query); + } + }, + [isMulti, onRunQuery, prevQuery, query, variable] + ); const onMultiChange = useCallback( (event: FormEvent) => { @@ -28,10 +54,24 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi const onQueryChange = useCallback( (event: FormEvent) => { + setPrevQuery(''); + + if (config.featureToggles.multiPropsVariables && valuesFormat === 'json') { + const validationError = validateJsonQuery(event.currentTarget.value.trim()); + setQueryValidationError(validationError); + if (validationError) { + return; + } + } + + if (!config.featureToggles.multiPropsVariables) { + variable.setState({ valuesFormat: 'csv' }); + } + variable.setState({ query: event.currentTarget.value }); onRunQuery(); }, - [variable, onRunQuery] + [valuesFormat, variable, onRunQuery] ); const onAllValueChange = useCallback( @@ -51,15 +91,75 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi return ( ); } + +export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] { + if (!(variable instanceof CustomVariable)) { + return []; + } + + return [ + new OptionsPaneItemDescriptor({ + title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'), + id: 'custom-variable-values', + render: ({ props }) => , + }), + ]; +} + +export const validateJsonQuery = (query: string): Error | undefined => { + if (!query) { + return; + } + + try { + const options = JSON.parse(query); + + if (!Array.isArray(options)) { + throw new Error('Enter a valid JSON array of objects'); + } + + if (!options.length) { + return; + } + + let errorIndex = options.findIndex((item) => !isObject(item)); + if (errorIndex !== -1) { + throw new Error(`All items must be objects. The item at index ${errorIndex} is not an object.`); + } + + const keys = Object.keys(options[0]); + if (!keys.includes('value')) { + throw new Error('Each object in the array must include at least a "value" property'); + } + if (keys.includes('')) { + throw new Error('Object property names cannot be empty strings'); + } + + errorIndex = options.findIndex((o) => !shallowCompare(keys, Object.keys(o))); + if (errorIndex !== -1) { + throw new Error( + `All objects must have the same set of properties. The object at index ${errorIndex} does not match the expected properties` + ); + } + + return; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return error as Error; + } +}; diff --git a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx index aed926a6809..aeb2be59af6 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/CustomVariableEditor/ModalEditor.tsx @@ -1,23 +1,43 @@ -import { useRef, useState } from 'react'; +import { FormEvent, useMemo, useRef, useState } from 'react'; import { lastValueFrom } from 'rxjs'; +import { CustomVariableModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; -import { CustomVariable, VariableValueOption, VariableValueSingle } from '@grafana/scenes'; -import { Button, Modal, Stack } from '@grafana/ui'; +import { config } from '@grafana/runtime'; +import { CustomVariable } from '@grafana/scenes'; +import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui'; import { dashboardEditActions } from '../../../../edit-pane/shared'; -import { VariableStaticOptionsForm, VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm'; -import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton'; +import { ValuesFormatSelector } from '../../components/CustomVariableForm'; import { VariableValuesPreview } from '../../components/VariableValuesPreview'; +import { validateJsonQuery } from './CustomVariableEditor'; +import { ModalEditorNonMultiProps } from './ModalEditorNonMultiProps'; + interface ModalEditorProps { variable: CustomVariable; onClose: () => void; } export function ModalEditor(props: ModalEditorProps) { - const { formRef, onCloseModal, options, onChangeOptions, onAddNewOption, onSaveOptions } = useModalEditor(props); + if (!config.featureToggles.multiPropsVariables) { + return ; + } + return ; +} + +function ModalEditorMultiProps(props: ModalEditorProps) { + const { + valuesFormat, + query, + queryValidationError, + options, + onCloseModal, + onValuesFormatChange, + onQueryChange, + onSaveOptions, + } = useModalEditor(props); return ( - - + +
+