Compare commits

...

45 Commits

Author SHA1 Message Date
dependabot[bot]
80ef886fb4 deps(actions): bump docker/setup-docker-action from 4.4.0 to 4.5.0
Bumps [docker/setup-docker-action](https://github.com/docker/setup-docker-action) from 4.4.0 to 4.5.0.
- [Release notes](https://github.com/docker/setup-docker-action/releases)
- [Commits](3fb92d6d9c...efe9e3891a)

---
updated-dependencies:
- dependency-name: docker/setup-docker-action
  dependency-version: 4.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-05 12:26:13 +00:00
Josh Hunt
93f1c24b82 FS: Update devenv docker images with renovate (#112943)
* FS: Use renovate to update devenv docker images

* add gha check to test tiltfile

* setup nodejs

* create empty config files
2025-11-05 12:24:45 +00:00
Tom Ratcliffe
7c2f38641a Chore: Add gitattributes config for generated files (#113402)
* Add gitattributes config for generated files

* Add snapshots and manifest files
2025-11-05 11:24:58 +00:00
Darren Janeczek
6376484b13 Card: apply grid fractional unit to description instead of heading (#113424) 2025-11-05 10:18:14 +00:00
Alexander Zobnin
d1334a6dff Zanzana: Log token namespace in case of error (#113437) 2025-11-05 11:13:08 +01:00
Alexander Zobnin
505e025d18 Zanzana: Fix namespace in remote client (#113433) 2025-11-05 11:12:41 +01:00
Roberto Jiménez Sánchez
571e5c2e3c Provisioning: Fix data race in job progress and leasing (#113157)
* Fix data race in provisioning job execution

* Fix TODO

* Update pkg/registry/apis/provisioning/jobs/driver.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update pkg/registry/apis/provisioning/jobs/driver.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix unlocking issue on panic

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 10:07:21 +00:00
Oscar Kilhed
4ceb7eec52 Dynamic Dashboards: Change dragging to using grid items instead of viz panels. (#113343)
* Preserve grid item size and repeat options when dragging between grids

* push gridItem usage all the way

* do console.warn and log to faro instead
2025-11-05 10:47:19 +01:00
Andres Martinez Gotor
2e507d5042 Advisor: Add mock checks to standalone setup (#113406) 2025-11-05 10:33:45 +01:00
Lauren
1bd5b29963 Alerting: Bug fix for regex matching in Alerts page (#113400)
* Alerting: bug fix for regex matching in Alerts page

* remove test
2025-11-05 09:24:48 +00:00
Jean-Philippe Quéméner
98ec655f33 fix: add resource type to not empty log (#113432) 2025-11-05 10:12:44 +01:00
Lauren
1d38cf7f0d Alerting: Add empty state to triage page WIP (#113390)
* add empty state to triage page WIP

* tidy up and refactor

* generate translations

* resolve PR comments

* generate translations

* resolve PR comment part 2
2025-11-05 08:46:41 +00:00
Anna Urbiztondo
891ed6c4b7 Docs: Git Sync permissions (#113405)
* Permissions

* Prettier

* Edit
2025-11-05 09:38:48 +01:00
Jesse David Peterson
e067b1de98 FeatureToggle: Create experimental timeRangePan flag (#112988)
feat(toggle): new experimental timeRangePan feature toggle
2025-11-04 21:39:46 -04:00
Stephanie Hingtgen
4cecab3185 Dashboards: add isPublic to dto and remove public endpoint call (#113334)
---------

Co-authored-by: Matheus Macabu <macabu.matheus@gmail.com>
2025-11-04 16:57:05 -06:00
Paul Marbach
867e8bb98f StatusHistory: Add e2e suite to confirm standard cases (#113358)
* StatusHistory: Add e2e suite to confirm standard cases

* update dev dashbord tests

* update CODEOWNERS
2025-11-04 16:21:30 -04:00
Paul Marbach
5abc0d0d91 Coverage: Add new exclusions for team coverage report (#112997) 2025-11-04 15:35:56 -04:00
Ashley Harrison
3972046695 Chore: Improve step name to differentiate between light/dark themes (#113407)
improve step name to differentiate between light/dark themes
2025-11-04 17:38:42 +00:00
Pepe Cano
ffa5e41bec docs(alerting): add note about invalid numeric identifiers in templates (#113269) 2025-11-04 17:56:41 +01:00
Alex Spencer
84bd99f1c1 SQL Expressions: Update to feature badges (#112795)
* chore: update badge + update logic

* chore: update comment
2025-11-04 08:18:15 -08:00
Todd Treece
de512fb02e Plugins: Fix API sync error handling (#113240) 2025-11-04 10:47:58 -05:00
Alexander Zobnin
3fca7cf952 Zanzana: Refactor basic role write APIs (#113397)
* Zanzana: Refactor basic role write APIs

* Fix updates

* fix linter
2025-11-04 16:29:56 +01:00
Serge Zaitsev
8f8ed2bbec Chore: Change ownership for annotations (#112791)
change ownership for annotations
2025-11-04 13:30:47 +00:00
Josh Hunt
69e4b4667b Backend: Add logs and metric for when host is redirected (#112373) 2025-11-04 13:28:33 +00:00
Ihor Yeromin
0c016e210a Linter: Rollback suppressed errors (#113396)
chore(linter): rollback suppressed errors
2025-11-04 13:15:54 +01:00
Jo
769787ea40 IAM: Improve team binding hooks error handling and validation (#113393)
small team hook fixes
2025-11-04 11:54:38 +01:00
Levente Balogh
b4312a220f Dashboard Controls: Adjust spacing for annotation controls (#113381)
* fix: spacing issues with annotation control switches inside the dashboad controls

* refactor: remove unnecessary css class
2025-11-04 11:20:10 +01:00
Lauren
c75a451b13 Alerting: Add counts for firing and pending alert rules (#113309)
* add counts for firing and pending alert rules

* resolve Pr comments part 1

* resolve PR comments part 2

* resolve PR comments part 3
2025-11-04 10:17:07 +00:00
dependabot[bot]
df816d00e4 deps(actions): bump actions/upload-artifact from 4 to 5 (#113025)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-04 10:03:56 +00:00
Rafael Bortolon Paulovic
782c819727 Get traces and profiles of nocgo sqlite integration tests as GH artifact (#113034) 2025-11-04 10:36:20 +01:00
Gábor Farkas
6fc82629d4 codeowners: updated ownership of datasources code (#113387) 2025-11-04 09:29:57 +00:00
Sonia Aguilar
fc34b18122 Alerting: Fix data source recording rules editor (#113363)
fix data source recording rules editor
2025-11-04 09:01:26 +01:00
Gábor Farkas
ac162e203b datasources: querier: do not forward these headers (#113383) 2025-11-04 09:01:16 +01:00
Georges Chaudy
ddeb970b4d unistore: replace CDK backend with KV store backend (again) (#113184)
* Reapply "unistore: replace CDK backend with KV store backend"" (#113132)

This reverts commit 7127b2538c.

* enable cluster scope
2025-11-04 00:33:36 +00:00
Charandas
a06905e2d3 fix: switch to test-containers from grafana/e2e for API server tests (#113256) 2025-11-03 16:10:32 -08:00
Georges Chaudy
07bf7b2ae1 kvstore: add cluster-scoped resource support (#113183)
kvstore add experimental clusterscope resource
2025-11-03 15:53:59 -08:00
Austin Pond
0649635639 Apps: call app.Runner().Run() in a goroutine in the post-start hook (#113371)
Run the app runner in a goroutine in the post-start hook, as the '/readyz' endpoint for the API server waits for a non-nil error response from the post-start hook to mark it as ready.
2025-11-03 17:05:20 -05:00
Ezequiel Victorero
126e6dbcd8 ShortURL: Set same generateName for old and new API endpoints (#113368) 2025-11-03 17:50:02 -03:00
Stephanie Hingtgen
f56fec2c10 Short URLs: Skip flaky test for now (#113364) 2025-11-03 17:19:12 +00:00
Alexa Vargas
ca5cf5fe7c Dashboard datasource: Fix library panels not tracked in mixed queries (#112959)
* Dashboard datasource: Fix library panels not tracked in mixed queries

* Remove unnecessary code and add unit tests

* Add relevant comment
2025-11-03 18:01:29 +01:00
Oscar Kilhed
06fb3fef43 Dynamic Dashboards: Change look and copy of add variable control to make it more obvious what it does (#113361)
Change look+copy of add variable control
2025-11-03 18:00:25 +01:00
Moustafa Baiou
414524e799 Alerting: Add index for rule_group_index in alert_rule table
This is a slight optimization for the list queries which sort by these fields.
2025-11-03 11:36:18 -05:00
Moustafa Baiou
acf0da9b80 Make the ordering of test on case-sensitivity consistent across databases and charsets 2025-11-03 11:36:18 -05:00
Moustafa Baiou
6f7c525213 Alerting: Ensure case-sensitive ordering for alert rule group column
The query which fetches alert rules in a paginated manner ordered by `rule_group` can result in strange and inconsistent ordering when the database uses a case-insensitive collation for the `rule_group` column. This can lead to scenarios where rules from different groups are interleaved in the results, making pagination unreliable and the returned number of rule_groups incorrect.

Related to #88990
2025-11-03 11:36:18 -05:00
Ihor Yeromin
684156fdf1 Event Tracking: Add tracking for expression query removal (#113113)
* feat(tracking-events): add traking event on expression remove

* chore(traking): remove type assertion
2025-11-03 16:58:51 +01:00
143 changed files with 7507 additions and 1262 deletions

5
.gitattributes vendored
View File

@@ -1 +1,6 @@
* text=auto eol=lf
*.gen.ts linguist-generated
*_gen.ts linguist-generated
*_gen.go linguist-generated
**/openapi_snapshots/*.json linguist-generated
apps/**/pkg/apis/*_manifest.go linguist-generated

21
.github/CODEOWNERS vendored
View File

@@ -151,7 +151,7 @@
/pkg/promlib @grafana/oss-big-tent
/pkg/storage/ @grafana/grafana-search-and-storage
/pkg/storage/secret/ @grafana/grafana-operator-experience-squad
/pkg/services/annotations/ @grafana/grafana-search-and-storage
/pkg/services/annotations/ @grafana/grafana-backend-services-squad
/pkg/services/apikey/ @grafana/identity-squad
/pkg/services/cleanup/ @grafana/grafana-backend-group
/pkg/services/contexthandler/ @grafana/grafana-backend-group @grafana/grafana-app-platform-squad
@@ -181,7 +181,7 @@
/pkg/services/search/ @grafana/grafana-search-and-storage
/pkg/services/searchusers/ @grafana/grafana-search-and-storage
/pkg/services/secrets/ @grafana/grafana-operator-experience-squad
/pkg/services/shorturls/ @grafana/grafana-backend-group
/pkg/services/shorturls/ @grafana/sharing-squad
/pkg/services/sqlstore/ @grafana/grafana-search-and-storage
/pkg/services/ssosettings/ @grafana/identity-squad
/pkg/services/star/ @grafana/grafana-search-and-storage
@@ -199,6 +199,7 @@
/pkg/tests/apis/features @grafana/grafana-backend-services-squad
/pkg/tests/apis/folder @grafana/grafana-search-and-storage
/pkg/tests/apis/iam @grafana/identity-access-team
/pkg/tests/apis/shorturl @grafana/sharing-squad
/pkg/tests/api/correlations/ @grafana/datapro
/pkg/tsdb/grafanads/ @grafana/grafana-backend-group
/pkg/tsdb/opentsdb/ @grafana/partner-datasources
@@ -241,6 +242,7 @@
/devenv/dev-dashboards/panel-library @grafana/dataviz-squad
/devenv/dev-dashboards/panel-piechart @grafana/dataviz-squad
/devenv/dev-dashboards/panel-stat @grafana/dataviz-squad
/devenv/dev-dashboards/panel-status-history @grafana/dataviz-squad
/devenv/dev-dashboards/panel-table @grafana/dataviz-squad
/devenv/dev-dashboards/panel-timeline @grafana/dataviz-squad
/devenv/dev-dashboards/panel-timeseries @grafana/dataviz-squad
@@ -472,24 +474,12 @@ i18next.config.ts @grafana/grafana-frontend-platform
/e2e-playwright/fixtures/long-trace-response.json @grafana/observability-traces-and-profiling
/e2e-playwright/fixtures/tempo-response.json @grafana/oss-big-tent
/e2e-playwright/fixtures/prometheus-response.json @grafana/datapro
/e2e-playwright/panels-suite/canvas-scene.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/ @grafana/dataviz-squad
/e2e-playwright/panels-suite/dashlist.spec.ts @grafana/grafana-search-navigate-organise
/e2e-playwright/panels-suite/datagrid-data-change.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/datagrid-editing-features.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/frontend-sandbox-panel.spec.ts @grafana/plugins-platform-frontend
/e2e-playwright/panels-suite/geomap-layer-types.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/geomap-map-controls.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/geomap-spatial-operations-transform.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/heatmap.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/panelEdit_base.spec.ts @grafana/dashboards-squad
/e2e-playwright/panels-suite/panelEdit_queries.spec.ts @grafana/dashboards-squad
/e2e-playwright/panels-suite/panelEdit_transforms.spec.ts @grafana/datapro
/e2e-playwright/panels-suite/state-timeline.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-footer.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-markdown.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-sparkline.spec.ts @grafana/dataviz-squad
/e2e-playwright/panels-suite/table-utils.ts @grafana/dataviz-squad
/e2e-playwright/plugin-e2e/ @grafana/oss-big-tent @grafana/partner-datasources
/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/ @grafana/plugins-platform-frontend
/e2e-playwright/smoke-tests-suite/ @grafana/grafana-frontend-platform
@@ -1176,6 +1166,7 @@ embed.go @grafana/grafana-as-code
/pkg/registry/ @grafana/grafana-as-code
/pkg/registry/apis/ @grafana/grafana-app-platform-squad
/pkg/registry/apis/folders @grafana/grafana-search-and-storage
/pkg/registry/apis/datasource @grafana/grafana-datasources-core-services
/pkg/registry/apis/query @grafana/grafana-datasources-core-services
/pkg/registry/apis/secret @grafana/grafana-operator-experience-squad
/pkg/registry/apis/userstorage @grafana/grafana-app-platform-squad @grafana/plugins-platform-backend

View File

@@ -31,6 +31,9 @@ outputs:
dockerfile:
description: Whether the dockerfile or self have changed in any way
value: ${{ steps.changed-files.outputs.dockerfile_any_changed || 'true' }}
devenv:
description: Whether the devenv or self have changed in any way
value: ${{ steps.changed-files.outputs.devenv_any_changed || 'true' }}
runs:
using: composite
steps:
@@ -136,6 +139,9 @@ runs:
- '.vale.ini'
- '.github/actions/change-detection/**'
- '${{ inputs.self }}'
devenv:
- 'devenv/**'
- '${{ inputs.self }}'
- name: Print all change groups
shell: bash
run: |
@@ -157,3 +163,5 @@ runs:
echo " --> ${{ steps.changed-files.outputs.docs_all_changed_files }}"
echo "Dockerfile: ${{ steps.changed-files.outputs.dockerfile_any_changed || 'true' }}"
echo " --> ${{ steps.changed-files.outputs.dockerfile_all_changed_files }}"
echo "devenv: ${{ steps.changed-files.outputs.devenv_any_changed || 'true' }}"
echo " --> ${{ steps.changed-files.outputs.devenv_all_changed_files }}"

View File

@@ -1,6 +1,6 @@
{
extends: ["config:recommended"],
enabledManagers: ["npm"],
enabledManagers: ["npm", "docker-compose"],
ignorePresets: [
"github>grafana/grafana-renovate-config//presets/labels",
],
@@ -26,7 +26,7 @@
"@types/slate-react", // we don't want to continue using this on the long run, use Monaco editor instead of Slate
"@types/slate", // we don't want to continue using this on the long run, use Monaco editor instead of Slate
],
includePaths: ["package.json", "packages/**", "public/app/plugins/**"],
includePaths: ["package.json", "packages/**", "public/app/plugins/**", "devenv/frontend-service/docker-compose.yaml"],
ignorePaths: ["emails/**", "**/mocks/**"],
labels: ["area/frontend", "dependencies", "no-changelog"],
postUpdateOptions: ["yarnDedupeHighest"],

View File

@@ -40,7 +40,7 @@ jobs:
}' "$GITHUB_EVENT_PATH" > /tmp/pr_info.json
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: pr_info
path: /tmp/pr_info.json

View File

@@ -193,7 +193,7 @@ jobs:
exit 1
fi
- name: store build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: build-artifacts
path: ${{ steps.get_dir.outputs.dir }}/ci/packages/*.zip

View File

@@ -64,7 +64,7 @@ jobs:
run: zip -r ./pr_built_packages.zip ./packages/**/*.tgz
- name: Upload build output as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: buildPr
path: './pr/pr_built_packages.zip'
@@ -116,7 +116,7 @@ jobs:
run: zip -r ./base_built_packages.zip ./packages/**/*.tgz
- name: Upload build output as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: buildBase
path: './base/base_built_packages.zip'
@@ -189,7 +189,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
- name: Upload check output as artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: levitate
path: levitate/

View File

@@ -94,14 +94,14 @@ jobs:
id: artifact
- name: Upload grafana.tar.gz
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
retention-days: 1
name: grafana-tar-gz
path: build-dir/grafana.tar.gz
- name: Upload grafana docker tarball
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
retention-days: 1
name: grafana-docker-tar-gz
@@ -133,7 +133,7 @@ jobs:
# We want a static binary, so we need to set CGO_ENABLED=0
CGO_ENABLED=0 go build -o ./e2e-runner ./e2e/
echo "artifact=e2e-runner-${{github.run_number}}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
id: upload
with:
retention-days: 1
@@ -245,7 +245,7 @@ jobs:
run: |
set -euo pipefail
echo "suite=$(echo "$SUITE" | sed 's/\//-/g')" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
if: success() || failure()
with:
name: ${{ steps.set-suite-name.outputs.suite }}-${{ github.run_number }}
@@ -307,7 +307,7 @@ jobs:
version: 0.18.8
verb: run
args: go run ./pkg/build/e2e-playwright --package=grafana.tar.gz --shard=${{ matrix.shard }}/${{ matrix.shardTotal }} --blob-dir=./blob-report
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
if: success() || failure()
with:
name: playwright-blob-${{ github.run_number }}-${{ matrix.shard }}
@@ -439,7 +439,7 @@ jobs:
- name: Upload HTML report
id: upload-html
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: playwright-html-${{ github.run_number }}
path: playwright-report
@@ -498,7 +498,7 @@ jobs:
args: go run ./pkg/build/a11y --package=grafana.tar.gz --no-threshold-fail --results=./pa11y-ci-results.json
- name: Upload pa11y results
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
retention-days: 1
name: pa11y-ci-results

View File

@@ -18,6 +18,7 @@ jobs:
contents: read
outputs:
changed: ${{ steps.detect-changes.outputs.frontend }}
devenv-changed: ${{ steps.detect-changes.outputs.devenv }}
steps:
- uses: actions/checkout@v5
with:
@@ -169,3 +170,26 @@ jobs:
needs: ${{ toJson(needs) }}
failure-message: "One or more unit test jobs have failed"
success-message: "All unit tests completed successfully"
devenv:
needs:
- detect-changes
if: needs.detect-changes.outputs.devenv-changed == 'true'
runs-on: ubuntu-x64-large
name: "Devenv frontend-service build"
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Docker
uses: docker/setup-docker-action@efe9e3891a4f7307e689f2100b33a155b900a608 # v4
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Install Tilt
run: curl -fsSL https://raw.githubusercontent.com/tilt-dev/tilt/master/scripts/install.sh | bash
- name: Create empty config files # TODO: the tiltfile should conditionally mount these only if they exist, like the enterprise license
run: |
touch devenv/frontend-service/configs/grafana-api.local.ini
touch devenv/frontend-service/configs/frontend-service.local.ini
- name: Test frontend-service Tiltfile
run: tilt ci --file devenv/frontend-service/Tiltfile

View File

@@ -34,6 +34,6 @@ jobs:
uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-docker-action@3fb92d6d9c634363128c8cce4bc3b2826526370a # v4
- uses: docker/setup-docker-action@efe9e3891a4f7307e689f2100b33a155b900a608 # v4
- name: Build Dockerfile
run: make build-docker-full

View File

@@ -78,6 +78,7 @@ jobs:
# We don't need more than this since it has to wait for the other tests.
shard: [
1/4, 2/4, 3/4, 4/4,
profiled,
]
fail-fast: false
@@ -96,13 +97,68 @@ jobs:
go-version-file: go.mod
cache: true
- name: Run tests
if: matrix.shard != 'profiled'
env:
SHARD: ${{ matrix.shard }}
CGO_ENABLED: 0
SKIP_PACKAGES: |-
pkg/tests/apis/folder
pkg/tests/apis/dashboard
run: |
set -euo pipefail
readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N"$SHARD" -d-)"
# ionice since tests are IO intensive
CGO_ENABLED=0 ionice -c2 -n7 go test -p=4 -tags=sqlite -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}"
# Build regex pattern like: pkg1$|pkg2$|pkg3$
SKIP_PATTERN=$(echo "$SKIP_PACKAGES" | sed '/^$/d' | sed 's|.*|&$|' | paste -sd '|' -)
readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | ./scripts/ci/backend-tests/shard.sh -N "$SHARD" -d - | grep -Ev "($SKIP_PATTERN)")"
go test -tags=sqlite -timeout=8m -run '^TestIntegration' "${PACKAGES[@]}"
- name: Run profiled tests
id: run-profiled-tests
if: matrix.shard == 'profiled'
env:
CGO_ENABLED: 0
PROFILED_PACKAGES: |-
pkg/tests/apis/folder
pkg/tests/apis/dashboard
run: |
set -euo pipefail
# Build regex pattern line: pkg1$|pkg2$|pkg3$
PROFILE_PATTERN=$(echo "$PROFILED_PACKAGES" | sed '/^$/d' | sed 's|.*|&$|' | paste -sd '|' -)
readarray -t PACKAGES <<< "$(./scripts/ci/backend-tests/pkgs-with-tests-named.sh -b TestIntegration | grep -E "($PROFILE_PATTERN)")"
if [ ${#PACKAGES[@]} -eq 0 ]; then
echo "⚠️ No profiled packages found"
exit 0
fi
mkdir -p profiles
EXIT_CODE=0
# Run each profiled package sequentially
for full_pkg in "${PACKAGES[@]}"; do
# Build valid file name
pkg_name=$(basename "$full_pkg" | tr '/' '_' | tr '.' '_')
echo "📦 Running $full_pkg"
set +e
go test -tags=sqlite -timeout=8m -run '^TestIntegration' \
-outputdir=profiles \
-cpuprofile="cpu_${pkg_name}.prof" \
-memprofile="mem_${pkg_name}.prof" \
-trace="trace_${pkg_name}.out" \
"$full_pkg" 2>&1 | tee "profiles/test_${pkg_name}.log"
TEST_EXIT=$?
set -e
if [ $TEST_EXIT -ne 0 ]; then
echo "❌ $full_pkg failed with exit code $TEST_EXIT"
EXIT_CODE=1
else
echo "✅ $full_pkg passed"
fi
done
exit $EXIT_CODE
- name: Output test profiles and traces
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4
if: (matrix.shard == 'profiled' && !cancelled())
with:
name: integration-test-profiles-sqlite-nocgo-${{ github.run_number }}
path: profiles/
retention-days: 7
if-no-files-found: ignore
mysql:
needs: detect-changes
if: needs.detect-changes.outputs.changed == 'true'

View File

@@ -187,12 +187,12 @@ jobs:
output: artifacts-${{ matrix.name }}.txt
verify: ${{ matrix.verify }}
build-id: ${{ github.run_id }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: artifacts-list-${{ matrix.name }}
path: ${{ steps.build.outputs.file }}
retention-days: 1
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4
with:
name: artifacts-${{ matrix.name }}
path: ${{ steps.build.outputs.dist-dir }}

View File

@@ -34,7 +34,7 @@ jobs:
id-token: write
needs: detect-changes
if: needs.detect-changes.outputs.changed == 'true'
name: "Run Storybook a11y tests"
name: "Run Storybook a11y tests (light theme)"
steps:
- uses: actions/checkout@v5
with:
@@ -64,7 +64,7 @@ jobs:
id-token: write
needs: detect-changes
if: needs.detect-changes.outputs.changed == 'true'
name: "Run Storybook a11y tests"
name: "Run Storybook a11y tests (dark theme)"
steps:
- uses: actions/checkout@v5
with:

View File

@@ -15,3 +15,20 @@ generate: install-app-sdk update-app-sdk
.PHONY: run
run:
@go run ./pkg/standalone/server.go --etcd-servers=http://127.0.0.1:22379 --secure-port 7445
.PHONY: create-checks
create-checks:
@echo "Creating plugin check..."
@curl -k -X POST https://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks \
-H "Content-Type: application/json" \
-d '{"kind":"Check","apiVersion":"advisor.grafana.app/v0alpha1","spec":{"data":{}},"metadata":{"generateName":"check-","labels":{"advisor.grafana.app/type":"plugin"},"namespace":"stacks-1"},"status":{"report":{"count":0,"failures":[]}}}' \
&& echo "Plugin check created successfully"
@echo "Creating datasource check..."
@curl -k -X POST https://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks \
-H "Content-Type: application/json" \
-d '{"kind":"Check","apiVersion":"advisor.grafana.app/v0alpha1","spec":{"data":{}},"metadata":{"generateName":"check-","labels":{"advisor.grafana.app/type":"datasource"},"namespace":"stacks-1"},"status":{"report":{"count":0,"failures":[]}}}' \
&& echo "Datasource check created successfully"
delete-checks:
@curl -k -X DELETE https://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks \
&& echo "All checks deleted successfully"

View File

@@ -163,3 +163,17 @@ make run # Start the advisor app in standalone mode
```
This will start the advisor app on port 7445. You can then access the advisor app at `http://localhost:7445`.
To see some sample checks, you can run the following command:
```bash
make create-checks
```
Then you can see list in the URL: `http://localhost:7445/apis/advisor.grafana.app/v0alpha1/namespaces/stacks-1/checks`
To delete all checks, you can run the following command:
```bash
make delete-checks
```

View File

@@ -1,12 +1,59 @@
package mockchecks
import "github.com/grafana/grafana/apps/advisor/pkg/app/checks"
import (
"github.com/grafana/grafana/apps/advisor/pkg/app/checkregistry/mockchecks/mocksvcs"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks/datasourcecheck"
"github.com/grafana/grafana/apps/advisor/pkg/app/checks/plugincheck"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/repo"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginchecker"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
// mockchecks.CheckRegistry is a mock implementation of the checkregistry.CheckService interface
// TODO: Add mocked checks here
type CheckRegistry struct {
datasourceSvc datasources.DataSourceService
pluginStore pluginstore.Store
pluginClient plugins.Client
pluginRepo repo.Service
GrafanaVersion string
pluginContextProvider datasourcecheck.PluginContextProvider
updateChecker pluginchecker.PluginUpdateChecker
pluginErrorResolver plugins.ErrorResolver
}
func (m *CheckRegistry) Checks() []checks.Check {
return []checks.Check{}
return []checks.Check{
datasourcecheck.New(
m.datasourceSvc,
m.pluginStore,
m.pluginContextProvider,
m.pluginClient,
m.pluginRepo,
m.GrafanaVersion,
),
plugincheck.New(
m.pluginStore,
m.pluginRepo,
m.updateChecker,
m.pluginErrorResolver,
m.GrafanaVersion,
),
}
}
func New() *CheckRegistry {
return &CheckRegistry{
datasourceSvc: &mocksvcs.DatasourceSvc{},
pluginStore: &mocksvcs.PluginStore{},
pluginClient: &mocksvcs.PluginClient{},
pluginRepo: &mocksvcs.PluginRepo{},
pluginContextProvider: &mocksvcs.PluginContextProvider{},
updateChecker: &mocksvcs.UpdateChecker{},
pluginErrorResolver: &mocksvcs.PluginErrorResolver{},
GrafanaVersion: "1.0.0",
}
}

View File

@@ -0,0 +1,44 @@
package mocksvcs
import (
"context"
"github.com/grafana/grafana/pkg/services/datasources"
)
var dss = map[string]*datasources.DataSource{
"prometheus-uid": {
ID: 1,
UID: "prometheus-uid",
Name: "Prometheus",
Type: "prometheus",
},
"mysql-uid": {
ID: 2,
UID: "mysql-uid",
Name: "MySQL",
Type: "mysql",
},
"unknown-uid": {
ID: 3,
UID: "unknown-uid",
Name: "Unknown",
Type: "unknown",
},
}
type DatasourceSvc struct {
datasources.DataSourceService
}
func (m *DatasourceSvc) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) {
sources := make([]*datasources.DataSource, 0, len(dss))
for _, ds := range dss {
sources = append(sources, ds)
}
return sources, nil
}
func (m *DatasourceSvc) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
return dss[query.UID], nil
}

View File

@@ -0,0 +1,19 @@
package mocksvcs
import (
"context"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/plugins"
)
type PluginClient struct {
plugins.Client
}
func (m *PluginClient) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
return &backend.CheckHealthResult{
Status: backend.HealthStatusOk,
Message: "Plugin is healthy",
}, nil
}

View File

@@ -0,0 +1,53 @@
package mocksvcs
import (
"context"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/datasources"
)
type PluginContextProvider struct {
}
// ACTUALLY USED by datasourcecheck
func (m *PluginContextProvider) GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error) {
// Create a plugin context with sample data based on the datasource
pluginContext := backend.PluginContext{
PluginID: pluginID,
PluginVersion: "1.0.0",
OrgID: 1,
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
ID: ds.ID,
UID: ds.UID,
Name: ds.Name,
URL: ds.URL,
JSONData: []byte(`{
"httpMethod": "GET",
"timeout": "30s",
"keepCookies": []
}`),
DecryptedSecureJSONData: map[string]string{
"password": "sample-password",
"apiKey": "sample-api-key",
},
},
GrafanaConfig: backend.NewGrafanaCfg(map[string]string{
"app_url": "http://localhost:3000",
"default_timezone": "UTC",
}),
}
// Add user context if provided
if user != nil && !user.IsNil() {
pluginContext.User = &backend.User{
Login: user.GetLogin(),
Name: user.GetName(),
Email: user.GetEmail(),
Role: string(user.GetOrgRole()),
}
}
return pluginContext, nil
}

View File

@@ -0,0 +1,19 @@
package mocksvcs
import (
"context"
"github.com/grafana/grafana/pkg/plugins"
)
type PluginErrorResolver struct {
}
// Assume no plugin with errors
func (m *PluginErrorResolver) PluginErrors(ctx context.Context) []*plugins.Error {
return nil
}
func (m *PluginErrorResolver) PluginError(ctx context.Context, pluginID string) *plugins.Error {
return nil
}

View File

@@ -0,0 +1,26 @@
package mocksvcs
import (
"context"
"github.com/grafana/grafana/pkg/plugins/repo"
)
type PluginRepo struct {
repo.Service
}
func (m *PluginRepo) GetPluginsInfo(ctx context.Context, options repo.GetPluginsInfoOptions, compatOpts repo.CompatOpts) ([]repo.PluginInfo, error) {
return []repo.PluginInfo{
{
ID: 1,
Slug: "grafana-piechart-panel",
Version: "1.6.0",
},
{
ID: 2,
Slug: "prometheus",
Version: "10.0.0",
},
}, nil
}

View File

@@ -0,0 +1,114 @@
package mocksvcs
import (
"context"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
type PluginStore struct {
}
var ps = map[string]pluginstore.Plugin{
"prometheus": {
JSONData: plugins.JSONData{
ID: "prometheus",
Type: plugins.TypeDataSource,
Name: "Prometheus",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
},
Version: "10.0.0",
},
Category: "Time series databases",
State: plugins.ReleaseStateAlpha,
Backend: true,
Metrics: true,
Logs: true,
Alerting: true,
Explore: true,
},
Class: plugins.ClassCore,
Signature: plugins.SignatureStatusInternal,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "grafana.com",
},
"test-datasource": {
JSONData: plugins.JSONData{
ID: "grafana-piechart-panel",
Type: plugins.TypePanel,
Name: "Pie Chart",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
},
Version: "1.6.0",
},
Category: "Visualization",
State: plugins.ReleaseStateAlpha,
},
Class: plugins.ClassCore,
Signature: plugins.SignatureStatusInternal,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "grafana.com",
},
"grafana-piechart-panel": {
JSONData: plugins.JSONData{
ID: "prometheus",
Type: plugins.TypeDataSource,
Name: "Prometheus",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Grafana Labs",
},
Version: "10.0.0",
},
Category: "Time series databases",
State: plugins.ReleaseStateAlpha,
Backend: true,
Metrics: true,
Logs: true,
Alerting: true,
Explore: true,
},
Class: plugins.ClassCore,
Signature: plugins.SignatureStatusInternal,
SignatureType: plugins.SignatureTypeGrafana,
SignatureOrg: "grafana.com",
},
"test-app": {
JSONData: plugins.JSONData{
ID: "test-app",
Type: plugins.TypeApp,
Name: "Test App",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Test Author",
},
Version: "2.0.0",
},
Category: "Application",
State: plugins.ReleaseStateAlpha,
AutoEnabled: true,
},
Class: plugins.ClassExternal,
Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeCommercial,
SignatureOrg: "test.com",
},
}
func (s *PluginStore) Plugin(ctx context.Context, pluginID string) (pluginstore.Plugin, bool) {
p, ok := ps[pluginID]
return p, ok
}
func (s *PluginStore) Plugins(ctx context.Context, pluginTypes ...plugins.Type) []pluginstore.Plugin {
plugins := make([]pluginstore.Plugin, 0, len(ps))
for _, p := range ps {
plugins = append(plugins, p)
}
return plugins
}

View File

@@ -0,0 +1,18 @@
package mocksvcs
import (
"context"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
)
type UpdateChecker struct {
}
func (m *UpdateChecker) IsUpdatable(ctx context.Context, plugin pluginstore.Plugin) bool {
return true
}
func (m *UpdateChecker) CanUpdate(pluginId string, currentVersion string, targetVersion string, onlyMinor bool) bool {
return true
}

View File

@@ -26,7 +26,7 @@ const (
type check struct {
DatasourceSvc datasources.DataSourceService
PluginStore pluginstore.Store
PluginContextProvider pluginContextProvider
PluginContextProvider PluginContextProvider
PluginClient plugins.Client
PluginRepo repo.Service
GrafanaVersion string
@@ -37,7 +37,7 @@ type check struct {
func New(
datasourceSvc datasources.DataSourceService,
pluginStore pluginstore.Store,
pluginContextProvider pluginContextProvider,
pluginContextProvider PluginContextProvider,
pluginClient plugins.Client,
pluginRepo repo.Service,
grafanaVersion string,
@@ -168,6 +168,6 @@ func (c *check) canBeInstalled(ctx context.Context, pluginType string) (bool, er
return isAvailableInRepo, nil
}
type pluginContextProvider interface {
type PluginContextProvider interface {
GetWithDataSource(ctx context.Context, pluginID string, user identity.Requester, ds *datasources.DataSource) (backend.PluginContext, error)
}

View File

@@ -15,7 +15,7 @@ import (
)
type healthCheckStep struct {
PluginContextProvider pluginContextProvider
PluginContextProvider PluginContextProvider
PluginClient plugins.Client
}

View File

@@ -29,7 +29,7 @@ func main() {
KubeConfig: rest.Config{}, // this will be replaced by the apiserver loopback config
ManifestData: *apis.LocalManifest().ManifestData,
SpecificConfig: checkregistry.AdvisorAppConfig{
CheckRegistry: &mockchecks.CheckRegistry{},
CheckRegistry: mockchecks.New(),
PluginConfig: map[string]string{},
StackID: "1", // Numeric stack ID for standalone mode
OrgService: nil, // Not needed when StackID is set

View File

@@ -3,8 +3,9 @@ package dashboard
// Information about how the requesting user can use a given dashboard
type DashboardAccess struct {
// Metadata fields
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
IsPublic bool `json:"isPublic"`
// The permissions part
CanSave bool `json:"canSave"`

View File

@@ -12,8 +12,9 @@ type DashboardWithAccessInfo struct {
// +k8s:deepcopy-gen=true
type DashboardAccess struct {
// Metadata fields
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
IsPublic bool `json:"isPublic"`
// The permissions part
CanSave bool `json:"canSave"`

View File

@@ -112,6 +112,7 @@ func Convert_dashboard_AnnotationPermission_To_v0alpha1_AnnotationPermission(in
func autoConvert_v0alpha1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error {
out.Slug = in.Slug
out.Url = in.Url
out.IsPublic = in.IsPublic
out.CanSave = in.CanSave
out.CanEdit = in.CanEdit
out.CanAdmin = in.CanAdmin
@@ -129,6 +130,7 @@ func Convert_v0alpha1_DashboardAccess_To_dashboard_DashboardAccess(in *Dashboard
func autoConvert_dashboard_DashboardAccess_To_v0alpha1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error {
out.Slug = in.Slug
out.Url = in.Url
out.IsPublic = in.IsPublic
out.CanSave = in.CanSave
out.CanEdit = in.CanEdit
out.CanAdmin = in.CanAdmin

View File

@@ -170,6 +170,13 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardAccess(ref common.ReferenceCall
Format: "",
},
},
"isPublic": {
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"canSave": {
SchemaProps: spec.SchemaProps{
Description: "The permissions part",
@@ -212,7 +219,7 @@ func schema_pkg_apis_dashboard_v0alpha1_DashboardAccess(ref common.ReferenceCall
},
},
},
Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"},
Required: []string{"isPublic", "canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"},
},
},
Dependencies: []string{

View File

@@ -123,8 +123,9 @@ type DashboardWithAccessInfo struct {
// +k8s:deepcopy-gen=true
type DashboardAccess struct {
// Metadata fields
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
IsPublic bool `json:"isPublic"`
// The permissions part
CanSave bool `json:"canSave"`

View File

@@ -118,6 +118,7 @@ func Convert_dashboard_AnnotationPermission_To_v1beta1_AnnotationPermission(in *
func autoConvert_v1beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error {
out.Slug = in.Slug
out.Url = in.Url
out.IsPublic = in.IsPublic
out.CanSave = in.CanSave
out.CanEdit = in.CanEdit
out.CanAdmin = in.CanAdmin
@@ -135,6 +136,7 @@ func Convert_v1beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardA
func autoConvert_dashboard_DashboardAccess_To_v1beta1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error {
out.Slug = in.Slug
out.Url = in.Url
out.IsPublic = in.IsPublic
out.CanSave = in.CanSave
out.CanEdit = in.CanEdit
out.CanAdmin = in.CanAdmin

View File

@@ -165,6 +165,13 @@ func schema_pkg_apis_dashboard_v1beta1_DashboardAccess(ref common.ReferenceCallb
Format: "",
},
},
"isPublic": {
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"canSave": {
SchemaProps: spec.SchemaProps{
Description: "The permissions part",
@@ -207,7 +214,7 @@ func schema_pkg_apis_dashboard_v1beta1_DashboardAccess(ref common.ReferenceCallb
},
},
},
Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"},
Required: []string{"isPublic", "canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"},
},
},
Dependencies: []string{

View File

@@ -123,8 +123,9 @@ type DashboardWithAccessInfo struct {
// +k8s:deepcopy-gen=true
type DashboardAccess struct {
// Metadata fields
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
IsPublic bool `json:"isPublic"`
// The permissions part
CanSave bool `json:"canSave"`

View File

@@ -118,6 +118,7 @@ func Convert_dashboard_AnnotationPermission_To_v2alpha1_AnnotationPermission(in
func autoConvert_v2alpha1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error {
out.Slug = in.Slug
out.Url = in.Url
out.IsPublic = in.IsPublic
out.CanSave = in.CanSave
out.CanEdit = in.CanEdit
out.CanAdmin = in.CanAdmin
@@ -135,6 +136,7 @@ func Convert_v2alpha1_DashboardAccess_To_dashboard_DashboardAccess(in *Dashboard
func autoConvert_dashboard_DashboardAccess_To_v2alpha1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error {
out.Slug = in.Slug
out.Url = in.Url
out.IsPublic = in.IsPublic
out.CanSave = in.CanSave
out.CanEdit = in.CanEdit
out.CanAdmin = in.CanAdmin

View File

@@ -265,6 +265,13 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardAccess(ref common.ReferenceCall
Format: "",
},
},
"isPublic": {
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"canSave": {
SchemaProps: spec.SchemaProps{
Description: "The permissions part",
@@ -307,7 +314,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardAccess(ref common.ReferenceCall
},
},
},
Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"},
Required: []string{"isPublic", "canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"},
},
},
Dependencies: []string{

View File

@@ -123,8 +123,9 @@ type DashboardWithAccessInfo struct {
// +k8s:deepcopy-gen=true
type DashboardAccess struct {
// Metadata fields
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
Slug string `json:"slug,omitempty"`
Url string `json:"url,omitempty"`
IsPublic bool `json:"isPublic"`
// The permissions part
CanSave bool `json:"canSave"`

View File

@@ -118,6 +118,7 @@ func Convert_dashboard_AnnotationPermission_To_v2beta1_AnnotationPermission(in *
func autoConvert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardAccess, out *dashboard.DashboardAccess, s conversion.Scope) error {
out.Slug = in.Slug
out.Url = in.Url
out.IsPublic = in.IsPublic
out.CanSave = in.CanSave
out.CanEdit = in.CanEdit
out.CanAdmin = in.CanAdmin
@@ -135,6 +136,7 @@ func Convert_v2beta1_DashboardAccess_To_dashboard_DashboardAccess(in *DashboardA
func autoConvert_dashboard_DashboardAccess_To_v2beta1_DashboardAccess(in *dashboard.DashboardAccess, out *DashboardAccess, s conversion.Scope) error {
out.Slug = in.Slug
out.Url = in.Url
out.IsPublic = in.IsPublic
out.CanSave = in.CanSave
out.CanEdit = in.CanEdit
out.CanAdmin = in.CanAdmin

View File

@@ -269,6 +269,13 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardAccess(ref common.ReferenceCallb
Format: "",
},
},
"isPublic": {
SchemaProps: spec.SchemaProps{
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"canSave": {
SchemaProps: spec.SchemaProps{
Description: "The permissions part",
@@ -311,7 +318,7 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardAccess(ref common.ReferenceCallb
},
},
},
Required: []string{"canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"},
Required: []string{"isPublic", "canSave", "canEdit", "canAdmin", "canStar", "canDelete", "annotationsPermissions"},
},
},
Dependencies: []string{

View File

@@ -122,6 +122,8 @@ 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-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
github.com/Azure/go-autorest/autorest v0.11.29 h1:I4+HL/JDvErx2LjyzaVxllw2lRDB5/BT2Bm4g20iqYw=
@@ -427,6 +429,10 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
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/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
@@ -436,6 +442,8 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -507,6 +515,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
@@ -595,6 +605,8 @@ 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-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/analysis v0.24.0 h1:vE/VFFkICKyYuTWYnplQ+aVr45vlG6NcZKC7BdIXhsA=
github.com/go-openapi/analysis v0.24.0/go.mod h1:GLyoJA+bvmGGaHgpfeDh8ldpGo69fAJg7eeMDMRCIrw=
github.com/go-openapi/errors v0.22.3 h1:k6Hxa5Jg1TUyZnOwV2Lh81j8ayNw5VVYLvKrp4zFKFs=
@@ -1106,6 +1118,8 @@ github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/m3db/prometheus_remote_client_golang v0.4.4 h1:DsAIjVKoCp7Ym35tAOFL1OuMLIdIikAEHeNPHY+yyM8=
github.com/m3db/prometheus_remote_client_golang v0.4.4/go.mod h1:wHfVbA3eAK6dQvKjCkHhusWYegCk3bDGkA15zymSHdc=
github.com/madflojo/testcerts v1.4.0 h1:I09gN0C1ly9IgeVNcAqKk8RAKIJTe3QnFrrPBDyvzN4=
@@ -1114,6 +1128,8 @@ github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 h1:hQWBtNqRYrI7CWIaUSXXtNKR90KzcUA5uiuxFVWw7sU=
@@ -1197,8 +1213,20 @@ github.com/mithrandie/ternary v1.1.1 h1:k/joD6UGVYxHixYmSR8EGgDFNONBMqyD373xT4QR
github.com/mithrandie/ternary v1.1.1/go.mod h1:0D9Ba3+09K2TdSZO7/bFCC0GjSXetCvYuYq0u8FY/1g=
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/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
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/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
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/mocktools/go-smtp-mock/v2 v2.5.1 h1:QcMJMChSgG1olVj4o6xxQFdrWzRjYNrcq660HAjd0wA=
github.com/mocktools/go-smtp-mock/v2 v2.5.1/go.mod h1:Rr8M2njlxx//l5INl2+uESnsL2lDsL24teEykCrGfmE=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -1212,6 +1240,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWu
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/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -1319,6 +1349,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -1419,6 +1451,8 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shadowspore/fossil-delta v0.0.0-20241213113458-1d797d70cbe3 h1:/4/IJi5iyTdh6mqOUaASW148HQpujYiHl0Wl78dSOSc=
github.com/shadowspore/fossil-delta v0.0.0-20241213113458-1d797d70cbe3/go.mod h1:aJIMhRsunltJR926EB2MUg8qHemFQDreSB33pyto2Ps=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
@@ -1498,6 +1532,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/testcontainers/testcontainers-go v0.36.0 h1:YpffyLuHtdp5EUsI5mT4sRw8GZhO/5ozyDT1xWGXt00=
github.com/testcontainers/testcontainers-go v0.36.0/go.mod h1:yk73GVJ0KUZIHUtFna6MO7QS144qYpoY8lEEtU9Hed0=
github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4=
github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=
github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM=
@@ -1507,6 +1543,10 @@ github.com/thomaspoignant/go-feature-flag v1.42.0/go.mod h1:y0QiWH7chHWhGATb/+Xq
github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o=
github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
@@ -1562,6 +1602,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk=
github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=

View File

@@ -6,6 +6,7 @@ require (
github.com/grafana/grafana-app-sdk v0.48.1
github.com/grafana/grafana-app-sdk/logging v0.48.1
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250428110029-a8ea72012bde
github.com/stretchr/testify v1.11.1
k8s.io/apimachinery v0.34.1
k8s.io/apiserver v0.34.1
k8s.io/klog/v2 v2.130.1

View File

@@ -93,6 +93,7 @@ func equalStringPointers(a, b *string) bool {
type InstallRegistrar struct {
clientGenerator resource.ClientGenerator
client *pluginsv0alpha1.PluginClient
clientErr error
clientOnce sync.Once
}
@@ -107,20 +108,21 @@ func (r *InstallRegistrar) GetClient() (*pluginsv0alpha1.PluginClient, error) {
r.clientOnce.Do(func() {
client, err := pluginsv0alpha1.NewPluginClientFromGenerator(r.clientGenerator)
if err != nil {
r.clientErr = err
r.client = nil
return
}
r.client = client
})
return r.client, nil
return r.client, r.clientErr
}
// Register creates or updates a plugin install in the registry.
func (r *InstallRegistrar) Register(ctx context.Context, namespace string, install *PluginInstall) error {
client, err := r.GetClient()
if err != nil {
return nil
return err
}
identifier := resource.Identifier{
Namespace: namespace,
@@ -132,9 +134,12 @@ func (r *InstallRegistrar) Register(ctx context.Context, namespace string, insta
return err
}
if existing != nil && install.ShouldUpdate(existing) {
_, err = client.Update(ctx, install.ToPluginInstallV0Alpha1(namespace), resource.UpdateOptions{ResourceVersion: existing.ResourceVersion})
return err
if existing != nil {
if install.ShouldUpdate(existing) {
_, err = client.Update(ctx, install.ToPluginInstallV0Alpha1(namespace), resource.UpdateOptions{ResourceVersion: existing.ResourceVersion})
return err
}
return nil
}
_, err = client.Create(ctx, install.ToPluginInstallV0Alpha1(namespace), resource.CreateOptions{})
@@ -155,6 +160,10 @@ func (r *InstallRegistrar) Unregister(ctx context.Context, namespace string, nam
if err != nil && !errorsK8s.IsNotFound(err) {
return err
}
// if the plugin doesn't exist, nothing to unregister
if existing == nil {
return nil
}
// if the source is different, do not unregister
if existingSource, ok := existing.Annotations[PluginInstallSourceAnnotation]; ok && existingSource != source {
return nil

View File

@@ -0,0 +1,908 @@
package install
import (
"context"
"errors"
"testing"
"github.com/grafana/grafana-app-sdk/resource"
"github.com/stretchr/testify/require"
errorsK8s "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
pluginsv0alpha1 "github.com/grafana/grafana/apps/plugins/pkg/apis/plugins/v0alpha1"
)
func TestPluginInstall_ShouldUpdate(t *testing.T) {
baseExisting := &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
Annotations: map[string]string{
PluginInstallSourceAnnotation: SourcePluginStore,
},
},
Spec: pluginsv0alpha1.PluginSpec{
Id: "plugin-1",
Version: "1.0.0",
Class: pluginsv0alpha1.PluginSpecClass(ClassExternal),
},
}
baseInstall := PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
}
tests := []struct {
name string
modifyInstall func(*PluginInstall)
modifyExisting func(*pluginsv0alpha1.Plugin)
expectUpdate bool
}{
{
name: "no changes",
expectUpdate: false,
},
{
name: "version differs",
modifyInstall: func(pi *PluginInstall) {
pi.Version = "2.0.0"
},
expectUpdate: true,
},
{
name: "class differs",
modifyInstall: func(pi *PluginInstall) {
pi.Class = ClassCore
},
expectUpdate: true,
},
{
name: "url differs",
modifyInstall: func(pi *PluginInstall) {
pi.URL = "https://example.com/plugin.zip"
},
expectUpdate: true,
},
{
name: "source differs",
modifyExisting: func(existing *pluginsv0alpha1.Plugin) {
existing.Annotations[PluginInstallSourceAnnotation] = SourceUnknown
},
expectUpdate: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
existing := baseExisting.DeepCopy()
install := baseInstall
if tt.modifyExisting != nil {
tt.modifyExisting(existing)
}
if tt.modifyInstall != nil {
tt.modifyInstall(&install)
}
require.Equal(t, tt.expectUpdate, install.ShouldUpdate(existing))
})
}
}
func TestInstallRegistrar_Register(t *testing.T) {
tests := []struct {
name string
install *PluginInstall
existing *pluginsv0alpha1.Plugin
existingErr error
expectedCreates int
expectedUpdates int
expectError bool
}{
{
name: "creates plugin when not found",
install: &PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
},
existingErr: errorsK8s.NewNotFound(pluginGroupResource(), "plugin-1"),
expectedCreates: 1,
},
{
name: "updates plugin when fields change",
install: &PluginInstall{
ID: "plugin-1",
Version: "2.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
},
existing: &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
ResourceVersion: "7",
Annotations: map[string]string{
PluginInstallSourceAnnotation: SourcePluginStore,
},
},
Spec: pluginsv0alpha1.PluginSpec{
Id: "plugin-1",
Version: "1.0.0",
Class: pluginsv0alpha1.PluginSpecClass(ClassExternal),
},
},
expectedUpdates: 1,
},
{
name: "skips create when plugin matches",
install: &PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
},
existing: &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
ResourceVersion: "9",
Annotations: map[string]string{
PluginInstallSourceAnnotation: SourcePluginStore,
},
},
Spec: pluginsv0alpha1.PluginSpec{
Id: "plugin-1",
Version: "1.0.0",
Class: pluginsv0alpha1.PluginSpecClass(ClassExternal),
},
},
},
{
name: "returns error on unexpected get failure",
install: &PluginInstall{
ID: "plugin-err",
Version: "1.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
},
existingErr: errorsK8s.NewInternalError(errors.New("boom")),
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
createCalls := 0
updateCalls := 0
var receivedResourceVersions []string
var updatedPlugins []*pluginsv0alpha1.Plugin
fakeClient := &fakePluginInstallClient{
getFunc: func(context.Context, resource.Identifier) (*pluginsv0alpha1.Plugin, error) {
if tt.existingErr != nil {
return nil, tt.existingErr
}
if tt.existing == nil {
return nil, errorsK8s.NewNotFound(pluginGroupResource(), "plugin-1")
}
return tt.existing.DeepCopy(), nil
},
createFunc: func(context.Context, *pluginsv0alpha1.Plugin, resource.CreateOptions) (*pluginsv0alpha1.Plugin, error) {
createCalls++
return tt.install.ToPluginInstallV0Alpha1("org-1"), nil
},
updateFunc: func(_ context.Context, obj *pluginsv0alpha1.Plugin, opts resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) {
updateCalls++
receivedResourceVersions = append(receivedResourceVersions, opts.ResourceVersion)
updatedPlugins = append(updatedPlugins, obj)
return obj, nil
},
}
registrar := NewInstallRegistrar(&fakeClientGenerator{client: fakeClient})
err := registrar.Register(ctx, "org-1", tt.install)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.expectedCreates, createCalls)
require.Equal(t, tt.expectedUpdates, updateCalls)
if tt.expectedUpdates > 0 {
require.Equal(t, []string{tt.existing.ResourceVersion}, receivedResourceVersions)
require.Len(t, updatedPlugins, 1)
require.Equal(t, tt.install.Version, updatedPlugins[0].Spec.Version)
}
})
}
}
func pluginGroupResource() schema.GroupResource {
return schema.GroupResource{Group: pluginsv0alpha1.APIGroup, Resource: "plugininstalls"}
}
type fakePluginInstallClient struct {
listAllFunc func(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginList, error)
getFunc func(ctx context.Context, identifier resource.Identifier) (*pluginsv0alpha1.Plugin, error)
createFunc func(ctx context.Context, obj *pluginsv0alpha1.Plugin, opts resource.CreateOptions) (*pluginsv0alpha1.Plugin, error)
updateFunc func(ctx context.Context, obj *pluginsv0alpha1.Plugin, opts resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error)
deleteFunc func(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error
}
func (f *fakePluginInstallClient) Get(ctx context.Context, identifier resource.Identifier) (*pluginsv0alpha1.Plugin, error) {
if f.getFunc != nil {
return f.getFunc(ctx, identifier)
}
return nil, errorsK8s.NewNotFound(pluginGroupResource(), identifier.Name)
}
func (f *fakePluginInstallClient) ListAll(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginList, error) {
if f.listAllFunc != nil {
return f.listAllFunc(ctx, namespace, opts)
}
return &pluginsv0alpha1.PluginList{}, nil
}
func (f *fakePluginInstallClient) List(ctx context.Context, namespace string, opts resource.ListOptions) (*pluginsv0alpha1.PluginList, error) {
return f.ListAll(ctx, namespace, opts)
}
func (f *fakePluginInstallClient) Create(ctx context.Context, obj *pluginsv0alpha1.Plugin, opts resource.CreateOptions) (*pluginsv0alpha1.Plugin, error) {
if f.createFunc != nil {
return f.createFunc(ctx, obj, opts)
}
return obj, nil
}
func (f *fakePluginInstallClient) Update(ctx context.Context, obj *pluginsv0alpha1.Plugin, opts resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) {
if f.updateFunc != nil {
return f.updateFunc(ctx, obj, opts)
}
return obj, nil
}
func (f *fakePluginInstallClient) UpdateStatus(ctx context.Context, identifier resource.Identifier, newStatus pluginsv0alpha1.PluginStatus, opts resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) {
return nil, nil
}
func (f *fakePluginInstallClient) Patch(ctx context.Context, identifier resource.Identifier, req resource.PatchRequest, opts resource.PatchOptions) (*pluginsv0alpha1.Plugin, error) {
return nil, nil
}
func (f *fakePluginInstallClient) Delete(ctx context.Context, identifier resource.Identifier, opts resource.DeleteOptions) error {
if f.deleteFunc != nil {
return f.deleteFunc(ctx, identifier, opts)
}
return nil
}
type fakeClientGenerator struct {
client *fakePluginInstallClient
shouldError bool
}
func (f *fakeClientGenerator) ClientFor(resource.Kind) (resource.Client, error) {
if f.shouldError {
return nil, errors.New("client generation failed")
}
return &fakeResourceClient{client: f.client}, nil
}
type fakeResourceClient struct {
client *fakePluginInstallClient
}
func (f *fakeResourceClient) Get(ctx context.Context, identifier resource.Identifier) (resource.Object, error) {
return f.client.Get(ctx, identifier)
}
func (f *fakeResourceClient) GetInto(ctx context.Context, identifier resource.Identifier, into resource.Object) error {
obj, err := f.client.Get(ctx, identifier)
if err != nil {
return err
}
if target, ok := into.(*pluginsv0alpha1.Plugin); ok {
*target = *obj
}
return nil
}
func (f *fakeResourceClient) List(ctx context.Context, namespace string, options resource.ListOptions) (resource.ListObject, error) {
return f.client.ListAll(ctx, namespace, options)
}
func (f *fakeResourceClient) ListInto(ctx context.Context, namespace string, options resource.ListOptions, into resource.ListObject) error {
list, err := f.client.ListAll(ctx, namespace, options)
if err != nil {
return err
}
if target, ok := into.(*pluginsv0alpha1.PluginList); ok {
*target = *list
}
return nil
}
func (f *fakeResourceClient) Create(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.CreateOptions) (resource.Object, error) {
plugin := obj.(*pluginsv0alpha1.Plugin)
return f.client.Create(ctx, plugin, options)
}
func (f *fakeResourceClient) CreateInto(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.CreateOptions, into resource.Object) error {
created, err := f.Create(ctx, identifier, obj, options)
if err != nil {
return err
}
if plugin, ok := created.(*pluginsv0alpha1.Plugin); ok {
if target, ok := into.(*pluginsv0alpha1.Plugin); ok {
*target = *plugin
}
}
return nil
}
func (f *fakeResourceClient) Update(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.UpdateOptions) (resource.Object, error) {
plugin := obj.(*pluginsv0alpha1.Plugin)
return f.client.Update(ctx, plugin, options)
}
func (f *fakeResourceClient) UpdateInto(ctx context.Context, identifier resource.Identifier, obj resource.Object, options resource.UpdateOptions, into resource.Object) error {
updated, err := f.Update(ctx, identifier, obj, options)
if err != nil {
return err
}
if plugin, ok := updated.(*pluginsv0alpha1.Plugin); ok {
if target, ok := into.(*pluginsv0alpha1.Plugin); ok {
*target = *plugin
}
}
return nil
}
func (f *fakeResourceClient) Patch(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest, options resource.PatchOptions) (resource.Object, error) {
return nil, nil
}
func (f *fakeResourceClient) PatchInto(ctx context.Context, identifier resource.Identifier, patch resource.PatchRequest, options resource.PatchOptions, into resource.Object) error {
return nil
}
func (f *fakeResourceClient) Delete(ctx context.Context, identifier resource.Identifier, options resource.DeleteOptions) error {
return f.client.Delete(ctx, identifier, options)
}
func (f *fakeResourceClient) SubresourceRequest(ctx context.Context, identifier resource.Identifier, req resource.CustomRouteRequestOptions) ([]byte, error) {
return []byte{}, nil
}
func (f *fakeResourceClient) Watch(ctx context.Context, namespace string, options resource.WatchOptions) (resource.WatchResponse, error) {
return &fakeWatchResponse{}, nil
}
type fakeWatchResponse struct{}
func (f *fakeWatchResponse) Stop() {}
func (f *fakeWatchResponse) WatchEvents() <-chan resource.WatchEvent {
ch := make(chan resource.WatchEvent)
close(ch)
return ch
}
func TestPluginInstall_ToPluginInstallV0Alpha1(t *testing.T) {
tests := []struct {
name string
install PluginInstall
namespace string
validate func(*testing.T, *pluginsv0alpha1.Plugin)
}{
{
name: "empty URL creates nil pointer",
install: PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
},
namespace: "org-1",
validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) {
require.Nil(t, p.Spec.Url)
},
},
{
name: "non-empty URL creates pointer",
install: PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
URL: "https://example.com/plugin.zip",
Class: ClassExternal,
Source: SourcePluginStore,
},
namespace: "org-1",
validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) {
require.NotNil(t, p.Spec.Url)
require.Equal(t, "https://example.com/plugin.zip", *p.Spec.Url)
},
},
{
name: "core class is mapped correctly",
install: PluginInstall{
ID: "plugin-core",
Version: "2.0.0",
Class: ClassCore,
Source: SourcePluginStore,
},
namespace: "org-2",
validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) {
require.Equal(t, pluginsv0alpha1.PluginSpecClass(ClassCore), p.Spec.Class)
},
},
{
name: "cdn class is mapped correctly",
install: PluginInstall{
ID: "plugin-cdn",
Version: "3.0.0",
Class: ClassCDN,
Source: SourcePluginStore,
},
namespace: "org-3",
validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) {
require.Equal(t, pluginsv0alpha1.PluginSpecClass(ClassCDN), p.Spec.Class)
},
},
{
name: "source annotation is set correctly",
install: PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
Class: ClassExternal,
Source: SourceUnknown,
},
namespace: "org-1",
validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) {
require.Equal(t, SourceUnknown, p.Annotations[PluginInstallSourceAnnotation])
},
},
{
name: "namespace and name are set correctly",
install: PluginInstall{
ID: "my-plugin",
Version: "1.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
},
namespace: "my-namespace",
validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) {
require.Equal(t, "my-namespace", p.Namespace)
require.Equal(t, "my-plugin", p.Name)
require.Equal(t, "my-plugin", p.Spec.Id)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.install.ToPluginInstallV0Alpha1(tt.namespace)
require.NotNil(t, result)
require.Equal(t, tt.namespace, result.Namespace)
require.Equal(t, tt.install.ID, result.Name)
require.Equal(t, tt.install.ID, result.Spec.Id)
require.Equal(t, tt.install.Version, result.Spec.Version)
tt.validate(t, result)
})
}
}
func TestEqualStringPointers(t *testing.T) {
str1 := "value1"
str2 := "value2"
str3 := "value1"
tests := []struct {
name string
a *string
b *string
expected bool
}{
{
name: "both nil",
a: nil,
b: nil,
expected: true,
},
{
name: "first nil, second non-nil",
a: nil,
b: &str1,
expected: false,
},
{
name: "first non-nil, second nil",
a: &str1,
b: nil,
expected: false,
},
{
name: "both non-nil with same value",
a: &str1,
b: &str3,
expected: true,
},
{
name: "both non-nil with different values",
a: &str1,
b: &str2,
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := equalStringPointers(tt.a, tt.b)
require.Equal(t, tt.expected, result)
})
}
}
func TestPluginInstall_ShouldUpdate_URLTransitions(t *testing.T) {
existingURL := "https://old.example.com/plugin.zip"
newURL := "https://new.example.com/plugin.zip"
tests := []struct {
name string
install PluginInstall
existingURL *string
expectUpdate bool
}{
{
name: "URL transition from nil to non-nil",
install: PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
URL: newURL,
Class: ClassExternal,
Source: SourcePluginStore,
},
existingURL: nil,
expectUpdate: true,
},
{
name: "URL transition from non-nil to nil",
install: PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
URL: "",
Class: ClassExternal,
Source: SourcePluginStore,
},
existingURL: &existingURL,
expectUpdate: true,
},
{
name: "URL stays nil",
install: PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
URL: "",
Class: ClassExternal,
Source: SourcePluginStore,
},
existingURL: nil,
expectUpdate: false,
},
{
name: "URL stays same non-nil value",
install: PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
URL: existingURL,
Class: ClassExternal,
Source: SourcePluginStore,
},
existingURL: &existingURL,
expectUpdate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
existing := &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
Annotations: map[string]string{
PluginInstallSourceAnnotation: SourcePluginStore,
},
},
Spec: pluginsv0alpha1.PluginSpec{
Id: "plugin-1",
Version: "1.0.0",
Url: tt.existingURL,
Class: pluginsv0alpha1.PluginSpecClass(ClassExternal),
},
}
require.Equal(t, tt.expectUpdate, tt.install.ShouldUpdate(existing))
})
}
}
func TestInstallRegistrar_GetClient(t *testing.T) {
t.Run("successfully creates client on first call", func(t *testing.T) {
fakeClient := &fakePluginInstallClient{}
generator := &fakeClientGenerator{client: fakeClient}
registrar := NewInstallRegistrar(generator)
client, err := registrar.GetClient()
require.NoError(t, err)
require.NotNil(t, client)
})
t.Run("returns same client on subsequent calls", func(t *testing.T) {
fakeClient := &fakePluginInstallClient{}
generator := &fakeClientGenerator{client: fakeClient}
registrar := NewInstallRegistrar(generator)
client1, err1 := registrar.GetClient()
require.NoError(t, err1)
client2, err2 := registrar.GetClient()
require.NoError(t, err2)
require.Equal(t, client1, client2)
})
t.Run("returns error when client generation fails", func(t *testing.T) {
generator := &fakeClientGenerator{client: nil, shouldError: true}
registrar := NewInstallRegistrar(generator)
client, err := registrar.GetClient()
require.Error(t, err)
require.Nil(t, client)
})
}
func TestInstallRegistrar_Register_ErrorCases(t *testing.T) {
tests := []struct {
name string
install *PluginInstall
setupClient func(*fakePluginInstallClient)
expectError bool
}{
{
name: "create fails",
install: &PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
},
setupClient: func(fc *fakePluginInstallClient) {
fc.getFunc = func(context.Context, resource.Identifier) (*pluginsv0alpha1.Plugin, error) {
return nil, errorsK8s.NewNotFound(pluginGroupResource(), "plugin-1")
}
fc.createFunc = func(context.Context, *pluginsv0alpha1.Plugin, resource.CreateOptions) (*pluginsv0alpha1.Plugin, error) {
return nil, errors.New("create failed")
}
},
expectError: true,
},
{
name: "update fails",
install: &PluginInstall{
ID: "plugin-1",
Version: "2.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
},
setupClient: func(fc *fakePluginInstallClient) {
fc.getFunc = func(context.Context, resource.Identifier) (*pluginsv0alpha1.Plugin, error) {
return &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
ResourceVersion: "5",
Annotations: map[string]string{
PluginInstallSourceAnnotation: SourcePluginStore,
},
},
Spec: pluginsv0alpha1.PluginSpec{
Id: "plugin-1",
Version: "1.0.0",
Class: pluginsv0alpha1.PluginSpecClass(ClassExternal),
},
}, nil
}
fc.updateFunc = func(context.Context, *pluginsv0alpha1.Plugin, resource.UpdateOptions) (*pluginsv0alpha1.Plugin, error) {
return nil, errors.New("update failed")
}
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
fakeClient := &fakePluginInstallClient{}
tt.setupClient(fakeClient)
registrar := NewInstallRegistrar(&fakeClientGenerator{client: fakeClient})
err := registrar.Register(ctx, "org-1", tt.install)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestInstallRegistrar_Unregister(t *testing.T) {
tests := []struct {
name string
namespace string
pluginName string
source Source
existing *pluginsv0alpha1.Plugin
existingErr error
expectedCalls int
expectError bool
}{
{
name: "successfully deletes plugin with matching source",
namespace: "org-1",
pluginName: "plugin-1",
source: SourcePluginStore,
existing: &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
Annotations: map[string]string{
PluginInstallSourceAnnotation: SourcePluginStore,
},
},
},
expectedCalls: 1,
},
{
name: "plugin not found should not error",
namespace: "org-1",
pluginName: "plugin-nonexistent",
source: SourcePluginStore,
existingErr: errorsK8s.NewNotFound(pluginGroupResource(), "plugin-nonexistent"),
expectedCalls: 0,
expectError: false,
},
{
name: "skips delete when source doesn't match",
namespace: "org-1",
pluginName: "plugin-1",
source: SourcePluginStore,
existing: &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
Annotations: map[string]string{
PluginInstallSourceAnnotation: SourceUnknown,
},
},
},
expectedCalls: 0,
},
{
name: "returns error on unexpected get failure",
namespace: "org-1",
pluginName: "plugin-err",
source: SourcePluginStore,
existingErr: errorsK8s.NewInternalError(errors.New("get failed")),
expectedCalls: 0,
expectError: true,
},
{
name: "delete failure returns error",
namespace: "org-1",
pluginName: "plugin-1",
source: SourcePluginStore,
existing: &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
Annotations: map[string]string{
PluginInstallSourceAnnotation: SourcePluginStore,
},
},
},
expectedCalls: 1,
expectError: true,
},
{
name: "handles missing source annotation",
namespace: "org-1",
pluginName: "plugin-1",
source: SourcePluginStore,
existing: &pluginsv0alpha1.Plugin{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-1",
Name: "plugin-1",
Annotations: map[string]string{},
},
},
expectedCalls: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
deleteCalls := 0
fakeClient := &fakePluginInstallClient{
getFunc: func(context.Context, resource.Identifier) (*pluginsv0alpha1.Plugin, error) {
if tt.existingErr != nil {
return nil, tt.existingErr
}
if tt.existing == nil {
return nil, errorsK8s.NewNotFound(pluginGroupResource(), tt.pluginName)
}
return tt.existing.DeepCopy(), nil
},
deleteFunc: func(context.Context, resource.Identifier, resource.DeleteOptions) error {
deleteCalls++
if tt.name == "delete failure returns error" {
return errors.New("delete failed")
}
return nil
},
}
registrar := NewInstallRegistrar(&fakeClientGenerator{client: fakeClient})
err := registrar.Unregister(ctx, tt.namespace, tt.pluginName, tt.source)
require.Equal(t, tt.expectedCalls, deleteCalls)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestInstallRegistrar_GetClientError(t *testing.T) {
t.Run("Register returns error with nil client", func(t *testing.T) {
ctx := context.Background()
generator := &fakeClientGenerator{client: nil, shouldError: true}
registrar := NewInstallRegistrar(generator)
install := &PluginInstall{
ID: "plugin-1",
Version: "1.0.0",
Class: ClassExternal,
Source: SourcePluginStore,
}
err := registrar.Register(ctx, "org-1", install)
require.Error(t, err)
})
t.Run("Unregister returns error with nil client", func(t *testing.T) {
ctx := context.Background()
generator := &fakeClientGenerator{client: nil, shouldError: true}
registrar := NewInstallRegistrar(generator)
err := registrar.Unregister(ctx, "org-1", "plugin-1", SourcePluginStore)
require.Error(t, err)
})
}

View File

@@ -86,7 +86,7 @@ services:
- 'alloy.logs=true'
alloy:
image: grafana/alloy:latest
image: grafana/alloy:v1.11.2
volumes:
- ./configs/alloy:/alloy-config
- /var/run/docker.sock:/var/run/docker.sock # To scrape Docker container logs
@@ -104,7 +104,7 @@ services:
- 'alloy.logs=true'
prometheus:
image: prom/prometheus
image: prom/prometheus:v3.7.2
volumes:
- prometheus-data:/prometheus
command:
@@ -116,7 +116,7 @@ services:
- 'alloy.logs=true'
loki:
image: grafana/loki
image: grafana/loki:3.5.7
volumes:
- loki-data:/loki
command: -config.file=/etc/loki/local-config.yaml
@@ -124,7 +124,7 @@ services:
- 'alloy.logs=true'
tempo-init:
image: busybox
image: busybox:1.37.0
user: root
entrypoint:
- 'chown'
@@ -134,7 +134,7 @@ services:
- tempo-data:/var/tempo
tempo:
image: grafana/tempo
image: grafana/tempo:2.9.0
volumes:
- tempo-data:/var/lib/tempo
- ./configs/tempo.yaml:/etc/tempo/tempo.yaml

View File

@@ -96,6 +96,7 @@
"rows-to-fields": (import '../dev-dashboards/transforms/rows-to-fields.json'),
"shared_queries": (import '../dev-dashboards/panel-common/shared_queries.json'),
"slow_queries_and_annotations": (import '../dev-dashboards/scenarios/slow_queries_and_annotations.json'),
"status-history-thresholds-mappings": (import '../dev-dashboards/panel-status-history/status-history-thresholds-mappings.json'),
"table_footer": (import '../dev-dashboards/panel-table/table_footer.json'),
"table_kitchen_sink": (import '../dev-dashboards/panel-table/table_kitchen_sink.json'),
"table_markdown": (import '../dev-dashboards/panel-table/table_markdown.json'),

View File

@@ -141,6 +141,20 @@ Alternatively, you can use the `index()` function to retrieve the query value:
{{ index $values "B" }} CPU usage for {{ index $labels "instance" }} over the last 5 minutes.
```
{{< admonition type="note" >}}
Variable names that start with a number (for example, `1B`) are not [valid identifiers in Go templates](https://go.dev/ref/spec#Identifiers).
To access a value or label whose key starts with a number, use the `index` function:
```
{{ index $values "1B" }} CPU usage for {{ index $labels "1instance" }} over the last 5 minutes.
```
Using `{{ $values.1B.Value }}` is invalid and causes the template code to render as plain text.
{{< /admonition >}}
#### $value
The `$value` variable is a string containing the labels and values of all instant queries; threshold, reduce and math expressions, and classic conditions in the alert rule.

View File

@@ -10,6 +10,12 @@ labels:
- enterprise
- oss
- cloud
refs:
roles-and-permissions:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/account-management/authentication-and-permissions/cloud-roles/
title: Git Sync
weight: 100
---
@@ -84,6 +90,11 @@ Refer to [Requirements](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/obser
- You can only authenticate in GitHub using your Personal Access Token token.
**Permission management**
- You cannot modify the permissions of a provisioned folder after you've synced it.
- Default permissions are: Admin = Admin, Editor = Editor, and Viewer = Viewer. Refer to [Roles and permissions](ref:roles-and-permissions) for more information.
**Compatibility**
- Support for native Git, Git app, and other providers, such as GitLab or Bitbucket, is on the roadmap.

View File

@@ -0,0 +1,87 @@
import { test, expect } from '@grafana/plugin-e2e';
const DASHBOARD_UID = 'a2f4ad9e-3b44-4624-8067-35f31be5d309';
test.use({
viewport: { width: 1280, height: 2000 },
});
test.describe('Panels test: StatusHistory', { tag: ['@panels', '@status-history'] }, () => {
test('renders successfully', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
});
// check that gauges are rendered
const statusHistoryUplot = page.locator('.uplot');
await expect(statusHistoryUplot, 'panels are rendered').toHaveCount(11);
// check that no panel errors exist
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
await expect(errorInfo, 'no errors in the panels').toBeHidden();
});
test('"no data"', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '15' }),
});
const statusHistoryUplot = page.locator('.uplot');
await expect(statusHistoryUplot, "that uplot doesn't appear").toBeHidden();
const emptyMessage = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage);
await expect(emptyMessage, 'that the empty text appears').toHaveText('No data');
// update the "No value" option and see if the panel updates
const noValueOption = dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Standard options No value'))
.locator('input');
await noValueOption.fill('My empty value');
await noValueOption.blur();
await expect(emptyMessage, 'that the empty text has changed').toHaveText('My empty value');
});
test('tooltip interactions', async ({ gotoDashboardPage, page, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '13' }),
});
const statusHistoryUplot = page.locator('.uplot');
await expect(statusHistoryUplot, 'uplot is rendered').toBeVisible();
const tooltip = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Tooltip.Wrapper);
// hover over a spot to trigger the tooltip
await statusHistoryUplot.hover({ position: { x: 100, y: 50 } });
await expect(tooltip, 'tooltip appears on hover').toBeVisible();
await expect(tooltip, 'tooltip displays the value').toContainText('value5');
// click to pin the tooltip, hover away to be sure it's pinned
await statusHistoryUplot.click({ position: { x: 100, y: 50 } });
await statusHistoryUplot.hover({ position: { x: 300, y: 50 } });
await expect(tooltip, 'tooltip pinned on click').toBeVisible();
await expect(tooltip, 'tooltip displays the first value').toContainText('value5');
// unpin the tooltip, ensure it closes on hover away
await statusHistoryUplot.click({ position: { x: 300, y: 50 } });
await statusHistoryUplot.blur();
await expect(tooltip, 'tooltip closed after unpinning and hovering away').toBeHidden();
// test clicking the "x" as well
await statusHistoryUplot.click({ position: { x: 100, y: 50 } });
await expect(tooltip, 'tooltip appears on click').toBeVisible();
await dashboardPage.getByGrafanaSelector(selectors.components.Portal.container).getByLabel('Close').click();
await expect(tooltip, 'tooltip closed on "x" click').toBeHidden();
// disable tooltips
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Tooltip Tooltip mode'))
.getByLabel('Hidden')
.click();
await statusHistoryUplot.hover({ position: { x: 100, y: 50 } });
await expect(tooltip, 'tooltip is not shown when disabled').toBeHidden();
});
});

26
go.mod
View File

@@ -110,7 +110,6 @@ require (
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/tempo v1.5.1-0.20250529124718-87c2dc380cec // @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.2 // @grafana/grafana-backend-group
@@ -171,6 +170,7 @@ require (
github.com/spf13/pflag v1.0.10 // @grafana-app-platform-squad
github.com/spyzhov/ajson v0.9.6 // @grafana/grafana-sharing-squad
github.com/stretchr/testify v1.11.1 // @grafana/grafana-backend-group
github.com/testcontainers/testcontainers-go v0.36.0 //@grafana/grafana-app-platform-squad
github.com/thomaspoignant/go-feature-flag v1.42.0 // @grafana/grafana-backend-group
github.com/tjhop/slog-gokit v0.1.3 // @grafana/grafana-app-platform-squad
github.com/ua-parser/uap-go v0.0.0-20250213224047-9c035f085b90 // @grafana/grafana-backend-group
@@ -401,7 +401,7 @@ require (
github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.4.0+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect; @grafana/grafana-app-platform-squad
github.com/docker/go-units v0.5.0 // indirect
github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect
github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect
@@ -653,7 +653,15 @@ require (
sigs.k8s.io/yaml v1.6.0 // indirect
)
require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling
require (
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
@@ -663,6 +671,20 @@ require (
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // 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
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.8.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
)
// Use fork of crewjam/saml with fixes for some issues until changes get merged into upstream

46
go.sum
View File

@@ -645,6 +645,8 @@ gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGq
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/1NCE-GmbH/grpc-go-pool v0.0.0-20231117122434-2a5bb974daa2 h1:qFYgLH2zZe3WHpQgUrzeazC+ebDebwAQqS9yE1cP5Bs=
github.com/1NCE-GmbH/grpc-go-pool v0.0.0-20231117122434-2a5bb974daa2/go.mod h1:09/ALd1AXCTCOfcJYD8+jIYKmFmi6PVCkTsipC18F7E=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
github.com/Azure/azure-sdk-for-go v23.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
@@ -1051,6 +1053,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
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/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
@@ -1060,12 +1064,16 @@ github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03V
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cznic/b v0.0.0-20180115125044-35e9bbe41f07/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/fileutil v0.0.0-20180108211300-6a051e75936f/go.mod h1:8S58EK26zhXSxzv7NQFpnliaOQsmDUxvoQO3rt154Vg=
github.com/cznic/golex v0.0.0-20170803123110-4ab7c5e190e4/go.mod h1:+bmmJDNmKlhWNG+gwWCkaBoTy39Fs+bzRxVBzoTQbIc=
@@ -1132,6 +1140,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
@@ -1251,6 +1261,8 @@ 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-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI=
github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik=
github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik=
@@ -1942,6 +1954,8 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/linode/linodego v1.47.0 h1:6MFNCyzWbr8Rhl4r7d5DwZLwxvFIsM4ARH6W0KS/R0U=
github.com/linode/linodego v1.47.0/go.mod h1:vyklQRzZUWhFVBZdYx4dcYJU/gG9yKB9VUcUs6ub0Lk=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
@@ -1953,6 +1967,8 @@ github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -2066,12 +2082,20 @@ github.com/mithrandie/ternary v1.1.1 h1:k/joD6UGVYxHixYmSR8EGgDFNONBMqyD373xT4QR
github.com/mithrandie/ternary v1.1.1/go.mod h1:0D9Ba3+09K2TdSZO7/bFCC0GjSXetCvYuYq0u8FY/1g=
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/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
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.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
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/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
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/mocktools/go-smtp-mock/v2 v2.5.1 h1:QcMJMChSgG1olVj4o6xxQFdrWzRjYNrcq660HAjd0wA=
@@ -2218,6 +2242,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pressly/goose/v3 v3.25.0 h1:6WeYhMWGRCzpyd89SpODFnCBCKz41KrVbRT58nVjGng=
github.com/pressly/goose/v3 v3.25.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -2365,6 +2391,8 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shadowspore/fossil-delta v0.0.0-20241213113458-1d797d70cbe3 h1:/4/IJi5iyTdh6mqOUaASW148HQpujYiHl0Wl78dSOSc=
github.com/shadowspore/fossil-delta v0.0.0-20241213113458-1d797d70cbe3/go.mod h1:aJIMhRsunltJR926EB2MUg8qHemFQDreSB33pyto2Ps=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -2461,6 +2489,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/testcontainers/testcontainers-go v0.36.0 h1:YpffyLuHtdp5EUsI5mT4sRw8GZhO/5ozyDT1xWGXt00=
github.com/testcontainers/testcontainers-go v0.36.0/go.mod h1:yk73GVJ0KUZIHUtFna6MO7QS144qYpoY8lEEtU9Hed0=
github.com/thanos-io/objstore v0.0.0-20240818203309-0363dadfdfb1 h1:z0v9BB/p7s4J6R//+0a5M3wCld8KzNjrGRLIwXfrAZk=
github.com/thanos-io/objstore v0.0.0-20240818203309-0363dadfdfb1/go.mod h1:3ukSkG4rIRUGkKM4oIz+BSuUx2e3RlQVVv3Cc3W+Tv4=
github.com/thejerf/slogassert v0.3.4 h1:VoTsXixRbXMrRSSxDjYTiEDCM4VWbsYPW5rB/hX24kM=
@@ -2471,6 +2501,10 @@ github.com/tidwall/pretty v0.0.0-20180105212114-65a9db5fad51/go.mod h1:XNkn88O1C
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tjhop/slog-gokit v0.1.3 h1:6SdexP3UIeg93KLFeiM1Wp1caRwdTLgsD/THxBUy1+o=
github.com/tjhop/slog-gokit v0.1.3/go.mod h1:Bbu5v2748qpAWH7k6gse/kw3076IJf6owJmh7yArmJs=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc=
@@ -2547,6 +2581,8 @@ github.com/yuin/gopher-lua v0.0.0-20190206043414-8bfc7677f583/go.mod h1:gqRgreBU
github.com/yuin/gopher-lua v0.0.0-20191213034115-f46add6fdb5c/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk=
github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
@@ -2995,6 +3031,7 @@ golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -3027,6 +3064,7 @@ golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -3609,8 +3647,8 @@ 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 v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

File diff suppressed because it is too large Load Diff

View File

@@ -35,17 +35,20 @@ const sourceFiles = teamFiles.filter((file) => {
const ext = path.extname(file);
return (
['.ts', '.tsx', '.js', '.jsx'].includes(ext) &&
// exclude all tests
// exclude all tests and mocks
!path.matchesGlob(file, '**/test/**/*') &&
!file.includes('.test.') &&
!file.includes('.spec.') &&
!path.matchesGlob(file, '**/__mocks__/**/*') &&
// and storybook stories
!file.includes('.story.') &&
// and generated files
!file.includes('.gen.ts') &&
// and type definitions
!file.includes('.d.ts') &&
!file.endsWith('/types.ts')
!file.endsWith('/types.ts') &&
// and anything in graveyard
!path.matchesGlob(file, '**/graveyard/**/*')
);
});

View File

@@ -845,6 +845,7 @@ export type DashboardAccess = {
/** The permissions part */
canSave: boolean;
canStar: boolean;
isPublic: boolean;
/** Metadata fields */
slug?: string;
url?: string;

View File

@@ -729,6 +729,10 @@ export interface FeatureToggles {
*/
timeRangeProvider?: boolean;
/**
* Enables time range panning functionality
*/
timeRangePan?: boolean;
/**
* Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.
* @default false
*/

View File

@@ -27,6 +27,7 @@ const dashboardToAppPlatform = (dashboard: (typeof mockTree)[number]['item']) =>
},
status: {},
// TODO: Eventually add access properties, as required by tests
access: {},
};
};

View File

@@ -93,7 +93,7 @@ export const getCardContainerStyles = (
display: 'grid',
position: 'relative',
gridTemplateColumns: 'auto 1fr auto',
gridTemplateRows: '1fr auto auto auto',
gridTemplateRows: 'auto auto 1fr auto',
gridAutoColumns: '1fr',
gridAutoFlow: 'row',
gridTemplateAreas: `

View File

@@ -212,7 +212,7 @@ func (sk8s *shortURLK8sHandler) createKubernetesShortURLsHandler(c *contextmodel
c.Logger.Debug("Creating short URL", "path", cmd.Path)
obj := shorturl.LegacyCreateCommandToUnstructured(cmd)
obj.SetGenerateName("u") // becomes a prefix
obj.SetGenerateName("s") // becomes a prefix
out, err := client.Create(c.Req.Context(), &obj, v1.CreateOptions{})
if err != nil {

View File

@@ -5,7 +5,7 @@ go 1.25.3
// Override docker/docker to avoid:
// go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires
// github.com/docker/docker@v0.0.0-00010101000000-000000000000: invalid version: unknown revision 000000000000
replace github.com/docker/docker => github.com/moby/moby v27.5.1+incompatible
replace github.com/docker/docker => github.com/moby/moby v28.0.1+incompatible
require (
github.com/google/uuid v1.6.0 // indirect; @grafana/grafana-backend-group

View File

@@ -58,4 +58,5 @@ import (
_ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1"
_ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1"
_ "github.com/testcontainers/testcontainers-go"
)

View File

@@ -3,11 +3,22 @@ package middleware
import (
"strings"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
var (
hostRedirectCounter = promauto.NewCounter(prometheus.CounterOpts{
Name: "host_redirect_total",
Help: "Number of requests redirected due to host header mismatch",
Namespace: "grafana",
})
)
func ValidateHostHeader(cfg *setting.Cfg) web.Handler {
return func(c *contextmodel.ReqContext) {
// ignore local render calls
@@ -21,6 +32,8 @@ func ValidateHostHeader(cfg *setting.Cfg) web.Handler {
}
if !strings.EqualFold(h, cfg.Domain) {
hostRedirectCounter.Inc()
c.Logger.Info("Enforcing Host header", "hosted", c.Req.Host, "expected", cfg.Domain)
c.Redirect(strings.TrimSuffix(cfg.AppURL, "/")+c.Req.RequestURI, 301)
return
}

View File

@@ -53,6 +53,7 @@ import (
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/services/user"
@@ -112,6 +113,7 @@ type DashboardsAPIBuilder struct {
dualWriter dualwrite.Service
folderClientProvider client.K8sHandlerProvider
libraryPanels libraryelements.Service // for legacy library panels
publicDashboardService publicdashboards.Service
isStandalone bool // skips any handling including anything to do with legacy storage
}
@@ -140,6 +142,7 @@ func RegisterAPIService(
restConfigProvider apiserver.RestConfigProvider,
userService user.Service,
libraryPanels libraryelements.Service,
publicDashboardService publicdashboards.Service,
) *DashboardsAPIBuilder {
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
@@ -163,6 +166,7 @@ func RegisterAPIService(
dualWriter: dual,
folderClientProvider: newSimpleFolderClientProvider(folderClient),
libraryPanels: libraryPanels,
publicDashboardService: publicDashboardService,
legacy: &DashboardStorage{
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features),
@@ -652,6 +656,7 @@ func (b *DashboardsAPIBuilder) storageForVersion(
b.accessControl,
opts.Scheme,
newDTOFunc,
b.publicDashboardService,
)
if err != nil {
return err

View File

@@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
@@ -28,13 +29,14 @@ type dtoBuilder = func(dashboard runtime.Object, access *dashboard.DashboardAcce
// The DTO returns everything the UI needs in a single request
type DTOConnector struct {
getter rest.Getter
legacy legacy.DashboardAccess
unified resource.ResourceClient
largeObjects apistore.LargeObjectSupport
accessControl accesscontrol.AccessControl
scheme *runtime.Scheme
builder dtoBuilder
getter rest.Getter
legacy legacy.DashboardAccess
unified resource.ResourceClient
largeObjects apistore.LargeObjectSupport
accessControl accesscontrol.AccessControl
scheme *runtime.Scheme
builder dtoBuilder
publicDashboardService publicdashboards.Service
}
func NewDTOConnector(
@@ -45,15 +47,17 @@ func NewDTOConnector(
accessControl accesscontrol.AccessControl,
scheme *runtime.Scheme,
builder dtoBuilder,
publicDashboardService publicdashboards.Service,
) (rest.Storage, error) {
return &DTOConnector{
getter: getter,
legacy: legacyAccess,
accessControl: accessControl,
unified: resourceClient,
largeObjects: largeObjects,
builder: builder,
scheme: scheme,
getter: getter,
legacy: legacyAccess,
accessControl: accessControl,
unified: resourceClient,
largeObjects: largeObjects,
builder: builder,
scheme: scheme,
publicDashboardService: publicDashboardService,
}, nil
}
@@ -154,6 +158,11 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob
access.Slug = slugify.Slugify(title)
access.Url = dashboards.GetDashboardFolderURL(false, name, access.Slug)
pubDash, err := r.publicDashboardService.FindByDashboardUid(ctx, user.GetOrgID(), name)
if err == nil && pubDash != nil {
access.IsPublic = true
}
dash, err := r.builder(rawobj, access)
if err != nil {
responder.Error(err)

View File

@@ -146,7 +146,7 @@ func validateOnDelete(ctx context.Context,
for _, v := range resp.Stats {
if v.Count > 0 {
return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d resources", v.Count)
return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d %s.%s", v.Count, v.Group, v.Resource)
}
}
return nil

View File

@@ -86,6 +86,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.
"name", tb.Name,
"subject", tb.Spec.Subject.Name,
"teamRef", tb.Spec.TeamRef.Name,
"permission", tb.Spec.Permission,
"err", err,
)
status = "failure"
@@ -117,6 +118,7 @@ func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.
"name", tb.Name,
"subject", tb.Spec.Subject.Name,
"teamRef", tb.Spec.TeamRef.Name,
"permission", tb.Spec.Permission,
)
} else {
// Record successful tuple write
@@ -143,6 +145,20 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.
return nil, nil
}
if oldTB.Spec.Subject.Name == newTB.Spec.Subject.Name && oldTB.Spec.TeamRef.Name == newTB.Spec.TeamRef.Name && oldTB.Spec.Permission == newTB.Spec.Permission {
return nil, nil // No changes to the team binding
}
if newTB.Spec.Subject.Name == "" || newTB.Spec.TeamRef.Name == "" {
b.logger.Error("invalid team binding",
"namespace", newTB.Namespace,
"name", newTB.Name,
"subject", newTB.Spec.Subject.Name,
"teamRef", newTB.Spec.TeamRef.Name,
)
return nil, nil
}
// Convert old team binding to tuple for deletion
var oldTuple *v1.TupleKey
var oldErr error
@@ -154,6 +170,7 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.
"name", oldTB.Name,
"err", oldErr,
)
return nil, nil
}
}
@@ -168,18 +185,16 @@ func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.
"name", newTB.Name,
"err", newErr,
)
return nil, nil
}
}
// Return a finish function that performs the zanzana write only on success
return func(ctx context.Context, success bool) {
if !success {
// Update failed, don't write to zanzana
return
}
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues("teambinding", "update").Observe(time.Since(wait).Seconds())

View File

@@ -487,6 +487,223 @@ func TestBeginTeamBindingUpdate(t *testing.T) {
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when zClient is nil
})
t.Run("should handle empty old binding subject name gracefully", func(t *testing.T) {
wg.Add(1)
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-6",
Namespace: "org-6",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "", // Empty name - conversion will be skipped
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-6",
Namespace: "org-6",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
testEmptyOldBinding := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-6", req.Namespace)
// Should not delete old binding (it was skipped due to empty name)
require.Nil(t, req.Deletes)
// Should write new binding
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
require.Equal(
t,
req.Writes.TupleKeys[0],
&v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"},
)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testEmptyOldBinding}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc) // Should still return finish function
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should return nil finish func when bindings are identical", func(t *testing.T) {
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-7",
Namespace: "org-7",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-7",
Namespace: "org-7",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
writeCalled := false
testNoWriteOnNoChange := func(ctx context.Context, req *v1.WriteRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when bindings are identical")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnNoChange}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when bindings are identical
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when bindings are identical")
})
t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) {
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-8",
Namespace: "org-8",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-8",
Namespace: "org-8",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "", // Empty name - should cause early return
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
writeCalled := false
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when new binding has empty subject name")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name")
})
t.Run("should return nil finish func when new binding has empty team ref name", func(t *testing.T) {
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-9",
Namespace: "org-9",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-9",
Namespace: "org-9",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "", // Empty name - should cause early return
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
writeCalled := false
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when new binding has empty team ref name")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when new binding has empty team ref name
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when new binding has empty team ref name")
})
}
func TestAfterTeamBindingDelete(t *testing.T) {

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"sync"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -74,6 +75,11 @@ type jobDriver struct {
// notifications channel for job create events
notifications chan struct{}
// Mutex to protect concurrent access to job processing
mu sync.Mutex
// currentJob is the job currently being processed
currentJob *provisioning.Job
}
func NewJobDriver(
@@ -142,7 +148,7 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
logger := logging.FromContext(ctx)
// Claim a job to work on.
job, rollback, err := d.store.Claim(ctx)
claimedJob, rollback, err := d.store.Claim(ctx)
if err != nil {
return apifmt.Errorf("failed to claim job: %w", err)
}
@@ -150,14 +156,16 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
// The rollback function does not care about cancellations.
defer rollback()
logger = logger.With("job", job.GetName(), "namespace", job.GetNamespace())
namespace := claimedJob.GetNamespace()
logger = logger.With("job", claimedJob.GetName(), "namespace", namespace)
ctx = logging.Context(ctx, logger)
logger.Debug("claimed a job")
d.currentJob = claimedJob
// Now that we have a job, we need to augment our namespace to grant ourselves permission to work on it.
// Incidentally, this also limits our permissions to only the namespace of the job.
ctx = request.WithNamespace(ctx, job.GetNamespace())
ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
ctx = request.WithNamespace(ctx, namespace)
ctx, _, err = identity.WithProvisioningIdentity(ctx, namespace)
if err != nil {
return apifmt.Errorf("failed to grant provisioning identity: %w", err)
}
@@ -169,37 +177,42 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
leaseRenewalCtx, cancelLeaseRenewal := context.WithCancel(jobctx)
leaseExpired := make(chan struct{})
go d.leaseRenewalLoop(leaseRenewalCtx, job, logger, leaseExpired)
go d.leaseRenewalLoop(leaseRenewalCtx, logger, leaseExpired)
defer cancelLeaseRenewal()
recorder := newJobProgressRecorder(d.onProgress(job))
recorder := newJobProgressRecorder(d.onProgress())
recorder.SetMessage(ctx, "start job")
// Process the job with lease loss detection
start := time.Now()
job.Status.Started = start.UnixMilli()
err = d.processJobWithLeaseCheck(jobctx, job, recorder, leaseExpired)
err = d.processJobWithLeaseCheck(jobctx, recorder, leaseExpired)
end := time.Now()
logger.Debug("job processed", "duration", end.Sub(start), "error", err)
logger.Debug("job processed", "duration", end.Sub(recorder.Started()), "error", err)
// Capture job timeout
if jobctx.Err() != nil && err == nil {
err = jobctx.Err()
}
job.Status = recorder.Complete(ctx, err)
// Complete the job
d.mu.Lock()
d.currentJob.Status = recorder.Complete(ctx, err)
defer func() {
d.currentJob = nil
d.mu.Unlock()
}()
// Save the finished job
err = d.historicJobs.WriteJob(ctx, job.DeepCopy())
err = d.historicJobs.WriteJob(ctx, d.currentJob.DeepCopy())
if err != nil {
// We're not going to return this as it is not critical. Not ideal, but not critical.
logger.Warn("failed to create historic job", "historic_job", *job, "error", err)
logger.Warn("failed to create historic job", "historic_job", *d.currentJob, "error", err)
} else {
logger.Debug("created historic job", "historic_job", *job)
logger.Debug("created historic job", "historic_job", *d.currentJob)
}
// Mark the job as completed.
if err := d.store.Complete(ctx, job); err != nil {
return apifmt.Errorf("failed to complete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
if err := d.store.Complete(ctx, d.currentJob); err != nil {
return apifmt.Errorf("failed to complete job '%s' in '%s': %w", d.currentJob.GetName(), d.currentJob.GetNamespace(), err)
}
logger.Debug("job completed")
@@ -208,7 +221,7 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
// leaseRenewalLoop continuously renews the lease for a job until the context is cancelled.
// If lease renewal fails persistently, it signals via the leaseExpired channel.
func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job, logger logging.Logger, leaseExpired chan struct{}) {
func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, leaseExpired chan struct{}) {
ticker := time.NewTicker(d.leaseRenewalInterval)
defer ticker.Stop()
@@ -223,7 +236,15 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job,
logger.Debug("lease renewal loop stopping")
return
case <-ticker.C:
err := d.store.RenewLease(ctx, job)
d.mu.Lock()
if d.currentJob == nil {
d.mu.Unlock()
return
}
err := d.store.RenewLease(ctx, d.currentJob)
d.mu.Unlock()
if err != nil {
consecutiveFailures++
if apierrors.IsNotFound(err) ||
@@ -253,11 +274,11 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job,
}
// processJobWithLeaseCheck processes a job but aborts if the lease expires.
func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, job *provisioning.Job, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error {
func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error {
// Run the job processing in a goroutine so we can monitor lease expiry
resultChan := make(chan error, 1)
go func() {
resultChan <- d.processJob(ctx, job, recorder)
resultChan <- d.processJob(ctx, recorder)
}()
select {
@@ -270,16 +291,28 @@ func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, job *provision
}
}
func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job, recorder JobProgressRecorder) error {
func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder) error {
logger := logging.FromContext(ctx)
d.mu.Lock()
if d.currentJob == nil {
d.mu.Unlock()
return nil
}
// Here it's safe to copy as only job spec is used for processing
job := d.currentJob.DeepCopy()
repoName := d.currentJob.Spec.Repository
namespace := d.currentJob.Namespace
d.mu.Unlock()
for _, worker := range d.workers {
if !worker.IsSupported(ctx, *job) {
continue
}
repo, err := d.repoGetter.GetRepository(ctx, job.Namespace, job.Spec.Repository)
repo, err := d.repoGetter.GetRepository(ctx, namespace, repoName)
if err != nil {
return apifmt.Errorf("failed to get repository '%s': %w", job.Spec.Repository, err)
return apifmt.Errorf("failed to get repository '%s': %w", repoName, err)
}
r := repo.Config()
@@ -298,42 +331,51 @@ func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job, recor
return apifmt.Errorf("no workers were registered to handle the job")
}
func (d *jobDriver) onProgress(job *provisioning.Job) ProgressFn {
func (d *jobDriver) onProgress() ProgressFn {
return func(ctx context.Context, status provisioning.JobStatus) error {
logging.FromContext(ctx).Debug("job progress", "status", status)
const maxRetries = 3
for attempt := 0; attempt < maxRetries; attempt++ {
// Use the current job for the first attempt, fetch fresh for retries
currentJob := job
d.mu.Lock()
if d.currentJob == nil {
d.mu.Unlock()
return nil
}
// Use the current job for the first attempt; on retry attempts, fetch fresh data from the store to resolve conflicts
if attempt > 0 {
// Fetch the latest version to resolve conflicts
latest, err := d.store.Get(ctx, job.GetNamespace(), job.GetName())
latest, err := d.store.Get(ctx, d.currentJob.GetNamespace(), d.currentJob.GetName())
if err != nil {
d.mu.Unlock()
if apierrors.IsNotFound(err) {
// Job was completed/deleted, nothing to update
return nil
}
return apifmt.Errorf("failed to fetch job for progress update: %w", err)
}
currentJob = latest
*d.currentJob = *latest
}
job := d.currentJob
// Update status on the current job
currentJob.Status = status
updated, err := d.store.Update(ctx, currentJob)
job.Status = status
updated, err := d.store.Update(ctx, job)
if err != nil {
if apierrors.IsConflict(err) && attempt < maxRetries-1 {
// Conflict detected, retry with fresh data
logging.FromContext(ctx).Debug("progress update conflict, retrying", "attempt", attempt+1)
continue
}
d.mu.Unlock()
return apifmt.Errorf("failed to update job progress: %w", err)
}
// Update succeeded, update our local copy
*job = *updated
*d.currentJob = *updated
d.mu.Unlock()
return nil
}

View File

@@ -1,12 +1,14 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package jobs
import (
context "context"
time "time"
mock "github.com/stretchr/testify/mock"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockJobProgressRecorder is an autogenerated mock type for the JobProgressRecorder type
@@ -271,6 +273,51 @@ func (_c *MockJobProgressRecorder_SetTotal_Call) RunAndReturn(run func(context.C
return _c
}
// Started provides a mock function with no fields
func (_m *MockJobProgressRecorder) Started() time.Time {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Started")
}
var r0 time.Time
if rf, ok := ret.Get(0).(func() time.Time); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(time.Time)
}
return r0
}
// MockJobProgressRecorder_Started_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Started'
type MockJobProgressRecorder_Started_Call struct {
*mock.Call
}
// Started is a helper method to define mock.On call
func (_e *MockJobProgressRecorder_Expecter) Started() *MockJobProgressRecorder_Started_Call {
return &MockJobProgressRecorder_Started_Call{Call: _e.mock.On("Started")}
}
func (_c *MockJobProgressRecorder_Started_Call) Run(run func()) *MockJobProgressRecorder_Started_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockJobProgressRecorder_Started_Call) Return(_a0 time.Time) *MockJobProgressRecorder_Started_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockJobProgressRecorder_Started_Call) RunAndReturn(run func() time.Time) *MockJobProgressRecorder_Started_Call {
_c.Call.Return(run)
return _c
}
// StrictMaxErrors provides a mock function with given fields: maxErrors
func (_m *MockJobProgressRecorder) StrictMaxErrors(maxErrors int) {
_m.Called(maxErrors)

View File

@@ -69,6 +69,10 @@ func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder {
}
}
func (r *jobProgressRecorder) Started() time.Time {
return r.started
}
func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResult) {
var shouldLogError bool
var logErr error

View File

@@ -2,6 +2,7 @@ package jobs
import (
"context"
"time"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
@@ -18,6 +19,7 @@ type RepoGetter interface {
//
//go:generate mockery --name JobProgressRecorder --structname MockJobProgressRecorder --inpackage --filename job_progress_recorder_mock.go --with-expecter
type JobProgressRecorder interface {
Started() time.Time
Record(ctx context.Context, result JobResourceResult)
ResetResults()
SetFinalMessage(ctx context.Context, msg string)

View File

@@ -40,8 +40,6 @@ var expectedHeaders = map[string]string{
strings.ToLower(queryService.HeaderPanelPluginId): queryService.HeaderPanelPluginId,
strings.ToLower(queryService.HeaderDashboardTitle): queryService.HeaderDashboardTitle,
strings.ToLower(queryService.HeaderPanelTitle): queryService.HeaderPanelTitle,
strings.ToLower("X-Real-IP"): "X-Real-IP",
strings.ToLower("X-Forwarded-For"): "X-Forwarded-For",
}
func ExtractKnownHeaders(header http.Header) map[string]string {

View File

@@ -840,7 +840,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService)
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl)
snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer)
dataSourceAPIBuilder, err := datasource.RegisterAPIService(configProvider, featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer)
if err != nil {
@@ -1474,7 +1474,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
identitySynchronizer := authnimpl.ProvideIdentitySynchronizer(authnimplService)
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService)
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl)
snapshotsAPIBuilder := dashboardsnapshot.RegisterAPIService(serviceImpl, apiserverService, cfg, featureToggles, sqlStore, registerer)
dataSourceAPIBuilder, err := datasource.RegisterAPIService(configProvider, featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer)
if err != nil {

View File

@@ -203,6 +203,14 @@ func createPostStartHook(
logger.Error("Failed to initialize app", "app", installer.ManifestData().AppName, "error", err)
return fmt.Errorf("failed to get app from installer %s: %w", installer.ManifestData().AppName, err)
}
return app.Runner().Run(hookContext.Context)
go func() {
err := app.Runner().Run(hookContext.Context)
if err != nil {
logger.Error("App runner exited with error", "app", installer.ManifestData().AppName, "error", err)
} else {
logger.Info("App runner exited without error", "app", installer.ManifestData().AppName)
}
}()
return nil
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@ message MutateOperation {
DeletePermissionOperation delete_permission = 4;
UpdateUserOrgRoleOperation update_user_org_role = 5;
DeleteUserOrgRoleOperation delete_user_org_role = 6;
AddUserOrgRoleOperation add_user_org_role = 7;
}
}
@@ -63,6 +64,14 @@ message DeletePermissionOperation {
Permission permission = 2;
}
message AddUserOrgRoleOperation {
// User UID
string user = 1;
// Role name (e.g: "Admin", "Editor", "Viewer")
string role = 2;
}
// UpdateUserOrgRoleOperation assigns the user's basic role and deletes existing basic role assignments.
message UpdateUserOrgRoleOperation {
// User UID
string user = 1;

View File

@@ -110,7 +110,7 @@ func ProvideStandaloneZanzanaClient(cfg *setting.Cfg, features featuremgmt.Featu
ServerCertFile: cfg.ZanzanaClient.ServerCertFile,
}
return NewRemoteZanzanaClient(fmt.Sprintf("stacks-%s", cfg.StackID), zanzanaConfig)
return NewRemoteZanzanaClient(cfg.ZanzanaClient.TokenNamespace, zanzanaConfig)
}
type ZanzanaClientConfig struct {

View File

@@ -1,6 +1,8 @@
package common
import (
"slices"
authlib "github.com/grafana/authlib/types"
dashboards "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
@@ -23,6 +25,14 @@ var basicRolesTranslations = map[string]string{
roleNone: "basic_none",
}
var basicRolesUIDs = []string{
"basic_grafana_admin",
"basic_admin",
"basic_editor",
"basic_viewer",
"basic_none",
}
type resourceTranslation struct {
typ string
group string
@@ -149,3 +159,7 @@ func TranslateToGroupResource(kind string) string {
func TranslateBasicRole(name string) string {
return basicRolesTranslations[name]
}
func IsBasicRole(name string) bool {
return slices.Contains(basicRolesUIDs, name)
}

View File

@@ -465,3 +465,21 @@ func AddRenderContext(req *openfgav1.CheckRequest) {
),
})
}
func SplitTupleObject(object string) (string, string, string) {
var objectType, name, relation string
parts := strings.Split(object, ":")
if len(parts) < 2 {
return "", "", ""
}
objectType = parts[0]
nameRel := parts[1]
parts = strings.Split(nameRel, "#")
if len(parts) > 1 {
relation = parts[1]
}
name = parts[0]
return objectType, name, relation
}

View File

@@ -26,7 +26,7 @@ func authorize(ctx context.Context, namespace string, ss setting.ZanzanaServerSe
return status.Errorf(codes.Unauthenticated, "unauthenticated")
}
if !claims.NamespaceMatches(c.GetNamespace(), namespace) {
return status.Errorf(codes.PermissionDenied, "namespace does not match")
return status.Errorf(codes.PermissionDenied, "token namespace %s does not match request namespace", c.GetNamespace())
}
return nil
}

View File

@@ -77,7 +77,7 @@ func getOperationGroup(operation *authzextv1.MutateOperation) (OperationGroup, e
return OperationGroupFolder, nil
case *authzextv1.MutateOperation_CreatePermission, *authzextv1.MutateOperation_DeletePermission:
return OperationGroupPermission, nil
case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole:
case *authzextv1.MutateOperation_UpdateUserOrgRole, *authzextv1.MutateOperation_DeleteUserOrgRole, *authzextv1.MutateOperation_AddUserOrgRole:
return OperationGroupUserOrgRole, nil
}
return OperationGroup(""), errors.New("unsupported mutate operation type")

View File

@@ -18,18 +18,29 @@ func (s *Server) mutateOrgRoles(ctx context.Context, store *storeInfo, operation
for _, operation := range operations {
switch op := operation.Operation.(type) {
case *authzextv1.MutateOperation_UpdateUserOrgRole:
tuple, err := s.getUserOrgRoleWriteTuple(ctx, store, op.UpdateUserOrgRole)
if err != nil {
return err
case *authzextv1.MutateOperation_AddUserOrgRole:
basicRole := zanzana.TranslateBasicRole(op.AddUserOrgRole.GetRole())
tuple := &openfgav1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, op.AddUserOrgRole.GetUser(), ""),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""),
}
writeTuples = append(writeTuples, tuple)
case *authzextv1.MutateOperation_DeleteUserOrgRole:
tuple, err := s.getUserOrgRoleDeleteTuple(ctx, store, op.DeleteUserOrgRole)
basicRole := zanzana.TranslateBasicRole(op.DeleteUserOrgRole.GetRole())
tuple := &openfgav1.TupleKeyWithoutCondition{
User: zanzana.NewTupleEntry(zanzana.TypeUser, op.DeleteUserOrgRole.GetUser(), ""),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""),
}
deleteTuples = append(deleteTuples, tuple)
case *authzextv1.MutateOperation_UpdateUserOrgRole:
writeTuple, existingTuples, err := s.getUserOrgRoleUpdateTuples(ctx, store, op.UpdateUserOrgRole)
if err != nil {
return err
}
deleteTuples = append(deleteTuples, tuple)
writeTuples = append(writeTuples, writeTuple)
deleteTuples = append(deleteTuples, existingTuples...)
default:
s.logger.Debug("unsupported mutate operation", "operation", op)
}
@@ -65,20 +76,38 @@ func (s *Server) mutateOrgRoles(ctx context.Context, store *storeInfo, operation
return nil
}
func (s *Server) getUserOrgRoleWriteTuple(ctx context.Context, store *storeInfo, req *authzextv1.UpdateUserOrgRoleOperation) (*openfgav1.TupleKey, error) {
basicRole := zanzana.TranslateBasicRole(req.GetRole())
return &openfgav1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""),
}, nil
}
func (s *Server) getUserOrgRoleUpdateTuples(ctx context.Context, store *storeInfo, req *authzextv1.UpdateUserOrgRoleOperation) (*openfgav1.TupleKey, []*openfgav1.TupleKeyWithoutCondition, error) {
readReq := &openfgav1.ReadRequest{
StoreId: store.ID,
TupleKey: &openfgav1.ReadRequestTupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""),
Relation: zanzana.RelationAssignee,
// read tuples by object type ("role:")
Object: zanzana.NewTupleEntry(zanzana.TypeRole, "", ""),
},
}
res, err := s.openfga.Read(ctx, readReq)
if err != nil {
return nil, nil, err
}
existingBasicRoleTuples := make([]*openfgav1.TupleKeyWithoutCondition, 0)
for _, tuple := range res.GetTuples() {
_, roleName, _ := zanzana.SplitTupleObject(tuple.GetKey().GetObject())
if zanzana.IsBasicRole(roleName) {
existingBasicRoleTuples = append(existingBasicRoleTuples, &openfgav1.TupleKeyWithoutCondition{
User: tuple.GetKey().GetUser(),
Relation: tuple.GetKey().GetRelation(),
Object: tuple.GetKey().GetObject(),
})
}
}
func (s *Server) getUserOrgRoleDeleteTuple(ctx context.Context, store *storeInfo, req *authzextv1.DeleteUserOrgRoleOperation) (*openfgav1.TupleKeyWithoutCondition, error) {
basicRole := zanzana.TranslateBasicRole(req.GetRole())
return &openfgav1.TupleKeyWithoutCondition{
writeTuple := &openfgav1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, req.GetUser(), ""),
Relation: zanzana.RelationAssignee,
Object: zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, ""),
}, nil
}
return writeTuple, existingBasicRoleTuples, nil
}

View File

@@ -36,14 +36,6 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
},
},
},
{
Operation: &v1.MutateOperation_DeleteUserOrgRole{
DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{
User: "1",
Role: "Editor",
},
},
},
},
})
require.NoError(t, err)
@@ -69,4 +61,50 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
})
t.Run("should add user org role and delete old role", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_AddUserOrgRole{
AddUserOrgRole: &v1.AddUserOrgRoleOperation{
User: "1",
Role: "Viewer",
},
},
},
{
Operation: &v1.MutateOperation_DeleteUserOrgRole{
DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{
User: "1",
Role: "Admin",
},
},
},
},
})
require.NoError(t, err)
res, err := srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationAssignee,
Object: "role:basic_admin",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
res, err = srv.Read(newContextWithNamespace(), &v1.ReadRequest{
Namespace: "default",
TupleKey: &v1.ReadRequestTupleKey{
Relation: common.RelationAssignee,
Object: "role:basic_viewer",
},
})
require.NoError(t, err)
require.Len(t, res.Tuples, 1)
require.Equal(t, "user:1", res.Tuples[0].Key.User)
})
}

View File

@@ -1254,6 +1254,13 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
},
{
Name: "timeRangePan",
Description: "Enables time range panning functionality",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
},
{
Name: "azureMonitorDisableLogLimit",
Description: "Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.",

View File

@@ -164,6 +164,7 @@ managedDualWriter,experimental,@grafana/search-and-storage,false,false,false
pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
timeRangePan,experimental,@grafana/dataviz-squad,false,false,true
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
preinstallAutoUpdate,GA,@grafana/plugins-platform-backend,false,false,false
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
164 pluginsSriChecks GA @grafana/plugins-platform-backend false false false
165 unifiedStorageBigObjectsSupport experimental @grafana/search-and-storage false false false
166 timeRangeProvider experimental @grafana/grafana-frontend-platform false false false
167 timeRangePan experimental @grafana/dataviz-squad false false true
168 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
169 preinstallAutoUpdate GA @grafana/plugins-platform-backend false false false
170 playlistsReconciler experimental @grafana/grafana-app-platform-squad false true false

View File

@@ -667,6 +667,10 @@ const (
// Enables time pickers sync
FlagTimeRangeProvider = "timeRangeProvider"
// FlagTimeRangePan
// Enables time range panning functionality
FlagTimeRangePan = "timeRangePan"
// FlagAzureMonitorDisableLogLimit
// Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.
FlagAzureMonitorDisableLogLimit = "azureMonitorDisableLogLimit"

View File

@@ -3915,6 +3915,22 @@
"frontend": true
}
},
{
"metadata": {
"name": "timeRangePan",
"resourceVersion": "1762290731154",
"creationTimestamp": "2025-10-24T19:49:53Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-04 21:12:11.154822 +0000 UTC"
}
},
"spec": {
"description": "Enables time range panning functionality",
"stage": "experimental",
"codeowner": "@grafana/dataviz-squad",
"frontend": true
}
},
{
"metadata": {
"name": "timeRangeProvider",

View File

@@ -1460,6 +1460,148 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) {
})
}
// To address issues arising from case-insensitive collations in some databases (e.g., MySQL/MariaDB),
func TestIntegrationListAlertRulesByGroupCaseSensitiveOrdering(t *testing.T) {
tutil.SkipIntegrationTestInShortMode(t)
usr := models.UserUID("test")
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
b := &fakeBus{}
logger := log.New("test-dbstore")
store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b)
store.FeatureToggles = featuremgmt.WithFeatures()
gen := models.RuleGen.With(models.RuleMuts.WithOrgID(1))
// Create namespace and base group key
groupKey := models.GenerateGroupKey(1)
// Create groups with case-sensitive names: "TEST", "Test", "test"
groupKeyUpper := groupKey
groupKeyUpper.RuleGroup = "TEST"
groupKeyMixed := groupKey
groupKeyMixed.RuleGroup = "Test"
groupKeyLower := groupKey
groupKeyLower.RuleGroup = "test"
// Generate rules for each group
groupUpper := gen.With(gen.WithGroupKey(groupKeyUpper)).GenerateMany(2)
groupMixed := gen.With(gen.WithGroupKey(groupKeyMixed)).GenerateMany(2)
groupLower := gen.With(gen.WithGroupKey(groupKeyLower)).GenerateMany(2)
// Insert all rules
allRules := append(append(groupUpper, groupMixed...), groupLower...)
_, err := store.InsertAlertRules(context.Background(), &usr, allRules)
require.NoError(t, err)
t.Run("should order groups case-sensitively", func(t *testing.T) {
result, _, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: 1},
})
require.NoError(t, err)
require.Len(t, result, 6, "should return all 6 rules")
// Extract group names in order
var groupOrder []string
for _, rule := range result {
if len(groupOrder) == 0 || groupOrder[len(groupOrder)-1] != rule.RuleGroup {
groupOrder = append(groupOrder, rule.RuleGroup)
}
}
// Verify case-sensitive alphabetical ordering
// different databases may sort uppercase before lowercase or vice versa depending on character set, the important part is that the order is consistent and case-sensitive
expectedOrder := []string{"test", "Test", "TEST"}
alternateExpectedOrder := []string{"TEST", "Test", "test"}
if !slices.Equal(groupOrder, expectedOrder) && !slices.Equal(groupOrder, alternateExpectedOrder) {
t.Fatalf("groups are not ordered case-sensitively as expected. got: %v, want: %v or %v", groupOrder, expectedOrder, alternateExpectedOrder)
}
// Verify each group contains the correct rules
groupRules := make(map[string][]*models.AlertRule)
for _, rule := range result {
groupRules[rule.RuleGroup] = append(groupRules[rule.RuleGroup], rule)
}
require.Len(t, groupRules["TEST"], 2, "TEST group should have 2 rules")
require.Len(t, groupRules["Test"], 2, "Test group should have 2 rules")
require.Len(t, groupRules["test"], 2, "test group should have 2 rules")
})
t.Run("should respect group limit with case-sensitive ordering", func(t *testing.T) {
// Test with limit of 2 groups - should get first 2 groups in case-sensitive order
result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: 1},
Limit: 2,
})
require.NoError(t, err)
require.Len(t, result, 4, "should return 4 rules (2 rules from first 2 groups)")
require.NotEmpty(t, continueToken, "should have continue token when limit is reached")
// Extract group names from limited result
var limitedGroupOrder []string
for _, rule := range result {
if len(limitedGroupOrder) == 0 || limitedGroupOrder[len(limitedGroupOrder)-1] != rule.RuleGroup {
limitedGroupOrder = append(limitedGroupOrder, rule.RuleGroup)
}
}
// Should get first 2 groups in case-sensitive order: "TEST", "Test" or "test", "Test"
expectedLimitedOrder := []string{"TEST", "Test"}
alternateExpectedOrder := []string{"test", "Test"}
matchesDescLexOrder := slices.Equal(limitedGroupOrder, expectedLimitedOrder)
matchesAscLexOrder := slices.Equal(limitedGroupOrder, alternateExpectedOrder)
if !matchesDescLexOrder && !matchesAscLexOrder {
t.Fatalf("limited groups are not ordered case-sensitively as expected. got: %v, want: %v or %v", limitedGroupOrder, expectedLimitedOrder, alternateExpectedOrder)
}
// Continue from token to get remaining groups
remainingResult, nextToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: 1},
ContinueToken: continueToken,
})
require.NoError(t, err)
require.Len(t, remainingResult, 2, "should return 2 rules from remaining group")
require.Empty(t, nextToken, "should not have continue token when all groups are fetched")
lastGroup := "test"
if matchesAscLexOrder {
lastGroup = "TEST"
}
// Verify the remaining group is "test"
for _, rule := range remainingResult {
require.Equal(t, lastGroup, rule.RuleGroup, "remaining group should be 'test'")
}
})
t.Run("should handle group limit of 1 correctly", func(t *testing.T) {
result, continueToken, err := store.ListAlertRulesByGroup(context.Background(), &models.ListAlertRulesExtendedQuery{
ListAlertRulesQuery: models.ListAlertRulesQuery{OrgID: 1},
Limit: 1,
})
require.NoError(t, err)
require.Len(t, result, 2, "should return 2 rules from first group")
require.NotEmpty(t, continueToken, "should have continue token")
// Should only get the first group which can be "TEST" or "test" depending on charset
expectedGroup := "TEST"
if result[0].RuleGroup == "test" {
expectedGroup = "test"
}
for _, rule := range result {
require.Equal(t, expectedGroup, rule.RuleGroup, "all rules should be from the first group")
}
})
}
func TestIntegrationIncreaseVersionForAllRulesInNamespaces(t *testing.T) {
tutil.SkipIntegrationTestInShortMode(t)

View File

@@ -156,4 +156,8 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
ualert.DropTitleUniqueIndexMigration(mg)
ualert.AddStateFiredAtColumn(mg)
ualert.CollateAlertRuleGroup(mg)
ualert.AddAlertRuleGroupIndexMigration(mg)
}

View File

@@ -0,0 +1,9 @@
package ualert
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
// CollateAlertRuleGroup ensures that rule_group column collates.
func CollateAlertRuleGroup(mg *migrator.Migrator) {
mg.AddMigration("ensure rule_group column is case sensitive in returned results", migrator.NewRawSQLMigration("").
Mysql("ALTER TABLE alert_rule MODIFY rule_group VARCHAR(190) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL;"))
}

View File

@@ -0,0 +1,14 @@
package ualert
import "github.com/grafana/grafana/pkg/services/sqlstore/migrator"
// AddAlertRuleGroupIndexMigration adds an index on org_id, namespace_uid, rule_group, and rule_group_idx columns to alert_rule table.
func AddAlertRuleGroupIndexMigration(mg *migrator.Migrator) {
mg.AddMigration("add index in alert_rule on org_id, namespace_uid, rule_group and rule_group_idx columns", migrator.NewAddIndexMigration(
migrator.Table{Name: "alert_rule"},
&migrator.Index{
Name: "IDX_alert_rule_org_id_namespace_uid_rule_group_rule_group_idx",
Cols: []string{"org_id", "namespace_uid", "rule_group", "rule_group_idx"},
},
))
}

View File

@@ -27,6 +27,8 @@ type ZanzanaClientSettings struct {
// URL called to perform exchange request.
// Only used when mode is set to client.
TokenExchangeURL string
// Namespace to use for the token.
TokenNamespace string
}
type ZanzanaServerSettings struct {
@@ -113,6 +115,10 @@ func (cfg *Cfg) readZanzanaSettings() {
zc.Addr = clientSec.Key("address").MustString("")
zc.ServerCertFile = clientSec.Key("tls_cert").MustString("")
// TODO: read Token and TokenExchangeURL from grpc_client_authentication section
grpcClientAuthSection := cfg.SectionWithEnvOverrides("grpc_client_authentication")
zc.TokenNamespace = grpcClientAuthSection.Key("token_namespace").MustString("stacks-" + cfg.StackID)
cfg.ZanzanaClient = zc
zs := ZanzanaServerSettings{}

View File

@@ -3,13 +3,11 @@
package apistore
import (
"context"
"os"
"path/filepath"
"time"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/memblob"
badger "github.com/dgraph-io/badger/v4"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/generic"
@@ -53,18 +51,30 @@ func NewRESTOptionsGetterForClient(
}
func NewRESTOptionsGetterMemory(originalStorageConfig storagebackend.Config, secrets secret.InlineSecureValueSupport) (*RESTOptionsGetter, error) {
backend, err := resource.NewCDKBackend(context.Background(), resource.CDKBackendOptions{
Bucket: memblob.OpenBucket(&memblob.Options{}),
// Create BadgerDB with in-memory mode
db, err := badger.Open(badger.DefaultOptions("").
WithInMemory(true).
WithLogger(nil))
if err != nil {
return nil, err
}
kv := resource.NewBadgerKV(db)
backend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{
KvStore: kv,
WithExperimentalClusterScope: true,
})
if err != nil {
return nil, err
}
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
Backend: backend,
})
if err != nil {
return nil, err
}
return NewRESTOptionsGetterForClient(
resource.NewLocalResourceClient(server),
secrets,
@@ -83,25 +93,27 @@ func NewRESTOptionsGetterForFileXX(path string,
path = filepath.Join(os.TempDir(), "grafana-apiserver")
}
bucket, err := fileblob.OpenBucket(filepath.Join(path, "resource"), &fileblob.Options{
CreateDir: true,
Metadata: fileblob.MetadataDontWrite, // skip
})
if err != nil {
return nil, err
}
backend, err := resource.NewCDKBackend(context.Background(), resource.CDKBackendOptions{
Bucket: bucket,
db, err := badger.Open(badger.DefaultOptions(filepath.Join(path, "badger")).
WithLogger(nil))
if err != nil {
return nil, err
}
kv := resource.NewBadgerKV(db)
backend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{
KvStore: kv,
})
if err != nil {
return nil, err
}
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
Backend: backend,
})
if err != nil {
return nil, err
}
return NewRESTOptionsGetterForClient(
resource.NewLocalResourceClient(server),
nil, // secrets

View File

@@ -8,15 +8,13 @@ package apistore_test
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
badger "github.com/dgraph-io/badger/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/memblob"
"k8s.io/apimachinery/pkg/api/apitesting"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
@@ -105,24 +103,20 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte
Resource: "pods",
}
bucket := memblob.OpenBucket(nil)
if true {
tmp, err := os.MkdirTemp("", "xxx-*")
require.NoError(t, err)
bucket, err = fileblob.OpenBucket(tmp, &fileblob.Options{
CreateDir: true,
Metadata: fileblob.MetadataDontWrite, // skip
})
require.NoError(t, err)
}
ctx := storagetesting.NewContext()
var server resource.ResourceServer
switch setupOpts.storageType {
case StorageTypeFile:
backend, err := resource.NewCDKBackend(ctx, resource.CDKBackendOptions{
Bucket: bucket,
// Create in-memory BadgerDB for testing
db, err := badger.Open(badger.DefaultOptions("").
WithInMemory(true).
WithLogger(nil))
require.NoError(t, err)
kv := resource.NewBadgerKV(db)
backend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{
KvStore: kv,
})
require.NoError(t, err)

View File

@@ -6,12 +6,12 @@ import (
"path/filepath"
"time"
badger "github.com/dgraph-io/badger/v4"
otgrpc "github.com/opentracing-contrib/go-grpc"
"github.com/opentracing/opentracing-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"gocloud.dev/blob/fileblob"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/keepalive"
@@ -111,19 +111,22 @@ func newClient(opts options.StorageOptions,
if opts.DataPath == "" {
opts.DataPath = filepath.Join(cfg.DataPath, "grafana-apiserver")
}
bucket, err := fileblob.OpenBucket(filepath.Join(opts.DataPath, "resource"), &fileblob.Options{
CreateDir: true,
Metadata: fileblob.MetadataDontWrite, // skip
})
if err != nil {
return nil, err
}
backend, err := resource.NewCDKBackend(ctx, resource.CDKBackendOptions{
Bucket: bucket,
// Create BadgerDB instance
db, err := badger.Open(badger.DefaultOptions(filepath.Join(opts.DataPath, "badger")).
WithLogger(nil))
if err != nil {
return nil, err
}
kv := resource.NewBadgerKV(db)
backend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{
KvStore: kv,
})
if err != nil {
return nil, err
}
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
Backend: backend,
Blob: resource.BlobConfig{

View File

@@ -1,418 +0,0 @@
package resource
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"iter"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
"gocloud.dev/blob"
_ "gocloud.dev/blob/fileblob"
_ "gocloud.dev/blob/memblob"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
type CDKBackendOptions struct {
Tracer trace.Tracer
Bucket CDKBucket
RootFolder string
}
func NewCDKBackend(ctx context.Context, opts CDKBackendOptions) (StorageBackend, error) {
if opts.Tracer == nil {
opts.Tracer = noop.NewTracerProvider().Tracer("cdk-appending-store")
}
if opts.Bucket == nil {
return nil, fmt.Errorf("missing bucket")
}
found, _, err := opts.Bucket.ListPage(ctx, blob.FirstPageToken, 1, &blob.ListOptions{
Prefix: opts.RootFolder,
Delimiter: "/",
})
if err != nil {
return nil, err
}
if found == nil {
return nil, fmt.Errorf("the root folder does not exist")
}
backend := &cdkBackend{
tracer: opts.Tracer,
bucket: opts.Bucket,
root: opts.RootFolder,
}
backend.rv.Swap(time.Now().UnixMilli())
return backend, nil
}
type cdkBackend struct {
tracer trace.Tracer
bucket CDKBucket
root string
mutex sync.Mutex
rv atomic.Int64
// Simple watch stream -- NOTE, this only works for single tenant!
broadcaster Broadcaster[*WrittenEvent]
stream chan<- *WrittenEvent
}
func (s *cdkBackend) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[ResourceLastImportTime, error] {
return func(yield func(ResourceLastImportTime, error) bool) {
yield(ResourceLastImportTime{}, errors.New("not implemented"))
}
}
func (s *cdkBackend) ListModifiedSince(ctx context.Context, key NamespacedResource, sinceRv int64) (int64, iter.Seq2[*ModifiedResource, error]) {
return 0, func(yield func(*ModifiedResource, error) bool) {
yield(nil, errors.New("not implemented"))
}
}
func (s *cdkBackend) getPath(key *resourcepb.ResourceKey, rv int64) string {
var buffer bytes.Buffer
buffer.WriteString(s.root)
if key.Group == "" {
return buffer.String()
}
buffer.WriteString(key.Group)
if key.Resource == "" {
return buffer.String()
}
buffer.WriteString("/")
buffer.WriteString(key.Resource)
if key.Namespace == "" {
if key.Name == "" {
return buffer.String()
}
buffer.WriteString("/__cluster__")
} else {
buffer.WriteString("/")
buffer.WriteString(key.Namespace)
}
if key.Name == "" {
return buffer.String()
}
buffer.WriteString("/")
buffer.WriteString(key.Name)
if rv > 0 {
buffer.WriteString(fmt.Sprintf("/%d.json", rv))
}
return buffer.String()
}
// GetResourceStats implements Backend.
func (s *cdkBackend) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]ResourceStats, error) {
return nil, fmt.Errorf("not implemented")
}
func (s *cdkBackend) WriteEvent(ctx context.Context, event WriteEvent) (rv int64, err error) {
if event.Type == resourcepb.WatchEvent_ADDED {
// ReadResource deals with deleted values (i.e. a file exists but has generation -999).
resp := s.ReadResource(ctx, &resourcepb.ReadRequest{Key: event.Key})
if resp.Error != nil && resp.Error.Code != http.StatusNotFound {
return 0, GetError(resp.Error)
}
if resp.Value != nil {
return 0, ErrResourceAlreadyExists
}
}
// Scope the lock
{
s.mutex.Lock()
defer s.mutex.Unlock()
rv = s.rv.Add(1)
err = s.bucket.WriteAll(ctx, s.getPath(event.Key, rv), event.Value, &blob.WriterOptions{
ContentType: "application/json",
})
}
// notify all subscribers
if s.stream != nil {
write := &WrittenEvent{
Type: event.Type,
Key: event.Key,
PreviousRV: event.PreviousRV,
Value: event.Value,
Timestamp: time.Now().UnixMilli(),
ResourceVersion: rv,
}
s.stream <- write
}
return rv, err
}
func (s *cdkBackend) ReadResource(ctx context.Context, req *resourcepb.ReadRequest) *BackendReadResponse {
rv := req.ResourceVersion
path := s.getPath(req.Key, rv)
if rv < 1 {
iter := s.bucket.List(&blob.ListOptions{Prefix: path + "/", Delimiter: "/"})
for {
obj, err := iter.Next(ctx)
if errors.Is(err, io.EOF) {
break
}
if strings.HasSuffix(obj.Key, ".json") {
idx := strings.LastIndex(obj.Key, "/") + 1
edx := strings.LastIndex(obj.Key, ".")
if idx > 0 {
v, err := strconv.ParseInt(obj.Key[idx:edx], 10, 64)
if err == nil && v > rv {
rv = v
path = obj.Key // find the path with biggest resource version
}
}
}
}
}
raw, err := s.bucket.ReadAll(ctx, path)
if raw == nil && req.ResourceVersion > 0 {
if req.ResourceVersion > s.rv.Load() {
return &BackendReadResponse{
Error: &resourcepb.ErrorResult{
Code: http.StatusGatewayTimeout,
Reason: string(metav1.StatusReasonTimeout), // match etcd behavior
Message: "ResourceVersion is larger than max",
Details: &resourcepb.ErrorDetails{
Causes: []*resourcepb.ErrorCause{
{
Reason: string(metav1.CauseTypeResourceVersionTooLarge),
Message: fmt.Sprintf("requested: %d, current %d", req.ResourceVersion, s.rv.Load()),
},
},
},
},
}
}
// If the there was an explicit request, get the latest
rsp := s.ReadResource(ctx, &resourcepb.ReadRequest{Key: req.Key})
if rsp != nil && len(rsp.Value) > 0 {
raw = rsp.Value
rv = rsp.ResourceVersion
err = nil
}
}
if err == nil && isDeletedValue(raw) {
raw = nil
}
if raw == nil {
return &BackendReadResponse{Error: NewNotFoundError(req.Key)}
}
return &BackendReadResponse{
Key: req.Key,
Folder: "", // TODO: implement this
ResourceVersion: rv,
Value: raw,
}
}
func isDeletedValue(raw []byte) bool {
if bytes.Contains(raw, []byte(`"generation":-999`)) {
tmp := &unstructured.Unstructured{}
err := tmp.UnmarshalJSON(raw)
if err == nil && tmp.GetGeneration() == utils.DeletedGeneration {
return true
}
}
return false
}
func (s *cdkBackend) ListIterator(ctx context.Context, req *resourcepb.ListRequest, cb func(ListIterator) error) (int64, error) {
resources, err := buildTree(ctx, s, req.Options.Key)
if err != nil {
return 0, err
}
err = cb(resources)
return resources.listRV, err
}
func (s *cdkBackend) ListHistory(ctx context.Context, req *resourcepb.ListRequest, cb func(ListIterator) error) (int64, error) {
return 0, fmt.Errorf("listing from history not supported in CDK backend")
}
func (s *cdkBackend) WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.broadcaster == nil {
var err error
s.broadcaster, err = NewBroadcaster(context.Background(), func(c chan<- *WrittenEvent) error {
s.stream = c
return nil
})
if err != nil {
return nil, err
}
}
return s.broadcaster.Subscribe(ctx)
}
// group > resource > namespace > name > versions
type cdkResource struct {
prefix string
versions []cdkVersion
}
type cdkVersion struct {
rv int64
key string
}
type cdkListIterator struct {
bucket CDKBucket
ctx context.Context
err error
listRV int64
resources []cdkResource
index int
currentRV int64
currentKey string
currentVal []byte
}
// Next implements ListIterator.
func (c *cdkListIterator) Next() bool {
if c.err != nil {
return false
}
for {
c.currentVal = nil
c.index += 1
if c.index >= len(c.resources) {
return false
}
item := c.resources[c.index]
latest := item.versions[0]
raw, err := c.bucket.ReadAll(c.ctx, latest.key)
if err != nil {
c.err = err
return false
}
if !isDeletedValue(raw) {
c.currentRV = latest.rv
c.currentKey = latest.key
c.currentVal = raw
return true
}
}
}
// Error implements ListIterator.
func (c *cdkListIterator) Error() error {
return c.err
}
// ResourceVersion implements ListIterator.
func (c *cdkListIterator) ResourceVersion() int64 {
return c.currentRV
}
// Value implements ListIterator.
func (c *cdkListIterator) Value() []byte {
return c.currentVal
}
// ContinueToken implements ListIterator.
func (c *cdkListIterator) ContinueToken() string {
return fmt.Sprintf("index:%d/key:%s", c.index, c.currentKey)
}
// Name implements ListIterator.
func (c *cdkListIterator) Name() string {
return c.currentKey // TODO (parse name from key)
}
// Namespace implements ListIterator.
func (c *cdkListIterator) Namespace() string {
return c.currentKey // TODO (parse namespace from key)
}
func (c *cdkListIterator) Folder() string {
return "" // TODO: implement this
}
var _ ListIterator = (*cdkListIterator)(nil)
func buildTree(ctx context.Context, s *cdkBackend, key *resourcepb.ResourceKey) (*cdkListIterator, error) {
byPrefix := make(map[string]*cdkResource)
path := s.getPath(key, 0)
iter := s.bucket.List(&blob.ListOptions{Prefix: path, Delimiter: ""}) // "" is recursive
for {
obj, err := iter.Next(ctx)
if errors.Is(err, io.EOF) {
break
}
if strings.HasSuffix(obj.Key, ".json") {
idx := strings.LastIndex(obj.Key, "/") + 1
edx := strings.LastIndex(obj.Key, ".")
if idx > 0 {
rv, err := strconv.ParseInt(obj.Key[idx:edx], 10, 64)
if err == nil {
prefix := obj.Key[:idx]
res, ok := byPrefix[prefix]
if !ok {
res = &cdkResource{prefix: prefix}
byPrefix[prefix] = res
}
res.versions = append(res.versions, cdkVersion{
rv: rv,
key: obj.Key,
})
}
}
}
}
// Now sort all versions
resources := make([]cdkResource, 0, len(byPrefix))
for _, res := range byPrefix {
sort.Slice(res.versions, func(i, j int) bool {
return res.versions[i].rv > res.versions[j].rv
})
resources = append(resources, *res)
}
sort.Slice(resources, func(i, j int) bool {
a := resources[i].prefix
b := resources[j].prefix
return a < b
})
return &cdkListIterator{
ctx: ctx,
bucket: s.bucket,
resources: resources,
listRV: s.rv.Load(),
index: -1, // must call next first
}, nil
}

View File

@@ -77,8 +77,10 @@ func (k DataKey) Validate() error {
}
// Validate naming conventions for all required fields
if err := validation.IsValidNamespace(k.Namespace); err != nil {
return NewValidationError("namespace", k.Namespace, err[0])
if k.Namespace != clusterScopeNamespace {
if err := validation.IsValidNamespace(k.Namespace); err != nil {
return NewValidationError("namespace", k.Namespace, err[0])
}
}
if err := validation.IsValidGroup(k.Group); err != nil {
return NewValidationError("group", k.Group, err[0])
@@ -117,7 +119,7 @@ func (k ListRequestKey) Validate() error {
if k.Namespace == "" && k.Name != "" {
return errors.New(ErrNameMustBeEmptyWhenNamespaceEmpty)
}
if k.Namespace != "" {
if k.Namespace != "" && k.Namespace != clusterScopeNamespace {
if err := validation.IsValidNamespace(k.Namespace); err != nil {
return NewValidationError("namespace", k.Namespace, err[0])
}
@@ -155,8 +157,10 @@ func (k GetRequestKey) Validate() error {
if k.Namespace == "" {
return errors.New(ErrNamespaceRequired)
}
if err := validation.IsValidNamespace(k.Namespace); err != nil {
return NewValidationError("namespace", k.Namespace, err[0])
if k.Namespace != clusterScopeNamespace {
if err := validation.IsValidNamespace(k.Namespace); err != nil {
return NewValidationError("namespace", k.Namespace, err[0])
}
}
if err := validation.IsValidGroup(k.Group); err != nil {
return NewValidationError("group", k.Group, err[0])

View File

@@ -51,8 +51,10 @@ func (k EventKey) Validate() error {
// Validate each field against the naming rules
// Validate naming conventions for all required fields
if err := validation.IsValidNamespace(k.Namespace); err != nil {
return NewValidationError("namespace", k.Namespace, err[0])
if k.Namespace != clusterScopeNamespace {
if err := validation.IsValidNamespace(k.Namespace); err != nil {
return NewValidationError("namespace", k.Namespace, err[0])
}
}
if err := validation.IsValidGroup(k.Group); err != nil {
return NewValidationError("group", k.Group, err[0])

View File

@@ -35,8 +35,10 @@ func verifyRequestKeyNamespaceGroupResource(key *resourcepb.ResourceKey) *resour
if key.Resource == "" {
return NewBadRequestError("request key is missing resource")
}
if err := validation.IsValidNamespace(key.Namespace); err != nil {
return NewBadRequestError(err[0])
if key.Namespace != clusterScopeNamespace {
if err := validation.IsValidNamespace(key.Namespace); err != nil {
return NewBadRequestError(err[0])
}
}
if err := validation.IsValidGroup(key.Group); err != nil {
return NewBadRequestError(err[0])

View File

@@ -1072,7 +1072,7 @@ func (s *server) List(ctx context.Context, req *resourcepb.ListRequest) (*resour
pageBytes += len(item.Value)
rsp.Items = append(rsp.Items, item)
if len(rsp.Items) >= int(req.Limit) || pageBytes >= maxPageBytes {
if (req.Limit > 0 && len(rsp.Items) >= int(req.Limit)) || pageBytes >= maxPageBytes {
t := iter.ContinueToken()
if iter.Next() {
rsp.NextPageToken = t

Some files were not shown because too many files have changed in this diff Show More