diff --git a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue index 4618bc87f86..cc240be9a11 100644 --- a/apps/dashboard/kinds/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2beta1/dashboard_spec.cue @@ -684,8 +684,8 @@ VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAs VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged" // Determine if the variable shows on dashboard -// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). -VariableHide: *"dontHide" | "hideLabel" | "hideVariable" +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing), `inControlsMenu` (show in a drop-down menu). +VariableHide: *"dontHide" | "hideLabel" | "hideVariable" | "inControlsMenu" // Determine the origin of the adhoc variable filter FilterOrigin: "dashboard" diff --git a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue index 6c6b1f3517a..f0797bc480b 100644 --- a/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v0alpha1/dashboard_kind.cue @@ -243,8 +243,8 @@ lineage: schemas: [{ #VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged") // Determine if the variable shows on dashboard - // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). - #VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type") + // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu). + #VariableHide: 0 | 1 | 2 | 3 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable|inControlsMenu") @grafana(TSVeneer="type") // Sort variable options // Accepted values are: diff --git a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue index 6c6b1f3517a..f0797bc480b 100644 --- a/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue +++ b/apps/dashboard/pkg/apis/dashboard/v1beta1/dashboard_kind.cue @@ -243,8 +243,8 @@ lineage: schemas: [{ #VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged") // Determine if the variable shows on dashboard - // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). - #VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type") + // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu). + #VariableHide: 0 | 1 | 2 | 3 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable|inControlsMenu") @grafana(TSVeneer="type") // Sort variable options // Accepted values are: diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue index 071a920ba19..ecca9d311e9 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec.cue @@ -688,8 +688,8 @@ VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAs VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged" // Determine if the variable shows on dashboard -// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). -VariableHide: *"dontHide" | "hideLabel" | "hideVariable" +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing), `inControlsMenu` (show in a drop-down menu). +VariableHide: *"dontHide" | "hideLabel" | "hideVariable" | "inControlsMenu" // Determine the origin of the adhoc variable filter FilterOrigin: "dashboard" diff --git a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go index 7096c8b2cc1..220813ac547 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2beta1/dashboard_spec_gen.go @@ -1282,14 +1282,15 @@ func NewDashboardVariableOption() *DashboardVariableOption { } // Determine if the variable shows on dashboard -// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing), `inControlsMenu` (show in a drop-down menu). // +k8s:openapi-gen=true type DashboardVariableHide string const ( - DashboardVariableHideDontHide DashboardVariableHide = "dontHide" - DashboardVariableHideHideLabel DashboardVariableHide = "hideLabel" - DashboardVariableHideHideVariable DashboardVariableHide = "hideVariable" + DashboardVariableHideDontHide DashboardVariableHide = "dontHide" + DashboardVariableHideHideLabel DashboardVariableHide = "hideLabel" + DashboardVariableHideHideVariable DashboardVariableHide = "hideVariable" + DashboardVariableHideInControlsMenu DashboardVariableHide = "inControlsMenu" ) // Options to config when to refresh a variable diff --git a/apps/iam/cmd/operator/config.go b/apps/iam/cmd/operator/config.go index 0794b3fd496..2784c961a3e 100644 --- a/apps/iam/cmd/operator/config.go +++ b/apps/iam/cmd/operator/config.go @@ -109,7 +109,7 @@ func LoadConfigFromEnv() (*Config, error) { cfg.KubeConfig = kubeConfig } - cfg.ZanzanaClient.Address = os.Getenv("ZANZANA_ADDR") + cfg.ZanzanaClient.URL = os.Getenv("ZANZANA_ADDR") cfg.ZanzanaClient.Token = os.Getenv("ZANZANA_TOKEN") cfg.ZanzanaClient.TokenExchangeURL = os.Getenv("TOKEN_EXCHANGE_URL") cfg.ZanzanaClient.ServerCertFile = os.Getenv("ZANZANA_SERVER_CERT_FILE") diff --git a/apps/iam/go.sum b/apps/iam/go.sum index f3069e2c824..8accbff7ec3 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -744,8 +744,6 @@ github.com/grafana/grafana-aws-sdk v1.1.0 h1:G0fvwbQmHw14c5RXPd7Gnw9ZQcgzl139LtM github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8= -github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs= -github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= github.com/grafana/grafana-plugin-sdk-go v0.278.0 h1:5/rIYparLi02pofdaag8wnjspMMVNCi8cZhC4cdC3Ho= github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= diff --git a/apps/iam/kinds/v0alpha1/serviceaccountspec.cue b/apps/iam/kinds/v0alpha1/serviceaccountspec.cue index 9474fc1a06f..b1fa37b76ac 100644 --- a/apps/iam/kinds/v0alpha1/serviceaccountspec.cue +++ b/apps/iam/kinds/v0alpha1/serviceaccountspec.cue @@ -1,6 +1,10 @@ package v0alpha1 ServiceAccountSpec: { + disabled: bool |* false + plugin: string + role: OrgRole title: string - disabled: bool } + +OrgRole: "None" | "Viewer" | "Editor" | "Admin" @cuetsy(kind="enum") diff --git a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_spec_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_spec_gen.go index a6bfffd53f9..e9d91734641 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_spec_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/serviceaccount_spec_gen.go @@ -2,13 +2,27 @@ package v0alpha1 +// +k8s:openapi-gen=true +type ServiceAccountOrgRole string + +const ( + ServiceAccountOrgRoleNone ServiceAccountOrgRole = "None" + ServiceAccountOrgRoleViewer ServiceAccountOrgRole = "Viewer" + ServiceAccountOrgRoleEditor ServiceAccountOrgRole = "Editor" + ServiceAccountOrgRoleAdmin ServiceAccountOrgRole = "Admin" +) + // +k8s:openapi-gen=true type ServiceAccountSpec struct { - Title string `json:"title"` - Disabled bool `json:"disabled"` + Disabled bool `json:"disabled"` + Plugin string `json:"plugin"` + Role ServiceAccountOrgRole `json:"role"` + Title string `json:"title"` } // NewServiceAccountSpec creates a new ServiceAccountSpec object. func NewServiceAccountSpec() *ServiceAccountSpec { - return &ServiceAccountSpec{} + return &ServiceAccountSpec{ + Disabled: false, + } } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go index c8a1e09e6a6..39fa0575558 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go @@ -1877,13 +1877,6 @@ func schema_pkg_apis_iam_v0alpha1_ServiceAccountSpec(ref common.ReferenceCallbac SchemaProps: spec.SchemaProps{ Type: []string{"object"}, Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, "disabled": { SchemaProps: spec.SchemaProps{ Default: false, @@ -1891,8 +1884,29 @@ func schema_pkg_apis_iam_v0alpha1_ServiceAccountSpec(ref common.ReferenceCallbac Format: "", }, }, + "plugin": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "role": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, }, - Required: []string{"title", "disabled"}, + Required: []string{"disabled", "plugin", "role", "title"}, }, }, } diff --git a/apps/provisioning/pkg/auth/round_tripper.go b/apps/provisioning/pkg/auth/round_tripper.go index 327999457ae..45e15db7a0f 100644 --- a/apps/provisioning/pkg/auth/round_tripper.go +++ b/apps/provisioning/pkg/auth/round_tripper.go @@ -6,7 +6,6 @@ import ( "net/http" "github.com/grafana/authlib/authn" - provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" utilnet "k8s.io/apimachinery/pkg/util/net" ) @@ -19,20 +18,22 @@ type tokenExchanger interface { type RoundTripper struct { client tokenExchanger transport http.RoundTripper + audience string } // NewRoundTripper constructs a RoundTripper that exchanges the provided token per request // and forwards the request to the provided base transport. -func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper) *RoundTripper { +func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string) *RoundTripper { return &RoundTripper{ client: tokenExchangeClient, transport: base, + audience: audience, } } func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{ - Audiences: []string{provisioning.GROUP}, + Audiences: []string{t.audience}, Namespace: "*", }) if err != nil { diff --git a/apps/provisioning/pkg/auth/round_tripper_test.go b/apps/provisioning/pkg/auth/round_tripper_test.go index 7925c46f973..259126db7b9 100644 --- a/apps/provisioning/pkg/auth/round_tripper_test.go +++ b/apps/provisioning/pkg/auth/round_tripper_test.go @@ -34,7 +34,7 @@ func TestRoundTripper_SetsAccessTokenHeader(t *testing.T) { rr := httptest.NewRecorder() rr.WriteHeader(http.StatusOK) return rr.Result(), nil - })) + }), "example-audience") req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil) resp, err := tr.RoundTrip(req) @@ -50,7 +50,7 @@ func TestRoundTripper_PropagatesExchangeError(t *testing.T) { tr := NewRoundTripper(&fakeExchanger{err: io.EOF}, roundTripperFunc(func(_ *http.Request) (*http.Response, error) { t.Fatal("transport should not be called on exchange error") return nil, nil - })) + }), "example-audience") req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil) resp, err := tr.RoundTrip(req) diff --git a/conf/defaults.ini b/conf/defaults.ini index 556a0ed4c1a..bca78940a73 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1903,8 +1903,6 @@ public_key_retrieval_disabled = false public_key_retrieval_on_startup = false # Enter a comma-separated list of plugin identifiers to avoid loading (including core plugins). These plugins will be hidden in the catalog. disable_plugins = -# Comma separated list of plugin ids for which angular deprecation UI should be disabled -hide_angular_deprecation = # Comma separated list of plugin ids for which environment variables should be forwarded. Used only when feature flag pluginsSkipHostEnvVars is enabled. forward_host_env_vars = # Comma separated list of plugin ids to install as part of the startup process. diff --git a/docs/sources/datasources/prometheus/configure/_index.md b/docs/sources/datasources/prometheus/configure/_index.md index b68219e3866..98cf92271fa 100644 --- a/docs/sources/datasources/prometheus/configure/_index.md +++ b/docs/sources/datasources/prometheus/configure/_index.md @@ -255,34 +255,34 @@ After you have provisioned a data source you cannot edit it. **Example of a Prometheus data source configuration:** - ```yaml - apiVersion: 1 +```yaml +apiVersion: 1 - datasources: - - name: Prometheus - type: prometheus - access: proxy - url: http://localhost:9090 - jsonData: - httpMethod: POST - manageAlerts: true - allowAsRecordingRulesTarget: true - prometheusType: Prometheus - prometheusVersion: 3.3.0 - cacheLevel: 'High' - disableRecordingRules: false - timeInterval: 10s # Prometheus scrape interval - incrementalQueryOverlapWindow: 10m - exemplarTraceIdDestinations: - # Field with internal link pointing to data source in Grafana. - # datasourceUid value can be anything, but it should be unique across all defined data source uids. - - datasourceUid: my_jaeger_uid - name: traceID +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://localhost:9090 + jsonData: + httpMethod: POST + manageAlerts: true + allowAsRecordingRulesTarget: true + prometheusType: Prometheus + prometheusVersion: 3.3.0 + cacheLevel: 'High' + disableRecordingRules: false + timeInterval: 10s # Prometheus scrape interval + incrementalQueryOverlapWindow: 10m + exemplarTraceIdDestinations: + # Field with internal link pointing to data source in Grafana. + # datasourceUid value can be anything, but it should be unique across all defined data source uids. + - datasourceUid: my_jaeger_uid + name: traceID - # Field with external link. - - name: traceID - url: 'http://localhost:3000/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22Jaeger%22,%7B%22query%22:%22$${__value.raw}%22%7D%5D' - ``` + # Field with external link. + - name: traceID + url: 'http://localhost:3000/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22Jaeger%22,%7B%22query%22:%22$${__value.raw}%22%7D%5D' +``` ## Azure authentication settings diff --git a/go.mod b/go.mod index 649998bacdf..1c4d2233a49 100644 --- a/go.mod +++ b/go.mod @@ -98,7 +98,6 @@ require ( github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend github.com/grafana/grafana-app-sdk v0.40.3 // @grafana/grafana-app-platform-squad github.com/grafana/grafana-app-sdk/logging v0.40.3 // @grafana/grafana-app-platform-squad - github.com/grafana/grafana-app-sdk/plugin v0.40.3 // @grafana/grafana-app-platform-squad github.com/grafana/grafana-aws-sdk v1.1.0 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // @grafana/partner-datasources github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad @@ -233,7 +232,6 @@ require ( require ( github.com/grafana/grafana/apps/advisor v0.0.0 // @grafana/plugins-platform-backend - github.com/grafana/grafana/apps/alerting/alertenrichment v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/alerting/notifications v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/dashboard v0.0.0 // @grafana/grafana-app-platform-squad @grafana/dashboards-squad github.com/grafana/grafana/apps/folder v0.0.0 // @grafana/grafana-search-and-storage diff --git a/go.sum b/go.sum index 0d6155913f5..a9f74536af2 100644 --- a/go.sum +++ b/go.sum @@ -1605,8 +1605,6 @@ github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhck github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= -github.com/grafana/grafana-app-sdk/plugin v0.40.3 h1:uH0oFZnYOUL+OXcyhd5NVYwoM+Wa0WUXvZ2Om1M91r0= -github.com/grafana/grafana-app-sdk/plugin v0.40.3/go.mod h1:+ylwE0P8WgPu5zURK5aDnVJpwRpuK3573rwrVV28qzQ= github.com/grafana/grafana-aws-sdk v1.1.0 h1:G0fvwbQmHw14c5RXPd7Gnw9ZQcgzl139LtMDoe0KhmE= github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA= diff --git a/kinds/dashboard/dashboard_kind.cue b/kinds/dashboard/dashboard_kind.cue index b1854b5ef7a..9276f28a85e 100644 --- a/kinds/dashboard/dashboard_kind.cue +++ b/kinds/dashboard/dashboard_kind.cue @@ -239,8 +239,8 @@ lineage: schemas: [{ #VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged") // Determine if the variable shows on dashboard - // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). - #VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type") + // Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu). + #VariableHide: 0 | 1 | 2 | 3 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable|inControlsMenu") @grafana(TSVeneer="type") // Sort variable options // Accepted values are: diff --git a/package.json b/package.json index b7a2952b7f3..02619d72eb4 100644 --- a/package.json +++ b/package.json @@ -264,7 +264,7 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@fingerprintjs/fingerprintjs": "^3.4.2", - "@floating-ui/react": "0.27.15", + "@floating-ui/react": "0.27.16", "@formatjs/intl-durationformat": "^0.7.0", "@glideapps/glide-data-grid": "^6.0.0", "@grafana/alerting": "workspace:*", @@ -286,8 +286,8 @@ "@grafana/plugin-ui": "^0.10.10", "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", - "@grafana/scenes": "6.33.0", - "@grafana/scenes-react": "6.33.0", + "@grafana/scenes": "6.34.0", + "@grafana/scenes-react": "6.34.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", diff --git a/packages/grafana-data/src/dataframe/utils.test.ts b/packages/grafana-data/src/dataframe/utils.test.ts index 5f7887a1c8c..a1b957f363c 100644 --- a/packages/grafana-data/src/dataframe/utils.test.ts +++ b/packages/grafana-data/src/dataframe/utils.test.ts @@ -1,7 +1,8 @@ import { FieldType } from '../types/dataFrame'; +import { TimeRange } from '../types/time'; import { createDataFrame, toDataFrame } from './processDataFrame'; -import { anySeriesWithTimeField, addRow } from './utils'; +import { anySeriesWithTimeField, addRow, alignTimeRangeCompareData, shouldAlignTimeCompare } from './utils'; describe('anySeriesWithTimeField', () => { describe('single frame', () => { @@ -104,3 +105,287 @@ describe('addRow', () => { expect(frame.length).toBe(2); }); }); + +describe('alignTimeRangeCompareData', () => { + const ONE_DAY_MS = 24 * 60 * 60 * 1000; // 86400000ms + const ONE_WEEK_MS = 7 * ONE_DAY_MS; // 604800000ms + + it('should align time field values with positive diff (1 day)', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 30] }, + ], + }); + + alignTimeRangeCompareData(frame, ONE_DAY_MS); + + expect(frame.fields[0].values).toEqual([ONE_DAY_MS + 1000, ONE_DAY_MS + 2000, ONE_DAY_MS + 3000]); + expect(frame.fields[1].values).toEqual([10, 20, 30]); // non-time fields unchanged + }); + + it('should align time field values with negative diff (1 week)', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [10, 20, 30] }, + ], + }); + + alignTimeRangeCompareData(frame, -ONE_WEEK_MS); + + // When diff is negative, function does v - diff, so v - (-ONE_WEEK_MS) = v + ONE_WEEK_MS + expect(frame.fields[0].values).toEqual([ONE_WEEK_MS + 1000, ONE_WEEK_MS + 2000, ONE_WEEK_MS + 3000]); + }); + + it('should apply default gray color and timeCompare config', () => { + const frame = toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'value', type: FieldType.number, values: [10, 20] }, + ], + }); + + alignTimeRangeCompareData(frame, ONE_DAY_MS); + + frame.fields.forEach((field) => { + expect(field.config.color?.fixedColor).toBe('gray'); + expect(field.config.custom?.timeCompare).toEqual({ + diffMs: ONE_DAY_MS, + isTimeShiftQuery: true, + }); + }); + }); + + it('should apply custom color when provided', () => { + const frame = toDataFrame({ + fields: [{ name: 'value', type: FieldType.number, values: [10, 20] }], + }); + + alignTimeRangeCompareData(frame, ONE_DAY_MS, 'red'); + + expect(frame.fields[0].config.color?.fixedColor).toBe('red'); + }); + + it('should preserve existing config when merging', () => { + const frame = toDataFrame({ + fields: [ + { + name: 'value', + type: FieldType.number, + values: [10, 20], + config: { + displayName: 'My Display Name', + custom: { existingProperty: 'existingValue' }, + }, + }, + ], + }); + + alignTimeRangeCompareData(frame, ONE_WEEK_MS); + + expect(frame.fields[0].config.displayName).toBe('My Display Name'); + expect(frame.fields[0].config.custom?.existingProperty).toBe('existingValue'); + expect(frame.fields[0].config.custom?.timeCompare?.diffMs).toBe(ONE_WEEK_MS); + }); +}); + +describe('shouldAlignTimeCompare', () => { + const TIME_VALUES_A = [1000, 2000, 3000]; + const TIME_VALUES_B = [5000, 6000, 7000]; + const ORIGINAL_VALUES = [10, 20, 30]; + const COMPARE_VALUES = [15, 25, 35]; + + const mockTimeRange: TimeRange = { + from: { valueOf: () => 4000 }, + to: { valueOf: () => 8000 }, + raw: { from: 'now-1h', to: 'now' }, + } as TimeRange; + + it('should return true when compare first time is before time range', () => { + const originalFrame = toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_VALUES_A }, + { name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }, + ], + }); + + const compareFrame = toDataFrame({ + refId: 'A-compare', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_VALUES_A }, + { name: 'value', type: FieldType.number, values: COMPARE_VALUES }, + ], + meta: { + timeCompare: { + isTimeShiftQuery: true, + diffMs: 86400000, + }, + }, + }); + + const allFrames = [originalFrame, compareFrame]; + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(true); + }); + + it('should return false when compare first time is after time range', () => { + const originalFrame = toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_VALUES_A }, + { name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }, + ], + }); + + const compareFrame = toDataFrame({ + refId: 'A-compare', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_VALUES_B }, + { name: 'value', type: FieldType.number, values: COMPARE_VALUES }, + ], + meta: { + timeCompare: { + isTimeShiftQuery: true, + diffMs: 86400000, + }, + }, + }); + + const allFrames = [originalFrame, compareFrame]; + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false); + }); + + it('should return false when compare frame refId does not end with -compare', () => { + const compareFrame = toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_VALUES_A }, + { name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }, + ], + }); + + const allFrames = [compareFrame]; + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false); + }); + + it('should return false when original frame is not found', () => { + const compareFrame = toDataFrame({ + refId: 'A-compare', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_VALUES_A }, + { name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }, + ], + }); + + const allFrames = [compareFrame]; // No original frame with refId 'A' + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false); + }); + + it('should return false when compare frame has no time field', () => { + const originalFrame = toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_VALUES_A }, + { name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }, + ], + }); + + const compareFrame = toDataFrame({ + refId: 'A-compare', + fields: [{ name: 'value', type: FieldType.number, values: COMPARE_VALUES }], + }); + + const allFrames = [originalFrame, compareFrame]; + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false); + }); + + it('should return false when original frame has no time field', () => { + const originalFrame = toDataFrame({ + refId: 'A', + fields: [{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }], + }); + + const compareFrame = toDataFrame({ + refId: 'A-compare', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_VALUES_A }, + { name: 'value', type: FieldType.number, values: COMPARE_VALUES }, + ], + }); + + const allFrames = [originalFrame, compareFrame]; + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false); + }); + + it('should return false when time fields have empty values', () => { + const EMPTY_VALUES: number[] = []; + + const originalFrame = toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: EMPTY_VALUES }, + { name: 'value', type: FieldType.number, values: EMPTY_VALUES }, + ], + }); + + const compareFrame = toDataFrame({ + refId: 'A-compare', + fields: [ + { name: 'time', type: FieldType.time, values: EMPTY_VALUES }, + { name: 'value', type: FieldType.number, values: EMPTY_VALUES }, + ], + }); + + const allFrames = [originalFrame, compareFrame]; + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false); + }); + + it('should handle null values and return true when first non-null time is before range', () => { + const TIME_WITH_NULLS = [null, ...TIME_VALUES_A]; + const ORIGINAL_WITH_NULLS = [null, ...ORIGINAL_VALUES]; + const COMPARE_WITH_NULLS = [null, ...COMPARE_VALUES]; + + const originalFrame = toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_WITH_NULLS }, + { name: 'value', type: FieldType.number, values: ORIGINAL_WITH_NULLS }, + ], + }); + + const compareFrame = toDataFrame({ + refId: 'A-compare', + fields: [ + { name: 'time', type: FieldType.time, values: TIME_WITH_NULLS }, + { name: 'value', type: FieldType.number, values: COMPARE_WITH_NULLS }, + ], + }); + + const allFrames = [originalFrame, compareFrame]; + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(true); + }); + + it('should return false when all time values are null', () => { + const ALL_NULL_TIMES = [null, null, null]; + + const originalFrame = toDataFrame({ + refId: 'A', + fields: [ + { name: 'time', type: FieldType.time, values: ALL_NULL_TIMES }, + { name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }, + ], + }); + + const compareFrame = toDataFrame({ + refId: 'A-compare', + fields: [ + { name: 'time', type: FieldType.time, values: ALL_NULL_TIMES }, + { name: 'value', type: FieldType.number, values: COMPARE_VALUES }, + ], + }); + + const allFrames = [originalFrame, compareFrame]; + expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false); + }); +}); diff --git a/packages/grafana-data/src/dataframe/utils.ts b/packages/grafana-data/src/dataframe/utils.ts index 8f45ddd1b92..c9114e7e647 100644 --- a/packages/grafana-data/src/dataframe/utils.ts +++ b/packages/grafana-data/src/dataframe/utils.ts @@ -1,4 +1,5 @@ import { DataFrame, Field, FieldType } from '../types/dataFrame'; +import { TimeRange } from '../types/time'; import { getTimeField } from './processDataFrame'; @@ -123,3 +124,79 @@ export function addRow(dataFrame: DataFrame, row: Record | unkn // does not need any external updating. } } + +/** + * Aligns time range comparison data by adjusting timestamps and applying compare-specific styling + * @param series - The DataFrame containing the comparison data + * @param diff - The time difference in milliseconds to align the timestamps + * @param compareColor - Optional color to use for the comparison series (defaults to 'gray') + */ +export function alignTimeRangeCompareData(series: DataFrame, diff: number, compareColor = 'gray') { + series.fields.forEach((field: Field) => { + // Align compare series time stamps with reference series + if (field.type === FieldType.time) { + field.values = field.values.map((v: number) => { + return diff < 0 ? v - diff : v + diff; + }); + } + + field.config = { + ...(field.config ?? {}), + color: { + mode: 'fixed', + fixedColor: compareColor, + }, + custom: { + ...(field.config?.custom ?? {}), + timeCompare: { + diffMs: diff, + isTimeShiftQuery: true, + }, + }, + }; + }); +} + +/** + * Checks if a time comparison frame needs alignment based on whether its first time is before the current time range. + * Returns true if the first time in compare is before timeRange.from, indicating it needs shifting. + * @param compareFrame - The frame with time comparison data + * @param allFrames - Array of all frames to find the matching original frame + * @param timeRange - The current panel time range + * @returns true if alignment is needed + */ +export function shouldAlignTimeCompare(compareFrame: DataFrame, allFrames: DataFrame[], timeRange: TimeRange): boolean { + // Find the matching original frame by removing '-compare' from refId + const compareRefId = compareFrame.refId; + if (!compareRefId || !compareRefId.endsWith('-compare')) { + return false; + } + + const originalRefId = compareRefId.replace('-compare', ''); + const originalFrame = allFrames.find( + (frame) => frame.refId === originalRefId && !frame.meta?.timeCompare?.isTimeShiftQuery + ); + + if (!originalFrame) { + return false; + } + + // Find time fields + const compareTimeField = compareFrame.fields.find((field) => field.type === FieldType.time); + const originalTimeField = originalFrame.fields.find((field) => field.type === FieldType.time); + + if (!compareTimeField?.values.length || !originalTimeField?.values.length) { + return false; + } + + // Find first non-null time value from each frame + const compareFirstTime = compareTimeField.values.find((value) => value != null); + const originalFirstTime = originalTimeField.values.find((value) => value != null); + + if (compareFirstTime == null || originalFirstTime == null) { + return false; + } + + // Check if first non-null time value is before timeRange.from + return compareFirstTime < timeRange.from.valueOf(); +} diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 23127b6c252..dec175674ce 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -50,6 +50,8 @@ export { isTimeSeriesField, getRowUniqueId, addRow, + alignTimeRangeCompareData, + shouldAlignTimeCompare, } from './dataframe/utils'; export { StreamingDataFrame, diff --git a/packages/grafana-data/src/types/pluginExtensions.ts b/packages/grafana-data/src/types/pluginExtensions.ts index ec0a2271b18..2461e8283db 100644 --- a/packages/grafana-data/src/types/pluginExtensions.ts +++ b/packages/grafana-data/src/types/pluginExtensions.ts @@ -229,6 +229,7 @@ export type CentralAlertHistorySceneV1Props = { defaultLabelsFilter?: string; defaultTimeRange?: { from: string; to: string }; hideFilters?: boolean; + hideAlertRuleColumn?: boolean; }; export type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context = { diff --git a/packages/grafana-data/src/types/templateVars.ts b/packages/grafana-data/src/types/templateVars.ts index 168c7f6a451..754afd7ab0c 100644 --- a/packages/grafana-data/src/types/templateVars.ts +++ b/packages/grafana-data/src/types/templateVars.ts @@ -47,6 +47,7 @@ export enum VariableHide { dontHide, hideLabel, hideVariable, + inControlsMenu, } export interface AdHocVariableFilter { diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 01874d4f9b6..581a8294d10 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -108,6 +108,14 @@ export const versionedPages = { '10.0.0': 'data-testid Confirm Modal Danger Button', [MIN_GRAFANA_VERSION]: 'Confirm Modal Danger Button', }, + input: { + '12.2.0': 'data-testid Confirm Modal Input', + }, + }, + SecretsManagement: { + SecretForm: { + '12.2.0': 'data-testid Secret Form', + }, }, AddDashboard: { url: { diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 55b9bfe73a1..4fd2566ce4e 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -40,7 +40,7 @@ }, "dependencies": { "@emotion/css": "11.13.5", - "@floating-ui/react": "0.27.15", + "@floating-ui/react": "0.27.16", "@grafana/data": "12.2.0-pre", "@grafana/e2e-selectors": "12.2.0-pre", "@grafana/i18n": "12.2.0-pre", diff --git a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts index 0b3315e1eed..1bc8b1e1890 100644 --- a/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts +++ b/packages/grafana-schema/src/raw/dashboard/x/dashboard_types.gen.ts @@ -246,12 +246,13 @@ export enum VariableRefresh { /** * Determine if the variable shows on dashboard - * Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). + * Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu). */ export enum VariableHide { dontHide = 0, hideLabel = 1, hideVariable = 2, + inControlsMenu = 3, } /** diff --git a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts index f8d5b84cceb..0bc4a00348d 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2beta1/types.spec.gen.ts @@ -1039,8 +1039,8 @@ export const defaultVariableOption = (): VariableOption => ({ }); // Determine if the variable shows on dashboard -// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing). -export type VariableHide = "dontHide" | "hideLabel" | "hideVariable"; +// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing), `inControlsMenu` (show in a drop-down menu). +export type VariableHide = "dontHide" | "hideLabel" | "hideVariable" | "inControlsMenu"; export const defaultVariableHide = (): VariableHide => ("dontHide"); diff --git a/packages/grafana-schema/src/veneer/dashboard.types.ts b/packages/grafana-schema/src/veneer/dashboard.types.ts index ee156beebbe..ca41c995c31 100644 --- a/packages/grafana-schema/src/veneer/dashboard.types.ts +++ b/packages/grafana-schema/src/veneer/dashboard.types.ts @@ -18,6 +18,7 @@ export enum VariableHide { dontHide, hideLabel, hideVariable, + inControlsMenu, } export interface VariableModel extends Omit { diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 518c6588d85..fb1a899ca6e 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -66,7 +66,7 @@ "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/serialize": "1.3.3", - "@floating-ui/react": "0.27.15", + "@floating-ui/react": "0.27.16", "@grafana/data": "12.2.0-pre", "@grafana/e2e-selectors": "12.2.0-pre", "@grafana/faro-web-sdk": "^1.13.2", diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx index 16c93cd6a37..6fe1a317043 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx @@ -103,7 +103,11 @@ export const ConfirmContent = ({
- +
diff --git a/pkg/events/events.go b/pkg/events/events.go index 38b6dce4090..c45ccaab0a2 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -7,26 +7,6 @@ import ( // Events can be passed to external systems via for example AMQP // Treat these events as basically DTOs so changes has to be backward compatible -type OrgCreated struct { - Timestamp time.Time `json:"timestamp"` - Id int64 `json:"id"` - Name string `json:"name"` -} - -type OrgUpdated struct { - Timestamp time.Time `json:"timestamp"` - Id int64 `json:"id"` - Name string `json:"name"` -} - -type UserCreated struct { - Timestamp time.Time `json:"timestamp"` - Id int64 `json:"id"` - Name string `json:"name"` - Login string `json:"login"` - Email string `json:"email"` -} - type SignUpStarted struct { Timestamp time.Time `json:"timestamp"` Email string `json:"email"` @@ -39,14 +19,6 @@ type SignUpCompleted struct { Email string `json:"email"` } -type UserUpdated struct { - Timestamp time.Time `json:"timestamp"` - Id int64 `json:"id"` - Name string `json:"name"` - Login string `json:"login"` - Email string `json:"email"` -} - type DataSourceDeleted struct { Timestamp time.Time `json:"timestamp"` Name string `json:"name"` @@ -55,22 +27,6 @@ type DataSourceDeleted struct { OrgID int64 `json:"org_id"` } -type DataSourceSecretDeleted struct { - Timestamp time.Time `json:"timestamp"` - Name string `json:"name"` - ID int64 `json:"id"` - UID string `json:"uid"` - OrgID int64 `json:"org_id"` -} - -type DataSourceCreated struct { - Timestamp time.Time `json:"timestamp"` - Name string `json:"name"` - ID int64 `json:"id"` - UID string `json:"uid"` - OrgID int64 `json:"org_id"` -} - // FolderFullPathUpdated is emitted when the full path of the folder(s) is updated. // For example, when the folder is renamed or moved to another folder. // It does not contain the full path of the folders because calculating diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index f464d6543db..504d1378bc7 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -53,6 +53,5 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" - _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" _ "github.com/grafana/tempo/pkg/traceql" ) diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go index 3072af6722c..fc2b9343d77 100644 --- a/pkg/kinds/dashboard/dashboard_spec_gen.go +++ b/pkg/kinds/dashboard/dashboard_spec_gen.go @@ -766,13 +766,14 @@ const ( ) // Determine if the variable shows on dashboard -// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing). +// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu). type VariableHide int64 const ( - VariableHideDontHide VariableHide = 0 - VariableHideHideLabel VariableHide = 1 - VariableHideHideVariable VariableHide = 2 + VariableHideDontHide VariableHide = 0 + VariableHideHideLabel VariableHide = 1 + VariableHideHideVariable VariableHide = 2 + VariableHideInControlsMenu VariableHide = 3 ) // Option to be selected in a variable. diff --git a/pkg/operators/iam/README.md b/pkg/operators/iam/README.md index 6fb3b088099..f1f245c6551 100644 --- a/pkg/operators/iam/README.md +++ b/pkg/operators/iam/README.md @@ -2,11 +2,13 @@ To build the operator, simply run `make build-go` To run the folder reconciler, you need a `./conf/operator.ini` config file. For example: ``` -[iam_folder_reconciler] -folder_app_url = https://host.docker.internal:6446 -folder_app_namespace = * -zanzana_address = zanzana.default.svc.cluster.local:50051 +[grpc_client_authentication] +token = IamFolderReconcilerToken token_exchange_url = http://host.docker.internal:8080/v1/sign-access-token -token = ProvisioningAdminToken + +[operator] +folder_app_url = https://host.docker.internal:6446 +zanzana_url = zanzana.default.svc.cluster.local:50051 +tls_insecure = true ``` After that, you can run it using: `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=iam-folder-reconciler ./bin/linux-arm64/grafana server target --config=conf/operator.ini`. Beware that you will also need a TokenExchanger, a Zanzana Server and a Folder app running for the operator to behave. diff --git a/pkg/operators/iam/zanzana_folder_reconciler.go b/pkg/operators/iam/zanzana_folder_reconciler.go index fcaf42dc32f..c49088e54cc 100644 --- a/pkg/operators/iam/zanzana_folder_reconciler.go +++ b/pkg/operators/iam/zanzana_folder_reconciler.go @@ -2,6 +2,7 @@ package iam import ( "context" + "crypto/x509" "errors" "fmt" "log/slog" @@ -10,9 +11,9 @@ import ( "os/signal" "syscall" - "github.com/grafana/grafana-app-sdk/k8s" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana-app-sdk/operator" + folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/apps/iam/pkg/app" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/apiserver/standalone" @@ -22,7 +23,6 @@ import ( "k8s.io/client-go/transport" "github.com/grafana/authlib/authn" - "github.com/grafana/grafana-app-sdk/plugin/kubeconfig" utilnet "k8s.io/apimachinery/pkg/util/net" ) @@ -78,64 +78,63 @@ type iamConfig struct { AppConfig app.AppConfig } -const ( - ConnTypeGRPC = "grpc" - ConnTypeHTTP = "http" -) - func buildIAMConfigFromSettings(cfg *setting.Cfg) (*iamConfig, error) { - var err error if cfg == nil { return nil, fmt.Errorf("no configuration available") } iamCfg := iamConfig{} - iamFolderReconcilerSec := cfg.SectionWithEnvOverrides("iam_folder_reconciler") - - zanzanaAddress := iamFolderReconcilerSec.Key("zanzana_address").MustString("") - if zanzanaAddress == "" { - return nil, fmt.Errorf("address is required in [iam_folder_reconciler.zanzana] section") - } - iamCfg.AppConfig.ZanzanaClientCfg.Address = zanzanaAddress - - tokenExchangeURL := iamFolderReconcilerSec.Key("token_exchange_url").MustString("") - if tokenExchangeURL == "" { - return nil, fmt.Errorf("token_exchange_url is required in [iam_folder_reconciler] section") - } - iamCfg.AppConfig.ZanzanaClientCfg.TokenExchangeURL = tokenExchangeURL - - token := iamFolderReconcilerSec.Key("token").MustString("") + gRPCAuth := cfg.SectionWithEnvOverrides("grpc_client_authentication") + token := gRPCAuth.Key("token").String() if token == "" { - return nil, fmt.Errorf("token is required in [iam_folder_reconciler] section") + return nil, fmt.Errorf("token is required in [grpc_client_authentication] section") } iamCfg.AppConfig.ZanzanaClientCfg.Token = token - folderAppURL := iamFolderReconcilerSec.Key("folder_app_url").MustString("") - folderAppNamespace := iamFolderReconcilerSec.Key("folder_app_namespace").MustString("default") + tokenExchangeURL := gRPCAuth.Key("token_exchange_url").String() + if tokenExchangeURL == "" { + return nil, fmt.Errorf("token_exchange_url is required in [grpc_client_authentication] section") + } + iamCfg.AppConfig.ZanzanaClientCfg.TokenExchangeURL = tokenExchangeURL - kubeConfig, err := buildKubeConfigFromFolderAppURL(folderAppURL, tokenExchangeURL, token, folderAppNamespace) + operatorSec := cfg.SectionWithEnvOverrides("operator") + + zanzanaURL := operatorSec.Key("zanzana_url").MustString("") + if zanzanaURL == "" { + return nil, fmt.Errorf("zanzana_url is required in [operator] section") + } + iamCfg.AppConfig.ZanzanaClientCfg.URL = zanzanaURL + + folderAppURL := operatorSec.Key("folder_app_url").MustString("") + if folderAppURL == "" { + return nil, fmt.Errorf("folder_app_url is required in [operator] section") + } + + tlsInsecure := operatorSec.Key("tls_insecure").MustBool(false) + tlsCertFile := operatorSec.Key("tls_cert_file").String() + tlsKeyFile := operatorSec.Key("tls_key_file").String() + tlsCAFile := operatorSec.Key("tls_ca_file").String() + iamCfg.AppConfig.ZanzanaClientCfg.ServerCertFile = tlsCertFile + + kubeConfig, err := buildKubeConfigFromFolderAppURL( + folderAppURL, + tokenExchangeURL, token, + tlsInsecure, tlsCertFile, tlsKeyFile, tlsCAFile, + ) if err != nil { return nil, fmt.Errorf("failed to build kube config: %w", err) } - iamCfg.RunnerConfig.KubeConfig = kubeConfig.RestConfig - - wenhookSection := cfg.SectionWithEnvOverrides("iam_folder_reconciler.webhook_server") - webhookPort := wenhookSection.Key("port").MustInt(8443) - webhookCertPath := wenhookSection.Key("cert_path").MustString("") - webhookKeyPath := wenhookSection.Key("key_path").MustString("") - iamCfg.RunnerConfig.WebhookConfig = operator.RunnerWebhookConfig{ - Port: webhookPort, - TLSConfig: k8s.TLSConfig{ - CertPath: webhookCertPath, - KeyPath: webhookKeyPath, - }, - } + iamCfg.RunnerConfig.KubeConfig = *kubeConfig return &iamCfg, nil } -func buildKubeConfigFromFolderAppURL(folderAppURL, exchangeUrl, authToken, namespace string) (*kubeconfig.NamespacedConfig, error) { +func buildKubeConfigFromFolderAppURL( + folderAppURL string, + exchangeUrl, authToken string, + tlsInsecure bool, tlsCertFile, tlsKeyFile, tlsCAFile string, +) (*rest.Config, error) { tokenExchangeClient, err := authn.NewTokenExchangeClient(authn.TokenExchangeConfig{ TokenExchangeURL: exchangeUrl, Token: authToken, @@ -144,24 +143,53 @@ func buildKubeConfigFromFolderAppURL(folderAppURL, exchangeUrl, authToken, names return nil, fmt.Errorf("failed to create token exchange client: %w", err) } - return &kubeconfig.NamespacedConfig{ - RestConfig: rest.Config{ - APIPath: "/apis", - Host: folderAppURL, - WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { - return &authRoundTripper{ - tokenExchangeClient: tokenExchangeClient, - transport: rt, - } - }), - TLSClientConfig: rest.TLSClientConfig{ - Insecure: true, - }, - }, - Namespace: namespace, + tlsConfig, err := buildTLSConfig(tlsInsecure, tlsCertFile, tlsKeyFile, tlsCAFile) + if err != nil { + return nil, fmt.Errorf("failed to build TLS configuration: %w", err) + } + + return &rest.Config{ + APIPath: "/apis", + Host: folderAppURL, + WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { + return &authRoundTripper{ + tokenExchangeClient: tokenExchangeClient, + transport: rt, + } + }), + TLSClientConfig: tlsConfig, }, nil } +func buildTLSConfig(insecure bool, certFile, keyFile, caFile string) (rest.TLSClientConfig, error) { + tlsConfig := rest.TLSClientConfig{ + Insecure: insecure, + } + + if certFile != "" && keyFile != "" { + tlsConfig.CertFile = certFile + tlsConfig.KeyFile = keyFile + } + + if caFile != "" { + // caFile is set in operator.ini file + // nolint:gosec + caCert, err := os.ReadFile(caFile) + if err != nil { + return tlsConfig, fmt.Errorf("failed to read CA certificate file: %w", err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return tlsConfig, fmt.Errorf("failed to parse CA certificate") + } + + tlsConfig.CAData = caCert + } + + return tlsConfig, nil +} + type authRoundTripper struct { tokenExchangeClient *authn.TokenExchangeClient transport http.RoundTripper @@ -169,7 +197,7 @@ type authRoundTripper struct { func (t *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { tokenResponse, err := t.tokenExchangeClient.Exchange(req.Context(), authn.TokenExchangeRequest{ - Audiences: []string{"folder.grafana.app"}, + Audiences: []string{folder.GROUP}, Namespace: "*", }) if err != nil { @@ -178,7 +206,6 @@ func (t *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) // clone the request as RTs are not expected to mutate the passed request req = utilnet.CloneRequest(req) - req.Header.Set("X-Access-Token", "Bearer "+tokenResponse.Token) return t.transport.RoundTrip(req) } diff --git a/pkg/operators/provisioning/README.md b/pkg/operators/provisioning/README.md index 07e85bb7793..fa2487c214b 100644 --- a/pkg/operators/provisioning/README.md +++ b/pkg/operators/provisioning/README.md @@ -4,12 +4,6 @@ Git sync has two different controllers: the jobs controller and the repo control ## Jobs Controller -> [!WARNING] -> This controller has current limitations: -> -> - Does not start the ConcurrentJobDriver yet. Notifications are logged but not consumed by workers here. -> - Job processing (claim/renew/update/complete) isn't implemented yet as it requires refactoring of some components. - ### Behavior - Watches provisioning `Jobs` and emits notifications on job creation. @@ -51,6 +45,7 @@ This binary currently wires informers and emits job-create notifications. In the - `make build` 2. Ensure the following services are running locally: provisioning API server, secrets service API server, repository controller, unified storage, and auth. 3. Create a operator.ini file: + ``` [database] ensure_default_org_and_user = false @@ -65,12 +60,15 @@ token_exchange_url = http://localhost:6481/sign/access-token # Uncomment to enable history cleanup via Loki. First ensure the Provisioning API is configured with Loki for job history (see `createJobHistoryConfigFromSettings` in `pkg/registry/apis/provisioning/register.go`). # history_expiration = 24h ``` + 3. Start the controller: - - `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-jobs ./bin/darwin-arm64/grafana server target --config=conf/operator.ini` + +- `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-jobs ./bin/darwin-arm64/grafana server target --config=conf/operator.ini` #### TLS Configuration Examples - **Production with proper TLS verification**: + ``` [operator] provisioning_server_url = https://localhost:6446 @@ -83,6 +81,7 @@ token_exchange_url = http://localhost:6481/sign/access-token ``` - **Mutual TLS authentication**: + ``` [operator] provisioning_server_url = https://localhost:6446 @@ -97,6 +96,7 @@ token_exchange_url = http://localhost:6481/sign/access-token ``` - **Development with self-signed certificates (insecure)**: + ``` [operator] provisioning_server_url = https://localhost:6446 @@ -155,4 +155,4 @@ curl -X POST https://localhost:6446/apis/provisioning.grafana.app/v0alpha1/names This controller is responsible for watching repositories. It will eventually do health checks, queue sync jobs, and create/delete github hooks. -To run locally, run `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-repo ./bin/darwin-arm64/grafana server target --config=conf/operator.ini` \ No newline at end of file +To run locally, run `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-repo ./bin/darwin-arm64/grafana server target --config=conf/operator.ini` diff --git a/pkg/operators/provisioning/config.go b/pkg/operators/provisioning/config.go index 6c03da5b91b..5b3c22bab97 100644 --- a/pkg/operators/provisioning/config.go +++ b/pkg/operators/provisioning/config.go @@ -111,7 +111,7 @@ func setupFromConfig(cfg *setting.Cfg) (controllerCfg *provisioningControllerCon APIPath: "/apis", Host: provisioningServerURL, WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { - return authrt.NewRoundTripper(tokenExchangeClient, rt) + return authrt.NewRoundTripper(tokenExchangeClient, rt, provisioning.GROUP) }), TLSClientConfig: tlsConfig, } @@ -145,27 +145,31 @@ func setupFromConfig(cfg *setting.Cfg) (controllerCfg *provisioningControllerCon } dashboardsServerURL := operatorSec.Key("dashboards_server_url").String() - if provisioningServerURL == "" { + if dashboardsServerURL == "" { return nil, fmt.Errorf("dashboards_server_url is required in [operator] section") } foldersServerURL := operatorSec.Key("folders_server_url").String() - if provisioningServerURL == "" { + if foldersServerURL == "" { return nil, fmt.Errorf("folders_server_url is required in [operator] section") } - apiServerURLs := []string{dashboardsServerURL, foldersServerURL, provisioningServerURL} - configProviders := make([]apiserver.RestConfigProvider, len(apiServerURLs)) + apiServerURLs := map[string]string{ + resources.DashboardResource.Group: dashboardsServerURL, + resources.FolderResource.Group: foldersServerURL, + provisioning.GROUP: provisioningServerURL, + } + configProviders := make(map[string]apiserver.RestConfigProvider) - for i, url := range apiServerURLs { + for group, url := range apiServerURLs { config := &rest.Config{ APIPath: "/apis", Host: url, WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper { - return authrt.NewRoundTripper(tokenExchangeClient, rt) + return authrt.NewRoundTripper(tokenExchangeClient, rt, group) }), TLSClientConfig: tlsConfig, } - configProviders[i] = NewDirectConfigProvider(config) + configProviders[group] = NewDirectConfigProvider(config) } clients := resources.NewClientFactoryForMultipleAPIServers(configProviders) diff --git a/pkg/operators/provisioning/jobs_operator.go b/pkg/operators/provisioning/jobs_operator.go index 7877e98f425..5a0a881ff88 100644 --- a/pkg/operators/provisioning/jobs_operator.go +++ b/pkg/operators/provisioning/jobs_operator.go @@ -13,8 +13,6 @@ import ( "github.com/urfave/cli/v2" "k8s.io/client-go/tools/cache" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/migrate" @@ -23,8 +21,6 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/grafana/grafana/pkg/services/apiserver/standalone" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/unified/resourcepb" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/grafana/grafana/apps/provisioning/pkg/controller" informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions" @@ -54,11 +50,6 @@ func RunJobController(opts standalone.BuildInfo, c *cli.Context, cfg *setting.Cf cancel() }() - // Use unified storage client and API clients for testing purposes. - // TODO: remove this once the processing logic is in place - // https://github.com/grafana/git-ui-sync-project/issues/467 - go temporaryPeriodicTestClients(ctx, logger, controllerCfg) - // Jobs informer and controller (resync ~60s like in register.go) jobInformerFactory := informer.NewSharedInformerFactoryWithOptions( controllerCfg.provisioningClient, @@ -223,74 +214,3 @@ func setupWorkers(controllerCfg *jobsControllerConfig) ([]jobs.Worker, error) { return workers, nil } - -// Use unified storage client for testing purposes. -// TODO: remove this once the processing logic is in place -// https://github.com/grafana/git-ui-sync-project/issues/467 -func temporaryPeriodicTestClients(ctx context.Context, logger logging.Logger, controllerCfg *jobsControllerConfig) { - tick := time.NewTicker(controllerCfg.resyncInterval) - logger.Info("starting periodic using clients", "interval", controllerCfg.resyncInterval.String()) - fetchAndLog := func(ctx context.Context) { - ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants us access to all namespaces. - if err != nil { - logger.Error("failed to set identity", "error", err) - return - } - - resp, err := controllerCfg.unified.CountManagedObjects(ctx, &resourcepb.CountManagedObjectsRequest{ - Kind: string(utils.ManagerKindRepo), - }) - if err != nil { - logger.Error("failed to list managed objects", "error", err) - } else { - if len(resp.Items) == 0 { - logger.Info("no managed objects found") - } else { - for _, obj := range resp.Items { - logger.Info("manage object counts", "item", obj) - } - } - } - - // List all supported resources - client, err := controllerCfg.clients.Clients(ctx, "") - if err != nil { - logger.Error("failed to get resource clients", "error", err) - return - } - - for kind, gvr := range resources.SupportedProvisioningResources { - logger := logger.With("kind", kind, "gvr", gvr.String()) - logger.Info("fetching resources") - - resourceClient, gvk, err := client.ForResource(ctx, gvr) - if err != nil { - logger.Error("failed to get resource client", "error", err) - continue - } - - logger = logger.With("gvk", gvk.String()) - list, err := resourceClient.List(ctx, metav1.ListOptions{}) - if err != nil { - logger.Error("failed to list resources", "error", err) - continue - } - - for _, item := range list.Items { - logger.Info("resource", "name", item.GetName(), "namespace", item.GetNamespace()) - } - } - } - - fetchAndLog(ctx) // Initial fetch - for { - select { - case <-ctx.Done(): - tick.Stop() - return - case <-tick.C: - // Periodic fetch - fetchAndLog(ctx) - } - } -} diff --git a/pkg/operators/provisioning/repo_operator.go b/pkg/operators/provisioning/repo_operator.go index 95b5f842a51..cd32be7cc54 100644 --- a/pkg/operators/provisioning/repo_operator.go +++ b/pkg/operators/provisioning/repo_operator.go @@ -50,7 +50,7 @@ func RunRepoController(opts standalone.BuildInfo, c *cli.Context, cfg *setting.C controllerCfg.resyncInterval, ) - resourceLister := resources.NewResourceListerForMigrations(controllerCfg.unified, nil, nil) + resourceLister := resources.NewResourceLister(controllerCfg.unified) jobs, err := jobs.NewJobStore(controllerCfg.provisioningClient.ProvisioningV0alpha1(), 30*time.Second) if err != nil { return fmt.Errorf("create API client job store: %w", err) diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index 9e345e2c26c..96ebd61b08c 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -24,8 +24,6 @@ type PluginManagementCfg struct { GrafanaAppURL string Features Features - - HideAngularDeprecation []string } // Features contains the feature toggles used for the plugin management system. @@ -42,20 +40,19 @@ type Features struct { // NewPluginManagementCfg returns a new PluginManagementCfg. func NewPluginManagementCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSettings, pluginsAllowUnsigned []string, pluginsCDNURLTemplate string, appURL string, features Features, - grafanaComAPIURL string, disablePlugins []string, hideAngularDeprecation []string, forwardHostEnvVars []string, grafanaComAPIToken string, + grafanaComAPIURL string, disablePlugins []string, forwardHostEnvVars []string, grafanaComAPIToken string, ) *PluginManagementCfg { return &PluginManagementCfg{ - PluginsPath: pluginsPath, - DevMode: devMode, - PluginSettings: pluginSettings, - PluginsAllowUnsigned: pluginsAllowUnsigned, - DisablePlugins: disablePlugins, - PluginsCDNURLTemplate: pluginsCDNURLTemplate, - GrafanaComAPIURL: grafanaComAPIURL, - GrafanaAppURL: appURL, - Features: features, - HideAngularDeprecation: hideAngularDeprecation, - ForwardHostEnvVars: forwardHostEnvVars, - GrafanaComAPIToken: grafanaComAPIToken, + PluginsPath: pluginsPath, + DevMode: devMode, + PluginSettings: pluginSettings, + PluginsAllowUnsigned: pluginsAllowUnsigned, + DisablePlugins: disablePlugins, + PluginsCDNURLTemplate: pluginsCDNURLTemplate, + GrafanaComAPIURL: grafanaComAPIURL, + GrafanaAppURL: appURL, + Features: features, + ForwardHostEnvVars: forwardHostEnvVars, + GrafanaComAPIToken: grafanaComAPIToken, } } diff --git a/pkg/plugins/manager/pipeline/validation/steps.go b/pkg/plugins/manager/pipeline/validation/steps.go index b88145f5c5e..bffac88fc0b 100644 --- a/pkg/plugins/manager/pipeline/validation/steps.go +++ b/pkg/plugins/manager/pipeline/validation/steps.go @@ -3,7 +3,6 @@ package validation import ( "context" "errors" - "slices" "time" "github.com/grafana/grafana/pkg/plugins" @@ -117,6 +116,5 @@ func (a *AngularDetector) Validate(ctx context.Context, p *plugins.Plugin) error }).WithMessage("angular plugins are not supported") } } - p.Angular.HideDeprecation = slices.Contains(a.cfg.HideAngularDeprecation, p.ID) return nil } diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 9264fa37cd4..16dd8d946f8 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -79,8 +79,7 @@ var ( ) type AngularMeta struct { - Detected bool `json:"detected"` - HideDeprecation bool `json:"hideDeprecation"` + Detected bool `json:"detected"` } // JSONData represents the plugin's plugin.json diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 9eeacb56e65..e33cf83dc1f 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -6,8 +6,6 @@ import ( "fmt" "strings" - "github.com/grafana/grafana/apps/iam/pkg/reconcilers" - "github.com/grafana/grafana/pkg/services/authz/zanzana" "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -19,7 +17,10 @@ import ( "k8s.io/kube-openapi/pkg/common" "k8s.io/kube-openapi/pkg/spec3" - authtypes "github.com/grafana/authlib/types" + "github.com/grafana/grafana/apps/iam/pkg/reconcilers" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + + authlib "github.com/grafana/authlib/types" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/identity" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" @@ -45,7 +46,6 @@ var errNoResource = errors.New("resource name is required") // This is used just so wire has something unique to return type FolderAPIBuilder struct { - gv schema.GroupVersion features featuremgmt.FeatureToggles namespacer request.NamespaceMapper folderSvc folder.Service @@ -70,12 +70,12 @@ func RegisterAPIService(cfg *setting.Cfg, folderPermissionsSvc accesscontrol.FolderPermissionsService, accessControl accesscontrol.AccessControl, acService accesscontrol.Service, + accessClient authlib.AccessClient, registerer prometheus.Registerer, unified resource.ResourceClient, zanzanaClient zanzana.Client, ) *FolderAPIBuilder { builder := &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), features: features, namespacer: request.GetNamespaceMapper(cfg), folderSvc: folderSvc, @@ -91,17 +91,15 @@ func RegisterAPIService(cfg *setting.Cfg, return builder } -func NewAPIService(ac authtypes.AccessClient) *FolderAPIBuilder { +func NewAPIService(ac authlib.AccessClient) *FolderAPIBuilder { return &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), - namespacer: request.GetNamespaceMapper(nil), authorizer: newMultiTenantAuthorizer(ac), ignoreLegacy: true, } } func (b *FolderAPIBuilder) GetGroupVersion() schema.GroupVersion { - return b.gv + return resourceInfo.GroupVersion() } func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { @@ -115,13 +113,14 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) { } func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { - addKnownTypes(scheme, b.gv) + gv := b.GetGroupVersion() + addKnownTypes(scheme, gv) // Link this version to the internal representation. // This is used for server-side-apply (PATCH), and avoids the error: // "no kind is registered for the type" addKnownTypes(scheme, schema.GroupVersion{ - Group: b.gv.Group, + Group: gv.Group, Version: runtime.APIVersionInternal, }) @@ -129,8 +128,8 @@ func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { // if err := playlist.RegisterConversions(scheme); err != nil { // return err // } - metav1.AddToGroupVersion(scheme, b.gv) - return scheme.SetVersionPriority(b.gv) + metav1.AddToGroupVersion(scheme, gv) + return scheme.SetVersionPriority(gv) } func (b *FolderAPIBuilder) AllowedV0Alpha1Resources() []string { diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 38fa74088fd..80e257d6cae 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -118,8 +118,6 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) { us := grafanarest.NewMockStorage(t) b := &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), - features: nil, namespacer: func(_ int64) string { return "123" }, folderSvc: foldertest.NewFakeService(), storage: us, @@ -194,8 +192,6 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) { ).Once() b := &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), - features: nil, namespacer: func(_ int64) string { return "123" }, folderSvc: foldertest.NewFakeService(), storage: us, @@ -365,8 +361,6 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) { } b := &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), - features: nil, namespacer: func(_ int64) string { return "123" }, folderSvc: foldertest.NewFakeService(), storage: us, @@ -461,8 +455,6 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) { us := grafanarest.NewMockStorage(t) sm := resource.NewMockResourceClient(t) b := &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), - features: nil, namespacer: func(_ int64) string { return "123" }, folderSvc: foldertest.NewFakeService(), storage: us, @@ -569,8 +561,6 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) { us := grafanarest.NewMockStorage(t) sm := resource.NewMockResourceClient(t) b := &FolderAPIBuilder{ - gv: resourceInfo.GroupVersion(), - features: nil, namespacer: func(_ int64) string { return "123" }, folderSvc: foldertest.NewFakeService(), storage: us, diff --git a/pkg/registry/apis/iam/legacy/create_service_account.sql b/pkg/registry/apis/iam/legacy/create_service_account.sql new file mode 100644 index 00000000000..87a891ebabf --- /dev/null +++ b/pkg/registry/apis/iam/legacy/create_service_account.sql @@ -0,0 +1,7 @@ +INSERT INTO {{ .Ident .UserTable }} + (uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified, + is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at) +VALUES + ({{ .Arg .Command.UID }}, 0, {{ .Arg .Command.Login }}, {{ .Arg .Command.Email }}, {{ .Arg .Command.Name }}, + {{ .Arg .Command.OrgID }}, false, {{ .Arg .Command.IsDisabled }}, false, + false, true, '', '', {{ .Arg .Command.Created }}, {{ .Arg .Command.Updated }}, {{ .Arg .Command.LastSeenAt }}) diff --git a/pkg/registry/apis/iam/legacy/service_account.go b/pkg/registry/apis/iam/legacy/service_account.go index d2219f60088..feae6927e6a 100644 --- a/pkg/registry/apis/iam/legacy/service_account.go +++ b/pkg/registry/apis/iam/legacy/service_account.go @@ -9,6 +9,7 @@ import ( claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/registry/apis/iam/common" + "github.com/grafana/grafana/pkg/services/sqlstore/session" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -107,10 +108,28 @@ type ServiceAccount struct { UID string Name string Disabled bool + Role string Created time.Time Updated time.Time } +type CreateServiceAccountCommand struct { + UID string + Name string + Email string + Login string + Role string + IsDisabled bool + OrgID int64 + Created DBTime + Updated DBTime + LastSeenAt time.Time +} + +type CreateServiceAccountResult struct { + ServiceAccount ServiceAccount +} + var sqlQueryServiceAccountsTemplate = mustTemplate("service_accounts_query.sql") func newListServiceAccounts(sql *legacysql.LegacyDatabaseHelper, q *ListServiceAccountsQuery) listServiceAccountsQuery { @@ -167,7 +186,7 @@ func (s *legacySQLStore) ListServiceAccounts(ctx context.Context, ns claims.Name var lastID int64 for rows.Next() { var s ServiceAccount - err := rows.Scan(&s.ID, &s.UID, &s.Name, &s.Disabled, &s.Created, &s.Updated) + err := rows.Scan(&s.ID, &s.UID, &s.Name, &s.Disabled, &s.Role, &s.Created, &s.Updated) if err != nil { return res, err } @@ -286,3 +305,98 @@ func (s *legacySQLStore) ListServiceAccountTokens(ctx context.Context, ns claims return res, err } + +var sqlCreateServiceAccountTemplate = mustTemplate("create_service_account.sql") + +func newCreateServiceAccount(sql *legacysql.LegacyDatabaseHelper, cmd *CreateServiceAccountCommand) createServiceAccountQuery { + return createServiceAccountQuery{ + SQLTemplate: sqltemplate.New(sql.DialectForDriver()), + UserTable: sql.Table("user"), + OrgUserTable: sql.Table("org_user"), + Command: cmd, + } +} + +type createServiceAccountQuery struct { + sqltemplate.SQLTemplate + UserTable string + OrgUserTable string + Command *CreateServiceAccountCommand +} + +func (r createServiceAccountQuery) Validate() error { + return nil +} + +func (s *legacySQLStore) CreateServiceAccount(ctx context.Context, ns claims.NamespaceInfo, cmd CreateServiceAccountCommand) (*CreateServiceAccountResult, error) { + cmd.OrgID = ns.OrgID + cmd.Email = cmd.Login + + now := time.Now().UTC().Truncate(time.Second) + lastSeenAt := now.AddDate(-10, 0, 0) // Set last seen 10 years ago like in user service + + cmd.Created = NewDBTime(now) + cmd.Updated = NewDBTime(now) + cmd.LastSeenAt = lastSeenAt + + if ns.OrgID == 0 { + return nil, fmt.Errorf("expected non zero org id") + } + + sql, err := s.sql(ctx) + if err != nil { + return nil, err + } + + req := newCreateServiceAccount(sql, &cmd) + + var createdSA ServiceAccount + err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error { + userQuery, err := sqltemplate.Execute(sqlCreateServiceAccountTemplate, req) + if err != nil { + return fmt.Errorf("execute service account template %q: %w", sqlCreateServiceAccountTemplate.Name(), err) + } + + serviceAccountID, err := st.ExecWithReturningId(ctx, userQuery, req.GetArgs()...) + if err != nil { + return fmt.Errorf("failed to create service account: %w", err) + } + + orgUserCmd := &CreateOrgUserCommand{ + OrgID: ns.OrgID, + UserID: serviceAccountID, + Role: cmd.Role, + Created: cmd.Created, + Updated: cmd.Updated, + } + orgUserReq := newCreateOrgUser(sql, orgUserCmd) + + orgUserQuery, err := sqltemplate.Execute(sqlCreateOrgUserTemplate, orgUserReq) + if err != nil { + return fmt.Errorf("execute org_user template %q: %w", sqlCreateOrgUserTemplate.Name(), err) + } + + _, err = st.Exec(ctx, orgUserQuery, orgUserReq.GetArgs()...) + if err != nil { + return fmt.Errorf("failed to create org_user relationship: %w", err) + } + + createdSA = ServiceAccount{ + ID: serviceAccountID, + UID: cmd.UID, + Name: cmd.Name, + Role: cmd.Role, + Disabled: cmd.IsDisabled, + Created: cmd.Created.Time, + Updated: cmd.Updated.Time, + } + + return nil + }) + + if err != nil { + return nil, err + } + + return &CreateServiceAccountResult{ServiceAccount: createdSA}, nil +} diff --git a/pkg/registry/apis/iam/legacy/service_accounts_query.sql b/pkg/registry/apis/iam/legacy/service_accounts_query.sql index ca700c1c064..40244fd64c5 100644 --- a/pkg/registry/apis/iam/legacy/service_accounts_query.sql +++ b/pkg/registry/apis/iam/legacy/service_accounts_query.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM {{ .Ident .UserTable }} as u JOIN {{ .Ident .OrgUserTable }} as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/sql.go b/pkg/registry/apis/iam/legacy/sql.go index 13c5d06fe19..1f77d18e6ed 100644 --- a/pkg/registry/apis/iam/legacy/sql.go +++ b/pkg/registry/apis/iam/legacy/sql.go @@ -2,9 +2,11 @@ package legacy import ( "context" + "database/sql/driver" "embed" "fmt" "text/template" + "time" claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/storage/legacysql" @@ -22,6 +24,8 @@ type LegacyIdentityStore interface { GetServiceAccountInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetServiceAccountInternalIDQuery) (*GetServiceAccountInternalIDResult, error) ListServiceAccounts(ctx context.Context, ns claims.NamespaceInfo, query ListServiceAccountsQuery) (*ListServiceAccountResult, error) + CreateServiceAccount(ctx context.Context, ns claims.NamespaceInfo, cmd CreateServiceAccountCommand) (*CreateServiceAccountResult, error) + ListServiceAccountTokens(ctx context.Context, ns claims.NamespaceInfo, query ListServiceAccountTokenQuery) (*ListServiceAccountTokenResult, error) GetTeamInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetTeamInternalIDQuery) (*GetTeamInternalIDResult, error) @@ -30,9 +34,7 @@ type LegacyIdentityStore interface { ListTeamMembers(ctx context.Context, ns claims.NamespaceInfo, query ListTeamMembersQuery) (*ListTeamMembersResult, error) } -var ( - _ LegacyIdentityStore = (*legacySQLStore)(nil) -) +var _ LegacyIdentityStore = (*legacySQLStore)(nil) func NewLegacySQLStores(sql legacysql.LegacyDatabaseProvider) LegacyIdentityStore { return &legacySQLStore{ @@ -58,3 +60,47 @@ func mustTemplate(filename string) *template.Template { } panic(fmt.Sprintf("template file not found: %s", filename)) } + +type DBTime struct { + time.Time +} + +func NewDBTime(t time.Time) DBTime { + return DBTime{Time: t} +} + +func (t DBTime) Value() (driver.Value, error) { + if t.IsZero() { + return nil, nil + } + + return t.Format(time.DateTime), nil +} + +func (t *DBTime) Scan(value interface{}) error { + if value == nil { + t.Time = time.Time{} + return nil + } + + var parsedTime time.Time + var err error + + switch v := value.(type) { + case []byte: + parsedTime, err = time.Parse(time.DateTime, string(v)) + case string: + parsedTime, err = time.Parse(time.DateTime, v) + case time.Time: + parsedTime = v + default: + return fmt.Errorf("could not scan type %T into DBTime", value) + } + + if err != nil { + return fmt.Errorf("could not parse time: %w", err) + } + + t.Time = parsedTime + return nil +} diff --git a/pkg/registry/apis/iam/legacy/sql_test.go b/pkg/registry/apis/iam/legacy/sql_test.go index aaffe0205ca..332f7f7697a 100644 --- a/pkg/registry/apis/iam/legacy/sql_test.go +++ b/pkg/registry/apis/iam/legacy/sql_test.go @@ -88,6 +88,12 @@ func TestIdentityQueries(t *testing.T) { return &v } + createServiceAccounts := func(cmd *CreateServiceAccountCommand) sqltemplate.SQLTemplate { + v := newCreateServiceAccount(nodb, cmd) + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + listServiceAccountTokens := func(q *ListServiceAccountTokenQuery) sqltemplate.SQLTemplate { v := newListServiceAccountTokens(nodb, q) v.SQLTemplate = mocks.NewTestingSQLTemplate() @@ -361,8 +367,8 @@ func TestIdentityQueries(t *testing.T) { OrgID: 1, UserID: 123, Role: "Viewer", - Created: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), - Updated: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), + Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)), + Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)), }), }, { @@ -371,8 +377,8 @@ func TestIdentityQueries(t *testing.T) { OrgID: 2, UserID: 456, Role: "Admin", - Created: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC), - Updated: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC), + Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)), + Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)), }), }, }, @@ -391,9 +397,9 @@ func TestIdentityQueries(t *testing.T) { IsProvisioned: false, Salt: "randomsalt", Rands: "randomrands", - Created: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), - Updated: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC), - LastSeenAt: time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC), + Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)), + Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)), + LastSeenAt: NewDBTime(time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC)), Role: "Viewer", }), }, @@ -411,13 +417,43 @@ func TestIdentityQueries(t *testing.T) { IsProvisioned: true, Salt: "adminsalt", Rands: "adminrands", - Created: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC), - Updated: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC), - LastSeenAt: time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC), + Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)), + Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)), + LastSeenAt: NewDBTime(time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC)), Role: "Admin", }), }, }, + sqlCreateServiceAccountTemplate: { + { + Name: "create_service_account_basic", + Data: createServiceAccounts(&CreateServiceAccountCommand{ + UID: "abcdef", + Name: "Service Account 1", + Email: "sa-1-service-account-1", + Login: "sa-1-service-account-1", + IsDisabled: false, + OrgID: 1, + Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)), + Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)), + LastSeenAt: time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC), + }), + }, + { + Name: "create_service_account_disabled", + Data: createServiceAccounts(&CreateServiceAccountCommand{ + UID: "abcdef", + Name: "Disabled Service Account", + Email: "sa-2-disabled-service-account", + Login: "sa-2-disabled-service-account", + IsDisabled: true, + OrgID: 2, + Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)), + Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)), + LastSeenAt: time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC), + }), + }, + }, }, }) } diff --git a/pkg/registry/apis/iam/legacy/testdata/mysql--create_service_account-create_service_account_basic.sql b/pkg/registry/apis/iam/legacy/testdata/mysql--create_service_account-create_service_account_basic.sql new file mode 100755 index 00000000000..c87d87b78cb --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/mysql--create_service_account-create_service_account_basic.sql @@ -0,0 +1,7 @@ +INSERT INTO `grafana`.`user` + (uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified, + is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at) +VALUES + ('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1', + 1, false, FALSE, false, + false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC') diff --git a/pkg/registry/apis/iam/legacy/testdata/mysql--create_service_account-create_service_account_disabled.sql b/pkg/registry/apis/iam/legacy/testdata/mysql--create_service_account-create_service_account_disabled.sql new file mode 100755 index 00000000000..9ea4e5b2180 --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/mysql--create_service_account-create_service_account_disabled.sql @@ -0,0 +1,7 @@ +INSERT INTO `grafana`.`user` + (uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified, + is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at) +VALUES + ('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account', + 2, false, TRUE, false, + false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC') diff --git a/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts.sql b/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts.sql index 4385e0e0f52..a52bffba3f1 100755 --- a/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts.sql +++ b/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts_page_1.sql b/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts_page_1.sql index e4d755b9e36..784bc117b8a 100755 --- a/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts_page_1.sql +++ b/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts_page_1.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts_page_2.sql b/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts_page_2.sql index 9611cb41f19..a495ee99b25 100755 --- a/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts_page_2.sql +++ b/pkg/registry/apis/iam/legacy/testdata/mysql--service_accounts_query-service_accounts_page_2.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/testdata/postgres--create_service_account-create_service_account_basic.sql b/pkg/registry/apis/iam/legacy/testdata/postgres--create_service_account-create_service_account_basic.sql new file mode 100755 index 00000000000..88d5f40b15f --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/postgres--create_service_account-create_service_account_basic.sql @@ -0,0 +1,7 @@ +INSERT INTO "grafana"."user" + (uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified, + is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at) +VALUES + ('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1', + 1, false, FALSE, false, + false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC') diff --git a/pkg/registry/apis/iam/legacy/testdata/postgres--create_service_account-create_service_account_disabled.sql b/pkg/registry/apis/iam/legacy/testdata/postgres--create_service_account-create_service_account_disabled.sql new file mode 100755 index 00000000000..6799eeebe08 --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/postgres--create_service_account-create_service_account_disabled.sql @@ -0,0 +1,7 @@ +INSERT INTO "grafana"."user" + (uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified, + is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at) +VALUES + ('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account', + 2, false, TRUE, false, + false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC') diff --git a/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts.sql b/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts.sql index b0aca28ddd2..59c3dfba644 100755 --- a/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts.sql +++ b/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts_page_1.sql b/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts_page_1.sql index 03658297fb0..cd33321abc8 100755 --- a/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts_page_1.sql +++ b/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts_page_1.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts_page_2.sql b/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts_page_2.sql index 5bd885ac7a6..4568ffcf036 100755 --- a/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts_page_2.sql +++ b/pkg/registry/apis/iam/legacy/testdata/postgres--service_accounts_query-service_accounts_page_2.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/testdata/sqlite--create_service_account-create_service_account_basic.sql b/pkg/registry/apis/iam/legacy/testdata/sqlite--create_service_account-create_service_account_basic.sql new file mode 100755 index 00000000000..88d5f40b15f --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/sqlite--create_service_account-create_service_account_basic.sql @@ -0,0 +1,7 @@ +INSERT INTO "grafana"."user" + (uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified, + is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at) +VALUES + ('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1', + 1, false, FALSE, false, + false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC') diff --git a/pkg/registry/apis/iam/legacy/testdata/sqlite--create_service_account-create_service_account_disabled.sql b/pkg/registry/apis/iam/legacy/testdata/sqlite--create_service_account-create_service_account_disabled.sql new file mode 100755 index 00000000000..6799eeebe08 --- /dev/null +++ b/pkg/registry/apis/iam/legacy/testdata/sqlite--create_service_account-create_service_account_disabled.sql @@ -0,0 +1,7 @@ +INSERT INTO "grafana"."user" + (uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified, + is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at) +VALUES + ('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account', + 2, false, TRUE, false, + false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC') diff --git a/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts.sql b/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts.sql index b0aca28ddd2..59c3dfba644 100755 --- a/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts.sql +++ b/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts_page_1.sql b/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts_page_1.sql index 03658297fb0..cd33321abc8 100755 --- a/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts_page_1.sql +++ b/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts_page_1.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts_page_2.sql b/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts_page_2.sql index 5bd885ac7a6..4568ffcf036 100755 --- a/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts_page_2.sql +++ b/pkg/registry/apis/iam/legacy/testdata/sqlite--service_accounts_query-service_accounts_page_2.sql @@ -3,6 +3,7 @@ SELECT u.uid, u.name, u.is_disabled, + o.role, u.created, u.updated FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id diff --git a/pkg/registry/apis/iam/legacy/user.go b/pkg/registry/apis/iam/legacy/user.go index ef6cfefec31..b1c656a4f83 100644 --- a/pkg/registry/apis/iam/legacy/user.go +++ b/pkg/registry/apis/iam/legacy/user.go @@ -303,9 +303,9 @@ type CreateUserCommand struct { IsProvisioned bool Salt string Rands string - Created time.Time - Updated time.Time - LastSeenAt time.Time + Created DBTime + Updated DBTime + LastSeenAt DBTime Role string } @@ -317,8 +317,8 @@ type CreateOrgUserCommand struct { OrgID int64 UserID int64 Role string - Created time.Time - Updated time.Time + Created DBTime + Updated DBTime } type DeleteUserCommand struct { @@ -395,9 +395,9 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo cmd.Salt = salt cmd.Rands = rands - cmd.Created = now - cmd.Updated = now - cmd.LastSeenAt = lastSeenAt + cmd.Created = NewDBTime(now) + cmd.Updated = NewDBTime(now) + cmd.LastSeenAt = NewDBTime(lastSeenAt) cmd.Role = "Viewer" // TODO: https://github.com/grafana/identity-access-team/issues/1552 sql, err := s.sql(ctx) @@ -451,9 +451,9 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo IsProvisioned: cmd.IsProvisioned, Salt: cmd.Salt, Rands: cmd.Rands, - Created: cmd.Created, - Updated: cmd.Updated, - LastSeenAt: cmd.LastSeenAt, + Created: cmd.Created.Time, + Updated: cmd.Updated.Time, + LastSeenAt: cmd.LastSeenAt.Time, IsServiceAccount: false, } diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index b15633c327c..7b0bad696ee 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -138,6 +138,7 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge teamBindingResource := iamv0.TeamBindingResourceInfo storage[teamBindingResource.StoragePath()] = team.NewLegacyBindingStore(b.store) + // User store registration userResource := iamv0.UserResourceInfo legacyStore := user.NewLegacyStore(b.store, b.legacyAccessClient, b.enableAuthnMutation) storage[userResource.StoragePath()] = legacyStore @@ -157,8 +158,26 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge } storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store) + + // Service Accounts store registration serviceAccountResource := iamv0.ServiceAccountResourceInfo - storage[serviceAccountResource.StoragePath()] = serviceaccount.NewLegacyStore(b.store, b.legacyAccessClient) + saLegacyStore := serviceaccount.NewLegacyStore(b.store, b.legacyAccessClient, b.enableAuthnMutation) + storage[serviceAccountResource.StoragePath()] = saLegacyStore + + if b.enableDualWriter { + store, err := grafanaregistry.NewRegistryStore(opts.Scheme, serviceAccountResource, opts.OptsGetter) + if err != nil { + return err + } + + dw, err := opts.DualWriteBuilder(serviceAccountResource.GroupResource(), saLegacyStore, store) + if err != nil { + return err + } + + storage[serviceAccountResource.StoragePath()] = dw + } + storage[serviceAccountResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store) if b.sso != nil { @@ -269,15 +288,21 @@ func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authoriz func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { switch a.GetOperation() { case admission.Create: - if a.GetKind() == iamv0.UserResourceInfo.GroupVersionKind() { + switch typedObj := a.GetObject().(type) { + case *iamv0.User: return b.validateCreateUser(ctx, a, o) + case *iamv0.ServiceAccount: + return serviceaccount.ValidateOnCreate(ctx, typedObj) } return nil - case admission.Connect: - case admission.Delete: case admission.Update: return nil + case admission.Delete: + return nil + case admission.Connect: + return nil } + return nil } @@ -289,7 +314,7 @@ func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Cont requester, err := identity.GetRequester(ctx) if err != nil { - return apierrors.NewBadRequest("no identity found") + return apierrors.NewUnauthorized("no identity found") } // Temporary validation that the user is not trying to create a Grafana Admin without being a Grafana Admin. @@ -312,8 +337,11 @@ func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Cont func (b *IdentityAccessManagementAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { switch a.GetOperation() { case admission.Create: - if a.GetKind() == iamv0.UserResourceInfo.GroupVersionKind() { - return b.mutateUser(ctx, a, o) + switch typedObj := a.GetObject().(type) { + case *iamv0.User: + return user.MutateOnCreate(ctx, typedObj) + case *iamv0.ServiceAccount: + return serviceaccount.MutateOnCreate(ctx, typedObj) } return nil case admission.Update: @@ -327,25 +355,6 @@ func (b *IdentityAccessManagementAPIBuilder) Mutate(ctx context.Context, a admis return nil } -func (b *IdentityAccessManagementAPIBuilder) mutateUser(_ context.Context, a admission.Attributes, o admission.ObjectInterfaces) error { - userObj, ok := a.GetObject().(*iamv0.User) - if !ok { - return nil - } - - userObj.Spec.Email = strings.ToLower(userObj.Spec.Email) - userObj.Spec.Login = strings.ToLower(userObj.Spec.Login) - - if userObj.Spec.Login == "" { - userObj.Spec.Login = userObj.Spec.Email - } - if userObj.Spec.Email == "" { - userObj.Spec.Email = userObj.Spec.Login - } - - return nil -} - func NewLocalStore(resourceInfo utils.ResourceInfo, scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter, reg prometheus.Registerer, ac types.AccessClient, storageBackend resource.StorageBackend) (grafanarest.Storage, error) { server, err := resource.NewResourceServer(resource.ResourceServerOptions{ diff --git a/pkg/registry/apis/iam/serviceaccount/mutate.go b/pkg/registry/apis/iam/serviceaccount/mutate.go new file mode 100644 index 00000000000..6ae2d2a11dd --- /dev/null +++ b/pkg/registry/apis/iam/serviceaccount/mutate.go @@ -0,0 +1,16 @@ +package serviceaccount + +import ( + "context" + + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" +) + +func MutateOnCreate(ctx context.Context, obj *iamv0alpha1.ServiceAccount) error { + // External service accounts have None org role by default + if obj.Spec.Plugin != "" && obj.Spec.Role == "" { + obj.Spec.Role = iamv0alpha1.ServiceAccountOrgRoleNone + } + + return nil +} diff --git a/pkg/registry/apis/iam/serviceaccount/mutate_test.go b/pkg/registry/apis/iam/serviceaccount/mutate_test.go new file mode 100644 index 00000000000..eedd1252436 --- /dev/null +++ b/pkg/registry/apis/iam/serviceaccount/mutate_test.go @@ -0,0 +1,69 @@ +package serviceaccount + +import ( + "context" + "testing" + + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/endpoints/request" +) + +func TestMutateOnCreate(t *testing.T) { + ctx := request.WithNamespace(context.Background(), "default") + + testCases := []struct { + name string + inputSA *iamv0alpha1.ServiceAccount + expectedRole iamv0alpha1.ServiceAccountOrgRole + }{ + { + name: "non-external sa with editor role", + inputSA: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "My Test SA", + Role: iamv0alpha1.ServiceAccountOrgRoleEditor, + }, + }, + expectedRole: iamv0alpha1.ServiceAccountOrgRoleEditor, + }, + { + name: "external sa with admin role is not overridden", + inputSA: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "grafana-plugin-name", + Plugin: "grafana-plugin-name", + Role: iamv0alpha1.ServiceAccountOrgRoleAdmin, + }, + }, + expectedRole: iamv0alpha1.ServiceAccountOrgRoleAdmin, + }, + { + name: "external sa with no role specified gets none", + inputSA: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "sa-1-extsvc-grafana-plugin-name", + Plugin: "grafana-plugin-name", + }, + }, + expectedRole: iamv0alpha1.ServiceAccountOrgRoleNone, + }, + { + name: "non-external sa with no role specified", + inputSA: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "Another SA", + }, + }, + expectedRole: "", // Role is not mutated if not present and not external + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := MutateOnCreate(ctx, tc.inputSA) + require.NoError(t, err) + require.Equal(t, tc.expectedRole, tc.inputSA.Spec.Role) + }) + } +} diff --git a/pkg/registry/apis/iam/serviceaccount/store.go b/pkg/registry/apis/iam/serviceaccount/store.go index 99f38a5438a..009635649d4 100644 --- a/pkg/registry/apis/iam/serviceaccount/store.go +++ b/pkg/registry/apis/iam/serviceaccount/store.go @@ -3,7 +3,9 @@ package serviceaccount import ( "context" "fmt" + "strings" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -12,9 +14,12 @@ import ( claims "github.com/grafana/authlib/types" iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/registry/apis/iam/common" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/util" ) var ( @@ -23,17 +28,85 @@ var ( _ rest.Getter = (*LegacyStore)(nil) _ rest.Lister = (*LegacyStore)(nil) _ rest.Storage = (*LegacyStore)(nil) + _ rest.CreaterUpdater = (*LegacyStore)(nil) + _ rest.GracefulDeleter = (*LegacyStore)(nil) + _ rest.CollectionDeleter = (*LegacyStore)(nil) ) var resource = iamv0alpha1.ServiceAccountResourceInfo -func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient) *LegacyStore { - return &LegacyStore{store, ac} +func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient, enableAuthnMutation bool) *LegacyStore { + return &LegacyStore{store, ac, enableAuthnMutation} } type LegacyStore struct { - store legacy.LegacyIdentityStore - ac claims.AccessClient + store legacy.LegacyIdentityStore + ac claims.AccessClient + enableAuthnMutation bool +} + +// DeleteCollection implements rest.CollectionDeleter. +func (s *LegacyStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete") +} + +// Delete implements rest.GracefulDeleter. +func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete") +} + +// Update implements rest.Updater. +func (s *LegacyStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) { + return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "update") +} + +// Create implements rest.Creater. +func (s *LegacyStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { + if !s.enableAuthnMutation { + return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "create") + } + + ns, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + saObj, ok := obj.(*iamv0alpha1.ServiceAccount) + if !ok { + return nil, fmt.Errorf("expected ServiceAccount object, got %T", obj) + } + + if saObj.GenerateName != "" { + saObj.Name = saObj.GenerateName + util.GenerateShortUID() + saObj.GenerateName = "" + } + + if createValidation != nil { + if err := createValidation(ctx, obj); err != nil { + return nil, err + } + } + + login := serviceaccounts.GenerateLogin(serviceaccounts.ServiceAccountPrefix, ns.OrgID, saObj.Spec.Title) + if saObj.Spec.Plugin != "" { + login = serviceaccounts.ExtSvcLoginPrefix(ns.OrgID) + slugify.Slugify(saObj.Spec.Title) + } + + createCmd := legacy.CreateServiceAccountCommand{ + IsDisabled: saObj.Spec.Disabled, + Name: saObj.Spec.Title, + UID: saObj.Name, + Login: strings.ToLower(login), + Role: string(saObj.Spec.Role), + } + + result, err := s.store.CreateServiceAccount(ctx, ns, createCmd) + if err != nil { + return nil, err + } + + iamSA := s.toSAItem(result.ServiceAccount, ns.Value) + return &iamSA, nil } func (s *LegacyStore) New() runtime.Object { @@ -72,7 +145,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt items := make([]iamv0alpha1.ServiceAccount, 0, len(found.Items)) for _, sa := range found.Items { - items = append(items, toSAItem(sa, ns.Value)) + items = append(items, s.toSAItem(sa, ns.Value)) } return &common.ListResponse[iamv0alpha1.ServiceAccount]{ @@ -93,7 +166,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt return obj, nil } -func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount { +func (s *LegacyStore) toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount { item := iamv0alpha1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: sa.UID, @@ -102,8 +175,10 @@ func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount { CreationTimestamp: metav1.NewTime(sa.Created), }, Spec: iamv0alpha1.ServiceAccountSpec{ + Plugin: extractPluginNameFromTitle(sa.Name), Title: sa.Name, Disabled: sa.Disabled, + Role: iamv0alpha1.ServiceAccountOrgRole(sa.Role), }, } obj, _ := utils.MetaAccessor(&item) @@ -112,6 +187,13 @@ func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount { return item } +func extractPluginNameFromTitle(title string) string { + if strings.HasPrefix(title, serviceaccounts.ExtSvcPrefix) { + return strings.TrimLeft(title, serviceaccounts.ExtSvcPrefix) + } + return "" +} + func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { ns, err := request.NamespaceInfoFrom(ctx, true) if err != nil { @@ -130,6 +212,6 @@ func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetO return nil, resource.NewNotFound(name) } - res := toSAItem(found.Items[0], ns.Value) + res := s.toSAItem(found.Items[0], ns.Value) return &res, nil } diff --git a/pkg/registry/apis/iam/serviceaccount/validate.go b/pkg/registry/apis/iam/serviceaccount/validate.go new file mode 100644 index 00000000000..34e851461a0 --- /dev/null +++ b/pkg/registry/apis/iam/serviceaccount/validate.go @@ -0,0 +1,58 @@ +package serviceaccount + +import ( + "context" + "fmt" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + + "github.com/grafana/authlib/types" + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/serviceaccounts" +) + +func ValidateOnCreate(ctx context.Context, obj *iamv0alpha1.ServiceAccount) error { + if obj.Spec.Title == "" { + return apierrors.NewBadRequest("service account must have a title") + } + + requester, err := identity.GetRequester(ctx) + if err != nil { + return apierrors.NewUnauthorized("no identity found") + } + + requestedRole := identity.RoleType(obj.Spec.Role) + if !requestedRole.IsValid() { + return apierrors.NewBadRequest(fmt.Sprintf("invalid role: %s", requestedRole)) + } + + if obj.Spec.Plugin != "" { + if !strings.HasPrefix(obj.Spec.Title, serviceaccounts.ExtSvcPrefix) { + return apierrors.NewBadRequest("title of external service accounts must start with " + serviceaccounts.ExtSvcPrefix) + } + + if !strings.HasSuffix(obj.Spec.Title, strings.ToLower(obj.Spec.Plugin)) { + return apierrors.NewBadRequest("title of external service accounts must end with " + strings.ToLower(obj.Spec.Plugin)) + } + + if !requester.IsIdentityType(types.TypeAccessPolicy) { + return apierrors.NewForbidden(iamv0alpha1.ServiceAccountResourceInfo.GroupResource(), + obj.Name, + fmt.Errorf("only service identities can create external service accounts")) + } + + if obj.Spec.Role != iamv0alpha1.ServiceAccountOrgRoleNone { + return apierrors.NewBadRequest("external service accounts must have role None") + } + } + + if !requester.HasRole(requestedRole) { + return apierrors.NewForbidden(iamv0alpha1.ServiceAccountResourceInfo.GroupResource(), + obj.Name, + fmt.Errorf("can not assign a role higher than user's role")) + } + + return nil +} diff --git a/pkg/registry/apis/iam/serviceaccount/validate_test.go b/pkg/registry/apis/iam/serviceaccount/validate_test.go new file mode 100644 index 00000000000..39703b83c83 --- /dev/null +++ b/pkg/registry/apis/iam/serviceaccount/validate_test.go @@ -0,0 +1,195 @@ +package serviceaccount + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/authlib/types" + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/serviceaccounts" +) + +func TestValidateOnCreate(t *testing.T) { + tests := []struct { + name string + serviceAccount *iamv0alpha1.ServiceAccount + requester *identity.StaticRequester + expectError bool + errorContains string + }{ + { + name: "valid service account with user requester", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "Test Service Account", + Role: iamv0alpha1.ServiceAccountOrgRoleViewer, + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeUser, + OrgRole: identity.RoleAdmin, + }, + expectError: false, + }, + { + name: "empty title", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "", + Role: iamv0alpha1.ServiceAccountOrgRoleViewer, + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeUser, + OrgRole: identity.RoleAdmin, + }, + expectError: true, + errorContains: "service account must have a title", + }, + { + name: "invalid role", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "Test Service Account", + Role: "InvalidRole", + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeUser, + OrgRole: identity.RoleAdmin, + }, + expectError: true, + errorContains: "invalid role", + }, + { + name: "role higher than requester's role", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "Test Service Account", + Role: iamv0alpha1.ServiceAccountOrgRoleAdmin, + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeUser, + OrgRole: identity.RoleViewer, + }, + expectError: true, + errorContains: "can not assign a role higher than user's role", + }, + { + name: "external service account - valid", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: serviceaccounts.ExtSvcPrefix + "test-plugin", + Role: iamv0alpha1.ServiceAccountOrgRoleNone, + Plugin: "test-plugin", + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeAccessPolicy, + OrgRole: identity.RoleAdmin, + }, + expectError: false, + }, + { + name: "external service account - invalid title prefix", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "invalid-prefix-test", + Role: iamv0alpha1.ServiceAccountOrgRoleNone, + Plugin: "test", + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeAccessPolicy, + OrgRole: identity.RoleAdmin, + }, + expectError: true, + errorContains: "title of external service accounts must start with " + serviceaccounts.ExtSvcPrefix, + }, + { + name: "external service account - invalid title suffix", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: serviceaccounts.ExtSvcPrefix + "wrong-suffix", + Role: iamv0alpha1.ServiceAccountOrgRoleNone, + Plugin: "test", + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeAccessPolicy, + OrgRole: identity.RoleAdmin, + }, + expectError: true, + errorContains: "title of external service accounts must end with test", + }, + { + name: "external service account - non-access-policy requester", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: serviceaccounts.ExtSvcPrefix + "test-test", + Role: iamv0alpha1.ServiceAccountOrgRoleNone, + Plugin: "test", + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeUser, + OrgRole: identity.RoleAdmin, + }, + expectError: true, + errorContains: "only service identities can create external service accounts", + }, + { + name: "external service account - role not None", + serviceAccount: &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: serviceaccounts.ExtSvcPrefix + "test-test", + Role: iamv0alpha1.ServiceAccountOrgRoleViewer, + Plugin: "test", + }, + }, + requester: &identity.StaticRequester{ + Type: types.TypeAccessPolicy, + OrgRole: identity.RoleAdmin, + }, + expectError: true, + errorContains: "external service accounts must have role None", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := identity.WithRequester( + context.Background(), + tt.requester, + ) + + err := ValidateOnCreate(ctx, tt.serviceAccount) + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateOnCreate_NoRequester(t *testing.T) { + serviceAccount := &iamv0alpha1.ServiceAccount{ + Spec: iamv0alpha1.ServiceAccountSpec{ + Title: "Test Service Account", + Role: iamv0alpha1.ServiceAccountOrgRoleViewer, + }, + } + + err := ValidateOnCreate(context.Background(), serviceAccount) + require.Error(t, err) + require.Contains(t, err.Error(), "no identity found") +} diff --git a/pkg/registry/apis/iam/user/mutate.go b/pkg/registry/apis/iam/user/mutate.go new file mode 100644 index 00000000000..75c55d3d029 --- /dev/null +++ b/pkg/registry/apis/iam/user/mutate.go @@ -0,0 +1,22 @@ +package user + +import ( + "context" + "strings" + + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" +) + +func MutateOnCreate(ctx context.Context, obj *iamv0alpha1.User) error { + obj.Spec.Email = strings.ToLower(obj.Spec.Email) + obj.Spec.Login = strings.ToLower(obj.Spec.Login) + + if obj.Spec.Login == "" { + obj.Spec.Login = obj.Spec.Email + } + if obj.Spec.Email == "" { + obj.Spec.Email = obj.Spec.Login + } + + return nil +} diff --git a/pkg/registry/apis/iam/user/mutate_test.go b/pkg/registry/apis/iam/user/mutate_test.go new file mode 100644 index 00000000000..0429e554799 --- /dev/null +++ b/pkg/registry/apis/iam/user/mutate_test.go @@ -0,0 +1,78 @@ +package user + +import ( + "context" + "testing" + + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/stretchr/testify/require" +) + +func TestMutateOnCreate_LoginEmail(t *testing.T) { + testCases := []struct { + name string + inputUser *iamv0alpha1.User + expectedLogin string + expectedEmail string + }{ + { + name: "login and email provided with mixed case", + inputUser: &iamv0alpha1.User{ + Spec: iamv0alpha1.UserSpec{ + Login: "Test.User", + Email: "Test.User@example.com", + }, + }, + expectedLogin: "test.user", + expectedEmail: "test.user@example.com", + }, + { + name: "only email provided", + inputUser: &iamv0alpha1.User{ + Spec: iamv0alpha1.UserSpec{ + Email: "Only.Email@example.com", + }, + }, + expectedLogin: "only.email@example.com", + expectedEmail: "only.email@example.com", + }, + { + name: "only login provided", + inputUser: &iamv0alpha1.User{ + Spec: iamv0alpha1.UserSpec{ + Login: "Only.Login", + }, + }, + expectedLogin: "only.login", + expectedEmail: "only.login", + }, + { + name: "login and email already lowercase", + inputUser: &iamv0alpha1.User{ + Spec: iamv0alpha1.UserSpec{ + Login: "already.lower", + Email: "already.lower@example.com", + }, + }, + expectedLogin: "already.lower", + expectedEmail: "already.lower@example.com", + }, + { + name: "both login and email are empty", + inputUser: &iamv0alpha1.User{ + Spec: iamv0alpha1.UserSpec{}, + }, + expectedLogin: "", + expectedEmail: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := MutateOnCreate(context.Background(), tc.inputUser) + require.NoError(t, err) + require.Equal(t, tc.expectedLogin, tc.inputUser.Spec.Login) + require.Equal(t, tc.expectedEmail, tc.inputUser.Spec.Email) + }) + } +} diff --git a/pkg/registry/apis/provisioning/controller/repository.go b/pkg/registry/apis/provisioning/controller/repository.go index 43bfecdfaa1..7109c65886f 100644 --- a/pkg/registry/apis/provisioning/controller/repository.go +++ b/pkg/registry/apis/provisioning/controller/repository.go @@ -215,7 +215,7 @@ func (rc *RepositoryController) handleDelete(ctx context.Context, obj *provision } else { err := rc.finalizer.process(ctx, repo, obj.Finalizers) if err != nil { - logger.Warn("error running finalizer", "err") + logger.Warn("error running finalizer", "err", err) } } diff --git a/pkg/registry/apis/provisioning/resources/client.go b/pkg/registry/apis/provisioning/resources/client.go index dbf5dd56465..d74d9d653ed 100644 --- a/pkg/registry/apis/provisioning/resources/client.go +++ b/pkg/registry/apis/provisioning/resources/client.go @@ -120,26 +120,39 @@ func NewClientFactory(configProvider apiserver.RestConfigProvider) ClientFactory } // NewClientFactoryForMultipleAPIServers creates a ClientFactory for multiple API servers -func NewClientFactoryForMultipleAPIServers(configProviders []apiserver.RestConfigProvider) ClientFactory { - clientFactories := make([]ClientFactory, len(configProviders)) +func NewClientFactoryForMultipleAPIServers(configProviders map[string]apiserver.RestConfigProvider) ClientFactory { + clientFactories := make(map[string]ClientFactory) - for i, configProvider := range configProviders { + for api, configProvider := range configProviders { clientFactory := NewClientFactory(configProvider) - clientFactories[i] = clientFactory + clientFactories[api] = clientFactory } return &multiClientFactory{clientFactories: clientFactories} } type multiClientFactory struct { - clientFactories []ClientFactory + clientFactories map[string]ClientFactory } func (m *multiClientFactory) Clients(ctx context.Context, namespace string) (ResourceClients, error) { - for _, clientFactory := range m.clientFactories { - return clientFactory.Clients(ctx, namespace) + clients := make(map[string]ResourceClients) + for group, clientFactory := range m.clientFactories { + c, err := clientFactory.Clients(ctx, namespace) + if err != nil { + return nil, err + } + + clients[group] = c } - return nil, fmt.Errorf("no client factories available") + if len(clients) == 0 { + return nil, fmt.Errorf("no client factories available") + } + + return &multiResourceClients{ + namespace: namespace, + resourceClientsByAPIGroup: clients, + }, nil } func (f *clientFactory) Clients(ctx context.Context, namespace string) (ResourceClients, error) { @@ -283,6 +296,52 @@ func (c *resourceClients) User(ctx context.Context) (dynamic.ResourceInterface, return v, err } +type multiResourceClients struct { + namespace string + mutex sync.Mutex + resourceClientsByAPIGroup map[string]ResourceClients +} + +// ForKind returns a client for a kind. +// If the kind has a version, it will be used. +// If the kind does not have a version, the preferred version will be used. +func (c *multiResourceClients) ForKind(ctx context.Context, gvk schema.GroupVersionKind) (dynamic.ResourceInterface, schema.GroupVersionResource, error) { + c.mutex.Lock() + defer c.mutex.Unlock() + + resourceClients, ok := c.resourceClientsByAPIGroup[gvk.Group] + if !ok { + return nil, schema.GroupVersionResource{}, fmt.Errorf("no clients provider for group %s", gvk.Group) + } + + return resourceClients.ForKind(ctx, gvk) +} + +// ForResource returns a client for a resource. +// If the resource has a version, it will be used. +// If the resource does not have a version, the preferred version will be used. +func (c *multiResourceClients) ForResource(ctx context.Context, gvr schema.GroupVersionResource) (dynamic.ResourceInterface, schema.GroupVersionKind, error) { + c.mutex.Lock() + defer c.mutex.Unlock() + + resourceClients, ok := c.resourceClientsByAPIGroup[gvr.Group] + if !ok { + return nil, schema.GroupVersionKind{}, fmt.Errorf("no clients provider for group %s", gvr.Group) + } + + return resourceClients.ForResource(ctx, gvr) +} + +func (c *multiResourceClients) Folder(ctx context.Context) (dynamic.ResourceInterface, error) { + client, _, err := c.ForResource(ctx, FolderResource) + return client, err +} + +func (c *multiResourceClients) User(ctx context.Context) (dynamic.ResourceInterface, error) { + v, _, err := c.ForResource(ctx, UserResource) + return v, err +} + // ForEach applies the function to each resource returned from the list operation func ForEach(ctx context.Context, client dynamic.ResourceInterface, fn func(item *unstructured.Unstructured) error) error { var continueToken string diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 1002aadbfb8..68703e03a4e 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -801,7 +801,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, registerer, resourceClient, zanzanaClient) + folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, registerer, storageBackendImpl, storageBackendImpl) if err != nil { @@ -1389,7 +1389,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, registerer, resourceClient, zanzanaClient) + folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient) storageBackendImpl := noopstorage.ProvideStorageBackend() identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, registerer, storageBackendImpl, storageBackendImpl) if err != nil { diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 3c411f02fd5..33cc3b8ed3a 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -45,7 +45,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features return NewZanzanaClient( fmt.Sprintf("stacks-%s", cfg.StackID), ZanzanaClientConfig{ - Address: cfg.ZanzanaClient.Addr, + URL: cfg.ZanzanaClient.Addr, Token: cfg.ZanzanaClient.Token, TokenExchangeURL: cfg.ZanzanaClient.TokenExchangeURL, ServerCertFile: cfg.ZanzanaClient.ServerCertFile, @@ -94,7 +94,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features } type ZanzanaClientConfig struct { - Address string + URL string Token string TokenExchangeURL string ServerCertFile string @@ -128,7 +128,7 @@ func NewZanzanaClient(namespace string, cfg ZanzanaClientConfig) (zanzana.Client ), } - conn, err := grpc.NewClient(cfg.Address, dialOptions...) + conn, err := grpc.NewClient(cfg.URL, dialOptions...) if err != nil { return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err) } diff --git a/pkg/services/datasources/service/store.go b/pkg/services/datasources/service/store.go index f005516433a..c89200ee2fc 100644 --- a/pkg/services/datasources/service/store.go +++ b/pkg/services/datasources/service/store.go @@ -300,14 +300,6 @@ func (ss *SqlStore) AddDataSource(ctx context.Context, cmd *datasources.AddDataS return err } } - - sess.PublishAfterCommit(&events.DataSourceCreated{ - Timestamp: time.Now(), - Name: cmd.Name, - ID: ds.ID, - UID: cmd.UID, - OrgID: cmd.OrgID, - }) return nil }) } diff --git a/pkg/services/datasources/service/store_test.go b/pkg/services/datasources/service/store_test.go index 7acb1dbafca..e5a76714b8a 100644 --- a/pkg/services/datasources/service/store_test.go +++ b/pkg/services/datasources/service/store_test.go @@ -109,33 +109,6 @@ func TestIntegrationDataAccess(t *testing.T) { _, err := ss.AddDataSource(context.Background(), &cmd) require.ErrorContains(t, err, "invalid format of UID") }) - - t.Run("fires an event when the datasource is added", func(t *testing.T) { - db := db.InitTestDB(t) - sqlStore := SqlStore{db: db} - var created *events.DataSourceCreated - db.Bus().AddEventListener(func(ctx context.Context, e *events.DataSourceCreated) error { - created = e - return nil - }) - - _, err := sqlStore.AddDataSource(context.Background(), &defaultAddDatasourceCommand) - require.NoError(t, err) - - require.Eventually(t, func() bool { - return assert.NotNil(t, created) - }, time.Second, time.Millisecond) - - query := datasources.GetDataSourcesQuery{OrgID: 10} - dataSources, err := sqlStore.GetDataSources(context.Background(), &query) - require.NoError(t, err) - require.Equal(t, 1, len(dataSources)) - - require.Equal(t, dataSources[0].ID, created.ID) - require.Equal(t, dataSources[0].UID, created.UID) - require.Equal(t, int64(10), created.OrgID) - require.Equal(t, "nisse", created.Name) - }) }) t.Run("UpdateDataSource", func(t *testing.T) { diff --git a/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.hcl b/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.hcl index 32a9210f42a..2f71ea87efc 100644 --- a/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.hcl +++ b/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.hcl @@ -261,14 +261,14 @@ resource "grafana_contact_point" "contact_point_2b661702215368fe" { title = "test-title" message = "test-message" - tlsConfig { + tls_config { insecure_skip_verify = false ca_certificate = "[REDACTED]" client_certificate = "[REDACTED]" client_key = "[REDACTED]" } - hmacConfig { + hmac_config { secret = "[REDACTED]" header = "X-Grafana-Alerting-Signature" timestamp_header = "X-Grafana-Alerting-Timestamp" diff --git a/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.hcl b/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.hcl index a65f7bdf0d9..aebe516faaa 100644 --- a/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.hcl +++ b/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.hcl @@ -240,14 +240,14 @@ resource "grafana_contact_point" "contact_point_2b661702215368fe" { title = "test-title" message = "test-message" - tlsConfig { + tls_config { insecure_skip_verify = false ca_certificate = "-----BEGIN CERTIFICATE-----\nMIGrMF+gAwIBAgIBATAFBgMrZXAwADAeFw0yNDExMTYxMDI4MzNaFw0yNTExMTYx\nMDI4MzNaMAAwKjAFBgMrZXADIQCf30GvRnHbs9gukA3DLXDK6W5JVgYw6mERU/60\n2M8+rjAFBgMrZXADQQCGmeaRp/AcjeqmJrF5Yh4d7aqsMSqVZvfGNDc0ppXyUgS3\nWMQ1+3T+/pkhU612HR0vFd3vyFhmB4yqFoNV8RML\n-----END CERTIFICATE-----" client_certificate = "-----BEGIN CERTIFICATE-----\nMIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw\nDgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow\nEjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d\n7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B\n5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr\nBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1\nNDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l\nWf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc\n6MF9+Yw1Yy0t\n-----END CERTIFICATE-----" client_key = "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49\nAwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q\nEKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==\n-----END EC PRIVATE KEY-----" } - hmacConfig { + hmac_config { secret = "test-hmac-secret" header = "X-Grafana-Alerting-Signature" timestamp_header = "X-Grafana-Alerting-Timestamp" diff --git a/pkg/services/ngalert/api/tooling/definitions/contact_points.go b/pkg/services/ngalert/api/tooling/definitions/contact_points.go index e32d69f0282..eda7c545b06 100644 --- a/pkg/services/ngalert/api/tooling/definitions/contact_points.go +++ b/pkg/services/ngalert/api/tooling/definitions/contact_points.go @@ -325,8 +325,8 @@ type WebhookIntegration struct { ExtraHeaders *map[string]string `json:"headers,omitempty" yaml:"headers,omitempty" hcl:"headers"` Title *string `json:"title,omitempty" yaml:"title,omitempty" hcl:"title"` Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"` - TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tlsConfig,block"` - HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmacConfig,block"` + TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tls_config,block"` + HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmac_config,block"` HTTPConfig *HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty" hcl:"http_config,block"` Payload *CustomPayload `json:"payload,omitempty" yaml:"payload,omitempty" hcl:"payload,block"` diff --git a/pkg/services/org/orgimpl/store.go b/pkg/services/org/orgimpl/store.go index 46b96483df5..50bbd68ec82 100644 --- a/pkg/services/org/orgimpl/store.go +++ b/pkg/services/org/orgimpl/store.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -96,11 +95,6 @@ func (ss *sqlStore) Insert(ctx context.Context, orga *org.Org) (int64, error) { return err } } - sess.PublishAfterCommit(&events.OrgCreated{ - Timestamp: orga.Created, - Id: orga.ID, - Name: orga.Name, - }) return nil }) if err != nil { @@ -156,12 +150,6 @@ func (ss *sqlStore) Update(ctx context.Context, cmd *org.UpdateOrgCommand) error return org.ErrOrgNotFound.Errorf("failed to update organization with ID: %d", cmd.OrgId) } - sess.PublishAfterCommit(&events.OrgUpdated{ - Timestamp: orga.Updated, - Id: orga.ID, - Name: orga.Name, - }) - return nil }) } @@ -200,12 +188,6 @@ func (ss *sqlStore) UpdateAddress(ctx context.Context, cmd *org.UpdateOrgAddress return err } - sess.PublishAfterCommit(&events.OrgUpdated{ - Timestamp: org.Updated, - Id: org.ID, - Name: org.Name, - }) - return nil }) } @@ -345,12 +327,6 @@ func (ss *sqlStore) CreateWithMember(ctx context.Context, cmd *org.CreateOrgComm _, err := sess.Insert(&user) - sess.PublishAfterCommit(&events.OrgCreated{ - Timestamp: orga.Created, - Id: orga.ID, - Name: orga.Name, - }) - return err }); err != nil { return &orga, err diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index d8014534c25..e1a2a8f8b59 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -1219,38 +1219,6 @@ func TestLoader_Load_Angular(t *testing.T) { } } -func TestLoader_HideAngularDeprecation(t *testing.T) { - fakePluginSource := &fakes.FakePluginSource{ - PluginClassFunc: func(ctx context.Context) plugins.Class { - return plugins.ClassExternal - }, - DiscoverFunc: sources.NewLocalSource(plugins.ClassExternal, []string{filepath.Join(testDataDir(t), "valid-v2-signature")}).Discover, - } - for _, tc := range []struct { - name string - cfg *config.PluginManagementCfg - }{ - {name: "with plugin id in HideAngularDeprecation list", cfg: &config.PluginManagementCfg{ - HideAngularDeprecation: []string{"one-app", "two-panel", "test-datasource", "three-datasource"}, - }}, - {name: "without plugin id in HideAngularDeprecation list", cfg: &config.PluginManagementCfg{ - HideAngularDeprecation: []string{"one-app", "two-panel", "three-datasource"}, - }}, - {name: "with empty HideAngularDeprecation", cfg: &config.PluginManagementCfg{ - HideAngularDeprecation: nil, - }}, - } { - t.Run(tc.name, func(t *testing.T) { - l := newLoaderWithOpts(t, tc.cfg, loaderDepOpts{ - angularInspector: angularinspector.AlwaysAngularFakeInspector, - }) - p, err := l.Load(context.Background(), fakePluginSource) - require.NoError(t, err) - require.Empty(t, p, "plugin shouldn't have been loaded") - }) - } -} - func TestLoader_Load_NestedPlugins(t *testing.T) { parent := &plugins.Plugin{ JSONData: plugins.JSONData{ diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index 08e0fc460c3..ab27c3c89cd 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -37,7 +37,6 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro }, cfg.GrafanaComAPIURL, cfg.DisablePlugins, - cfg.HideAngularDeprecation, cfg.ForwardHostEnvVars, cfg.GrafanaComSSOAPIToken, ), nil diff --git a/pkg/services/serviceaccounts/database/store.go b/pkg/services/serviceaccounts/database/store.go index 6ed850b52db..e8f2c29e87c 100644 --- a/pkg/services/serviceaccounts/database/store.go +++ b/pkg/services/serviceaccounts/database/store.go @@ -43,22 +43,9 @@ func ProvideServiceAccountsStore(cfg *setting.Cfg, store db.DB, apiKeyService ap } } -// generateLogin makes a generated string to have a ID for the service account across orgs and it's name -// this causes you to create a service account with the same name in different orgs -// not the same name in the same org -// -- WARNING: -// -- if you change this function you need to change the ExtSvcLoginPrefix as well -// -- to make sure they are not considered as regular service accounts -func generateLogin(prefix string, orgId int64, name string) string { - generatedLogin := fmt.Sprintf("%v-%v-%v", prefix, orgId, strings.ToLower(name)) - // in case the name has multiple spaces or dashes in the prefix or otherwise, replace them with a single dash - generatedLogin = strings.Replace(generatedLogin, "--", "-", 1) - return strings.ReplaceAll(generatedLogin, " ", "-") -} - // CreateServiceAccount creates service account func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, orgId int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) { - login := generateLogin(serviceaccounts.ServiceAccountPrefix, orgId, saForm.Name) + login := serviceaccounts.GenerateLogin(serviceaccounts.ServiceAccountPrefix, orgId, saForm.Name) isDisabled := false role := org.RoleViewer if saForm.IsDisabled != nil { @@ -483,7 +470,7 @@ func (s *ServiceAccountsStoreImpl) MigrateApiKeysToServiceAccounts(ctx context.C func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Context, key *apikey.APIKey) error { prefix := "sa-autogen" cmd := user.CreateUserCommand{ - Login: generateLogin(prefix, key.OrgID, key.Name), + Login: serviceaccounts.GenerateLogin(prefix, key.OrgID, key.Name), Name: fmt.Sprintf("%v-%v", prefix, key.Name), OrgID: key.OrgID, DefaultOrgRole: string(key.Role), @@ -501,7 +488,7 @@ func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Co // a unique service account by adding suffixes to the initial login name (e.g. -001, -002, ... , -010). for i := 1; errCreateSA != nil && i <= attempts; i++ { serviceAccountName := fmt.Sprintf("%s-%03d", key.Name, i) - cmd.Login = generateLogin(prefix, key.OrgID, serviceAccountName) + cmd.Login = serviceaccounts.GenerateLogin(prefix, key.OrgID, serviceAccountName) newSA, errCreateSA = s.userService.CreateServiceAccount(tctx, &cmd) if errCreateSA != nil && !errors.Is(errCreateSA, serviceaccounts.ErrServiceAccountAlreadyExists) { break diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 9c0182c3b9e..dd3849511f0 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -1,8 +1,6 @@ package serviceaccounts import ( - "fmt" - "strings" "time" "github.com/grafana/grafana/pkg/apimachinery/errutil" @@ -217,16 +215,3 @@ var AccessEvaluator = accesscontrol.EvalAny( accesscontrol.EvalPermission(ActionRead), accesscontrol.EvalPermission(ActionCreate), ) - -func ExtSvcLoginPrefix(orgID int64) string { - return fmt.Sprintf("%s%d-%s", ServiceAccountPrefix, orgID, ExtSvcPrefix) -} - -func IsExternalServiceAccount(login string) bool { - parts := strings.SplitAfter(login, "-") - if len(parts) < 4 { - return false - } - - return parts[0] == ServiceAccountPrefix && parts[2] == ExtSvcPrefix -} diff --git a/pkg/services/serviceaccounts/utils.go b/pkg/services/serviceaccounts/utils.go new file mode 100644 index 00000000000..c9c78d24080 --- /dev/null +++ b/pkg/services/serviceaccounts/utils.go @@ -0,0 +1,32 @@ +package serviceaccounts + +import ( + "fmt" + "strings" +) + +// generateLogin makes a generated string to have a ID for the service account across orgs and it's name +// this causes you to create a service account with the same name in different orgs +// not the same name in the same org +// -- WARNING: +// -- if you change this function you need to change the ExtSvcLoginPrefix as well +// -- to make sure they are not considered as regular service accounts +func GenerateLogin(prefix string, orgId int64, name string) string { + generatedLogin := fmt.Sprintf("%v-%v-%v", prefix, orgId, strings.ToLower(name)) + // in case the name has multiple spaces or dashes in the prefix or otherwise, replace them with a single dash + generatedLogin = strings.Replace(generatedLogin, "--", "-", 1) + return strings.ReplaceAll(generatedLogin, " ", "-") +} + +func ExtSvcLoginPrefix(orgID int64) string { + return fmt.Sprintf("%s%d-%s", ServiceAccountPrefix, orgID, ExtSvcPrefix) +} + +func IsExternalServiceAccount(login string) bool { + parts := strings.SplitAfter(login, "-") + if len(parts) < 4 { + return false + } + + return parts[0] == ServiceAccountPrefix && parts[2] == ExtSvcPrefix +} diff --git a/pkg/services/sqlstore/session.go b/pkg/services/sqlstore/session.go index 1ded27a3559..8269683493f 100644 --- a/pkg/services/sqlstore/session.go +++ b/pkg/services/sqlstore/session.go @@ -31,10 +31,6 @@ type DBSession struct { type DBTransactionFunc func(sess *DBSession) error -func (sess *DBSession) publishAfterCommit(msg any) { - sess.events = append(sess.events, msg) -} - func (sess *DBSession) PublishAfterCommit(msg any) { sess.events = append(sess.events, msg) } diff --git a/pkg/services/sqlstore/user.go b/pkg/services/sqlstore/user.go index 991643b73ae..a76261c465a 100644 --- a/pkg/services/sqlstore/user.go +++ b/pkg/services/sqlstore/user.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" @@ -99,14 +98,6 @@ func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args user.C return usr, err } - sess.publishAfterCommit(&events.UserCreated{ - Timestamp: usr.Created, - Id: usr.ID, - Name: usr.Name, - Login: usr.Login, - Email: usr.Email, - }) - orgUser := org.OrgUser{ OrgID: orgID, UserID: usr.ID, @@ -183,11 +174,5 @@ func (ss *SQLStore) getOrCreateOrg(sess *DBSession, orgName string) (int64, erro } } - sess.publishAfterCommit(&events.OrgCreated{ - Timestamp: org.Created, - Id: org.ID, - Name: org.Name, - }) - return org.ID, nil } diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index 49d437e8cb9..a7773147373 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -7,7 +7,6 @@ import ( "strings" "time" - "github.com/grafana/grafana/pkg/events" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -63,13 +62,6 @@ func (ss *sqlStore) Insert(ctx context.Context, cmd *user.User) (int64, error) { if _, err = sess.Insert(cmd); err != nil { return err } - sess.PublishAfterCommit(&events.UserCreated{ - Timestamp: cmd.Created, - Id: cmd.ID, - Name: cmd.Name, - Login: cmd.Login, - Email: cmd.Email, - }) return nil }) @@ -294,14 +286,6 @@ func (ss *sqlStore) Update(ctx context.Context, cmd *user.UpdateUserCommand) err } } - sess.PublishAfterCommit(&events.UserUpdated{ - Timestamp: usr.Created, - Id: usr.ID, - Name: usr.Name, - Login: usr.Login, - Email: usr.Email, - }) - return nil }) } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 2986911c2e3..2f5dec83ee9 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -205,7 +205,6 @@ type Cfg struct { PluginForcePublicKeyDownload bool PluginSkipPublicKeyDownload bool DisablePlugins []string - HideAngularDeprecation []string ForwardHostEnvVars []string PreinstallPluginsAsync []InstallPlugin PreinstallPluginsSync []InstallPlugin diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 160e17169b7..b03aa02c304 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -141,7 +141,6 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { cfg.PluginsAllowUnsigned = util.SplitString(pluginsSection.Key("allow_loading_unsigned_plugins").MustString("")) cfg.DisablePlugins = util.SplitString(pluginsSection.Key("disable_plugins").MustString("")) - cfg.HideAngularDeprecation = util.SplitString(pluginsSection.Key("hide_angular_deprecation").MustString("")) cfg.ForwardHostEnvVars = util.SplitString(pluginsSection.Key("forward_host_env_vars").MustString("")) disablePreinstall := pluginsSection.Key("preinstall_disabled").MustBool(false) if !disablePreinstall { diff --git a/pkg/setting/setting_plugins_test.go b/pkg/setting/setting_plugins_test.go index af1d3f43561..9b0df2aa583 100644 --- a/pkg/setting/setting_plugins_test.go +++ b/pkg/setting/setting_plugins_test.go @@ -85,14 +85,10 @@ func Test_readPluginSettings(t *testing.T) { _, err = sec.NewKey("plugin_catalog_hidden_plugins", tc.f("plugin3")) require.NoError(t, err) - _, err = sec.NewKey("hide_angular_deprecation", tc.f("a", "b", "c")) - require.NoError(t, err) - err = cfg.readPluginSettings(cfg.Raw) require.NoError(t, err) require.Equal(t, []string{"plugin1", "plugin2"}, cfg.DisablePlugins) require.Equal(t, []string{"plugin3", "plugin1", "plugin2"}, cfg.PluginCatalogHiddenPlugins) - require.Equal(t, []string{"a", "b", "c"}, cfg.HideAngularDeprecation) }) } }) diff --git a/pkg/storage/unified/apistore/watcher_test.go b/pkg/storage/unified/apistore/watcher_test.go index ab4d64a6813..b5973d0f447 100644 --- a/pkg/storage/unified/apistore/watcher_test.go +++ b/pkg/storage/unified/apistore/watcher_test.go @@ -40,8 +40,8 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" - "github.com/grafana/grafana/pkg/tests" "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" ) type StorageType string @@ -135,7 +135,7 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte _, err = server.IsHealthy(ctx, &resourcepb.HealthCheckRequest{}) require.NoError(t, err) case StorageTypeUnified: - tests.SkipIntegrationTestInShortMode(t) + testutil.SkipIntegrationTestInShortMode(t) dbstore := infraDB.InitTestDB(t) cfg := setting.NewCfg() diff --git a/pkg/storage/unified/sql/test/benchmark_test.go b/pkg/storage/unified/sql/test/benchmark_test.go index 1fe29d3b594..540b17c0bbf 100644 --- a/pkg/storage/unified/sql/test/benchmark_test.go +++ b/pkg/storage/unified/sql/test/benchmark_test.go @@ -17,7 +17,7 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" test "github.com/grafana/grafana/pkg/storage/unified/testing" - "github.com/grafana/grafana/pkg/tests" + "github.com/grafana/grafana/pkg/util/testutil" ) func newTestBackend(b testing.TB) resource.StorageBackend { @@ -39,10 +39,7 @@ func newTestBackend(b testing.TB) resource.StorageBackend { } func TestIntegrationBenchmarkSQLStorageBackend(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - tests.SkipIntegrationTestInShortMode(t) + testutil.SkipIntegrationTestInShortMode(t) opts := test.DefaultBenchmarkOptions() if db.IsTestDbSQLite() { opts.Concurrency = 1 // to avoid SQLite database is locked error @@ -53,10 +50,7 @@ func TestIntegrationBenchmarkSQLStorageBackend(t *testing.T) { func TestIntegrationBenchmarkResourceServer(t *testing.T) { t.Skip("skipping slow test, causing CI to fail due to timeout") - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - tests.SkipIntegrationTestInShortMode(t) + testutil.SkipIntegrationTestInShortMode(t) ctx := context.Background() opts := &test.BenchmarkOptions{ diff --git a/pkg/storage/unified/sql/test/integration_test.go b/pkg/storage/unified/sql/test/integration_test.go index 159f7b97a83..a72606e586b 100644 --- a/pkg/storage/unified/sql/test/integration_test.go +++ b/pkg/storage/unified/sql/test/integration_test.go @@ -26,7 +26,6 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/sql" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" unitest "github.com/grafana/grafana/pkg/storage/unified/testing" - "github.com/grafana/grafana/pkg/tests" "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util/testutil" ) @@ -102,10 +101,7 @@ func TestIntegrationSQLStorageBackend(t *testing.T) { } func TestIntegrationSearchAndStorage(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test in short mode") - } - tests.SkipIntegrationTestInShortMode(t) + testutil.SkipIntegrationTestInShortMode(t) ctx := context.Background() diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index ca10b73318a..192222802b5 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -26,10 +26,10 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/tests" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util/testutil" ) func TestMain(m *testing.M) { @@ -1172,7 +1172,7 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) { // Reproduces a bug where folder deletion does not check for attached library panels. func TestIntegrationFolderDeletionBlockedByLibraryElements(t *testing.T) { - tests.SkipIntegrationTestInShortMode(t) + testutil.SkipIntegrationTestInShortMode(t) if !db.IsTestDbSQLite() { t.Skip("test only on sqlite for now") @@ -1251,7 +1251,7 @@ func TestIntegrationFolderDeletionBlockedByLibraryElements(t *testing.T) { } func TestIntegrationRootFolderDeletionBlockedByLibraryElementsInSubfolder(t *testing.T) { - tests.SkipIntegrationTestInShortMode(t) + testutil.SkipIntegrationTestInShortMode(t) if !db.IsTestDbSQLite() { t.Skip("test only on sqlite for now") diff --git a/pkg/tests/apis/iam/service_account_integration_test.go b/pkg/tests/apis/iam/service_account_integration_test.go new file mode 100644 index 00000000000..038c163e3b6 --- /dev/null +++ b/pkg/tests/apis/iam/service_account_integration_test.go @@ -0,0 +1,274 @@ +package identity + +import ( + "context" + "fmt" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/serviceaccounts" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var gvrServiceAccounts = schema.GroupVersionResource{ + Group: "iam.grafana.app", + Version: "v0alpha1", + Resource: "serviceaccounts", +} + +func TestIntegrationServiceAccounts(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // TODO: Figure out why rest.Mode4 is failing + modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3} + for _, mode := range modes { + t.Run(fmt.Sprintf("Service Account CRUD operations with dual writer mode %d", mode), func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, + DisableAnonymous: true, + APIServerStorageType: "unified", + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "serviceaccounts.iam.grafana.app": { + DualWriterMode: mode, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + featuremgmt.FlagKubernetesAuthnMutation, + }, + }) + doServiceAccountCRUDTestsUsingTheNewAPIs(t, helper) + + if mode < 3 { + doServiceAccountCRUDTestsUsingTheLegacyAPIs(t, helper) + } + }) + } +} + +func doServiceAccountCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) { + t.Run("should create service account and get it using the new APIs as a GrafanaAdmin", func(t *testing.T) { + ctx := context.Background() + + saClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrServiceAccounts, + }) + + created, err := saClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-create-v0.yaml"), metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, created) + + createdSpec := created.Object["spec"].(map[string]interface{}) + require.Equal(t, "Test Service Account 1", createdSpec["title"]) + require.Equal(t, false, createdSpec["disabled"]) + require.Empty(t, createdSpec["plugin"]) + + createdUID := created.GetName() + require.NotEmpty(t, createdUID) + + _, err = saClient.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + + fetched, err := saClient.Resource.Get(ctx, createdUID, metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, fetched) + + fetchedSpec := fetched.Object["spec"].(map[string]interface{}) + require.Equal(t, "Test Service Account 1", fetchedSpec["title"]) + require.Equal(t, false, fetchedSpec["disabled"]) + require.Empty(t, fetchedSpec["plugin"]) + + require.Equal(t, createdUID, fetched.GetName()) + require.Equal(t, "default", fetched.GetNamespace()) + }) + + t.Run("should not be able to create service account when using a user with insufficient permissions", func(t *testing.T) { + for _, user := range []apis.User{ + helper.Org1.Editor, + helper.Org1.Viewer, + } { + t.Run(fmt.Sprintf("with basic role_%s", user.Identity.GetOrgRole()), func(t *testing.T) { + ctx := context.Background() + saClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: user, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrServiceAccounts, + }) + + _, err := saClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-create-v0.yaml"), metav1.CreateOptions{}) + require.Error(t, err) + var statusErr *errors.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, int32(403), statusErr.ErrStatus.Code) + }) + } + }) + + t.Run("should not be able to create service account with invalid role", func(t *testing.T) { + ctx := context.Background() + saClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrServiceAccounts, + }) + + saToCreate := helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-invalid-role-v0.yaml") + + _, err := saClient.Resource.Create(ctx, saToCreate, metav1.CreateOptions{}) + require.Error(t, err) + var statusErr *errors.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, int32(400), statusErr.ErrStatus.Code) + require.Contains(t, statusErr.ErrStatus.Message, "invalid role: InvalidRole") + }) + + t.Run("should not be able to create service account with higher role than the user", func(t *testing.T) { + ctx := context.Background() + + editorWithSACreate := helper.CreateUser("custom-editor", apis.Org1, org.RoleEditor, + []resourcepermissions.SetResourcePermissionCommand{ + {Actions: []string{serviceaccounts.ActionCreate}}, + }) + + saClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: editorWithSACreate, + Namespace: helper.Namespacer(editorWithSACreate.Identity.GetOrgID()), + GVR: gvrServiceAccounts, + }) + + saToCreate := helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-higher-role-v0.yaml") + + _, err := saClient.Resource.Create(ctx, saToCreate, metav1.CreateOptions{}) + require.Error(t, err) + var statusErr *errors.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, int32(403), statusErr.ErrStatus.Code) + require.Contains(t, statusErr.ErrStatus.Message, "can not assign a role higher than user's role") + }) + + t.Run("should not be able to create service account without a title", func(t *testing.T) { + ctx := context.Background() + saClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrServiceAccounts, + }) + + saToCreate := helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-no-title-v0.yaml") + + _, err := saClient.Resource.Create(ctx, saToCreate, metav1.CreateOptions{}) + require.Error(t, err) + var statusErr *errors.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, int32(400), statusErr.ErrStatus.Code) + require.Contains(t, statusErr.ErrStatus.Message, "service account must have a title") + }) + + t.Run("should not be able to create external service account as a user", func(t *testing.T) { + ctx := context.Background() + saClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrServiceAccounts, + }) + + saToCreate := helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-external-v0.yaml") + + _, err := saClient.Resource.Create(ctx, saToCreate, metav1.CreateOptions{}) + require.Error(t, err) + var statusErr *errors.StatusError + require.ErrorAs(t, err, &statusErr) + require.Equal(t, int32(403), statusErr.ErrStatus.Code) + require.Contains(t, statusErr.ErrStatus.Message, "only service identities can create external service accounts") + }) + + t.Run("should create service account with generateName and get it using the new APIs as a GrafanaAdmin", func(t *testing.T) { + ctx := context.Background() + + saClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()), + GVR: gvrServiceAccounts, + }) + + created, err := saClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-generate-name-v0.yaml"), metav1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, created) + + createdSpec := created.Object["spec"].(map[string]interface{}) + require.Equal(t, "Test Service Account with GenerateName", createdSpec["title"]) + require.Equal(t, false, createdSpec["disabled"]) + require.Empty(t, createdSpec["plugin"]) + + createdUID := created.GetName() + require.NotEmpty(t, createdUID) + require.Contains(t, createdUID, "sa-") + + _, err = saClient.Resource.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + + fetched, err := saClient.Resource.Get(ctx, createdUID, metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, fetched) + + fetchedSpec := fetched.Object["spec"].(map[string]interface{}) + require.Equal(t, "Test Service Account with GenerateName", fetchedSpec["title"]) + require.Equal(t, false, fetchedSpec["disabled"]) + require.Empty(t, fetchedSpec["plugin"]) + + require.Equal(t, createdUID, fetched.GetName()) + require.Equal(t, "default", fetched.GetNamespace()) + }) +} + +func doServiceAccountCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper) { + t.Run("should create service account using legacy APIs and get it using the new APIs", func(t *testing.T) { + ctx := context.Background() + saClient := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvrServiceAccounts, + }) + + legacySAPayload := `{ + "name": "Test Service Account 2", + "role": "Viewer" + }` + + rsp := apis.DoRequest(helper, apis.RequestParams{ + User: helper.Org1.Admin, + Method: "POST", + Path: "/api/serviceaccounts", + Body: []byte(legacySAPayload), + }, &serviceaccounts.ServiceAccountDTO{}) + + require.NotNil(t, rsp) + require.Equal(t, 201, rsp.Response.StatusCode) + require.NotEmpty(t, rsp.Result.UID) + + sa, err := saClient.Resource.Get(ctx, rsp.Result.UID, metav1.GetOptions{}) + require.NoError(t, err) + require.NotNil(t, sa) + + saSpec := sa.Object["spec"].(map[string]interface{}) + require.Equal(t, "Test Service Account 2", saSpec["title"]) + require.Equal(t, false, saSpec["disabled"]) + require.Empty(t, saSpec["plugin"]) + + require.Equal(t, rsp.Result.UID, sa.GetName()) + require.Equal(t, "default", sa.GetNamespace()) + }) +} diff --git a/pkg/tests/apis/iam/testdata/serviceaccount-test-create-v0.yaml b/pkg/tests/apis/iam/testdata/serviceaccount-test-create-v0.yaml new file mode 100644 index 00000000000..96b244fe2e7 --- /dev/null +++ b/pkg/tests/apis/iam/testdata/serviceaccount-test-create-v0.yaml @@ -0,0 +1,9 @@ +apiVersion: iam.grafana.app/v0alpha1 +kind: ServiceAccount +metadata: + name: test-sa-1 +spec: + title: "Test Service Account 1" + disabled: false + role: Editor + \ No newline at end of file diff --git a/pkg/tests/apis/iam/testdata/serviceaccount-test-external-v0.yaml b/pkg/tests/apis/iam/testdata/serviceaccount-test-external-v0.yaml new file mode 100644 index 00000000000..4d17c7112a0 --- /dev/null +++ b/pkg/tests/apis/iam/testdata/serviceaccount-test-external-v0.yaml @@ -0,0 +1,9 @@ +apiVersion: iam.grafana.app/v0alpha1 +kind: ServiceAccount +metadata: + name: sa-external +spec: + title: "extsvc-grafana-plugin-name" + role: Viewer + plugin: grafana-plugin-name + \ No newline at end of file diff --git a/pkg/tests/apis/iam/testdata/serviceaccount-test-generate-name-v0.yaml b/pkg/tests/apis/iam/testdata/serviceaccount-test-generate-name-v0.yaml new file mode 100644 index 00000000000..0578ea017c0 --- /dev/null +++ b/pkg/tests/apis/iam/testdata/serviceaccount-test-generate-name-v0.yaml @@ -0,0 +1,7 @@ +apiVersion: iam.grafana.app/v0alpha1 +kind: ServiceAccount +metadata: + generateName: sa- +spec: + title: Test Service Account with GenerateName + role: Viewer diff --git a/pkg/tests/apis/iam/testdata/serviceaccount-test-higher-role-v0.yaml b/pkg/tests/apis/iam/testdata/serviceaccount-test-higher-role-v0.yaml new file mode 100644 index 00000000000..59512f73bfe --- /dev/null +++ b/pkg/tests/apis/iam/testdata/serviceaccount-test-higher-role-v0.yaml @@ -0,0 +1,7 @@ +apiVersion: iam.grafana.app/v0alpha1 +kind: ServiceAccount +metadata: + name: sa-with-higher-role +spec: + title: SA with higher role + role: Admin diff --git a/pkg/tests/apis/iam/testdata/serviceaccount-test-invalid-role-v0.yaml b/pkg/tests/apis/iam/testdata/serviceaccount-test-invalid-role-v0.yaml new file mode 100644 index 00000000000..6ed214b6b87 --- /dev/null +++ b/pkg/tests/apis/iam/testdata/serviceaccount-test-invalid-role-v0.yaml @@ -0,0 +1,7 @@ +apiVersion: iam.grafana.app/v0alpha1 +kind: ServiceAccount +metadata: + name: sa-with-invalid-role +spec: + title: SA with invalid role + role: InvalidRole diff --git a/pkg/tests/apis/iam/testdata/serviceaccount-test-no-title-v0.yaml b/pkg/tests/apis/iam/testdata/serviceaccount-test-no-title-v0.yaml new file mode 100644 index 00000000000..d1b66591354 --- /dev/null +++ b/pkg/tests/apis/iam/testdata/serviceaccount-test-no-title-v0.yaml @@ -0,0 +1,7 @@ +apiVersion: iam.grafana.app/v0alpha1 +kind: ServiceAccount +metadata: + name: sa-with-no-title +spec: + title: "" + role: Viewer diff --git a/pkg/tests/apis/iam/testdata/user-test-create-v0.yaml b/pkg/tests/apis/iam/testdata/user-test-create-v0.yaml index 529f90286d3..cd2545e070f 100644 --- a/pkg/tests/apis/iam/testdata/user-test-create-v0.yaml +++ b/pkg/tests/apis/iam/testdata/user-test-create-v0.yaml @@ -7,4 +7,5 @@ spec: email: testuser1@example123.com login: testuser1 name: Test User 1 - provisioned: false \ No newline at end of file + provisioned: false + \ No newline at end of file diff --git a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json index d2b3c474888..f8acfca25ec 100644 --- a/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/iam.grafana.app-v0alpha1.json @@ -89,6 +89,98 @@ ], "description": "list objects of kind ServiceAccount", "operationId": "listServiceAccount", + "parameters": [ + { + "name": "allowWatchBookmarks", + "in": "query", + "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "watch", + "in": "query", + "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], "responses": { "200": { "description": "OK", @@ -128,52 +220,285 @@ "kind": "ServiceAccount" } }, + "post": { + "tags": [ + "ServiceAccount" + ], + "description": "create a ServiceAccount", + "operationId": "createServiceAccount", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ServiceAccount" + } + }, + "delete": { + "tags": [ + "ServiceAccount" + ], + "description": "delete collection of ServiceAccount", + "operationId": "deletecollectionServiceAccount", + "parameters": [ + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "deletecollection", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ServiceAccount" + } + }, "parameters": [ - { - "name": "allowWatchBookmarks", - "in": "query", - "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, { "name": "namespace", "in": "path", @@ -192,51 +517,6 @@ "type": "string", "uniqueItems": true } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "watch", - "in": "query", - "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", - "schema": { - "type": "boolean", - "uniqueItems": true - } } ] }, @@ -276,6 +556,330 @@ "kind": "ServiceAccount" } }, + "put": { + "tags": [ + "ServiceAccount" + ], + "description": "replace the specified ServiceAccount", + "operationId": "replaceServiceAccount", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ServiceAccount" + } + }, + "delete": { + "tags": [ + "ServiceAccount" + ], + "description": "delete a ServiceAccount", + "operationId": "deleteServiceAccount", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "delete", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ServiceAccount" + } + }, + "patch": { + "tags": [ + "ServiceAccount" + ], + "description": "partially update the specified ServiceAccount", + "operationId": "updateServiceAccount", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccount" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "iam.grafana.app", + "version": "v0alpha1", + "kind": "ServiceAccount" + } + }, "parameters": [ { "name": "name", @@ -2386,14 +2990,24 @@ "com.github.grafana.grafana.apps.iam.pkg.apis.iam.v0alpha1.ServiceAccountSpec": { "type": "object", "required": [ - "title", - "disabled" + "disabled", + "plugin", + "role", + "title" ], "properties": { "disabled": { "type": "boolean", "default": false }, + "plugin": { + "type": "string", + "default": "" + }, + "role": { + "type": "string", + "default": "" + }, "title": { "type": "string", "default": "" @@ -4492,14 +5106,24 @@ "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1.ServiceAccountSpec": { "type": "object", "required": [ - "title", - "disabled" + "disabled", + "plugin", + "role", + "title" ], "properties": { "disabled": { "type": "boolean", "default": false }, + "plugin": { + "type": "string", + "default": "" + }, + "role": { + "type": "string", + "default": "" + }, "title": { "type": "string", "default": "" diff --git a/pkg/tests/utils.go b/pkg/tests/utils.go index b8eba7b5f39..28ac3a7a2ec 100644 --- a/pkg/tests/utils.go +++ b/pkg/tests/utils.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "net/url" "os" - "strings" "testing" "github.com/go-openapi/strfmt" @@ -25,16 +24,6 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -func SkipIntegrationTestInShortMode(t testing.TB) { - t.Helper() - if !strings.HasPrefix(t.Name(), "TestIntegration") { - t.Fatal("test is not an integration test") - } - if testing.Short() { - t.Skip("skipping integration test in short mode") - } -} - func CreateUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() diff --git a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go index 52d3c7b8720..072a625a241 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/postgres_pgx_test.go @@ -1408,5 +1408,153 @@ func TestIntegrationPostgresPGX(t *testing.T) { require.NotNil(t, frames[0].Fields) require.Empty(t, frames[0].Fields) }) + + t.Run("Should handle multiple result sets without panicking", func(t *testing.T) { + // Create a test table for the panic scenario test + sql := ` + DROP TABLE IF EXISTS test_multi_results; + CREATE TABLE test_multi_results( + id integer, + name text, + value numeric + ); + INSERT INTO test_multi_results VALUES + (1, 'test1', 10.5), + (2, 'test2', 20.7), + (3, 'test3', 30.2); + ` + _, err := p.Exec(t.Context(), sql) + require.NoError(t, err) + + t.Run("Should handle compatible multiple result sets", func(t *testing.T) { + // This query returns multiple result sets with the same structure + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + JSON: []byte(`{ + "rawSql": "SELECT id, name FROM test_multi_results WHERE id <= 2; SELECT id, name FROM test_multi_results WHERE id >= 2;", + "format": "table" + }`), + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(1 * time.Hour), + }, + }, + }, + } + + // This should not panic and should work correctly + resp, err := exe.QueryDataPGX(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + + // The frame should be properly constructed from both SELECT results + frame := frames[0] + require.Equal(t, 2, len(frame.Fields)) // id, name from both queries + require.Equal(t, "id", frame.Fields[0].Name) + require.Equal(t, "name", frame.Fields[1].Name) + require.Equal(t, 4, frame.Rows()) // 2 rows from first result + 2 rows from second result + }) + + t.Run("Should return error for incompatible multiple result sets", func(t *testing.T) { + // This query returns multiple result sets with different structures - the kind that used to cause panic + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + JSON: []byte(`{ + "rawSql": "SELECT id, name FROM test_multi_results WHERE id <= 2; SELECT id, value FROM test_multi_results WHERE id >= 2;", + "format": "table" + }`), + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(1 * time.Hour), + }, + }, + }, + } + + // This should not panic anymore, but should return an error instead + resp, err := exe.QueryDataPGX(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + + // We expect an error about column mismatch, not a panic + require.Error(t, queryResult.Error) + require.Contains(t, queryResult.Error.Error(), "column name mismatch") + }) + + t.Run("Should return error for incompatible number of columns", func(t *testing.T) { + // This query returns multiple result sets with different number of columns + // This should fix the error "runtime error: index out of range [1] with length 1" + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + JSON: []byte(`{ + "rawSql": "SELECT id, name FROM test_multi_results WHERE id = 1; SELECT id FROM test_multi_results WHERE id = 1;", + "format": "table" + }`), + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(1 * time.Hour), + }, + }, + }, + } + + // This should not panic anymore, but should return an error instead + resp, err := exe.QueryDataPGX(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + + // We expect an error about incompatible result structure, not a panic + require.Error(t, queryResult.Error) + require.Contains(t, queryResult.Error.Error(), "incompatible result structure: expected 2 columns, got 1 columns") + }) + }) + + t.Run("Should handle queries with mixed statement types", func(t *testing.T) { + // This tests a scenario with UPDATE + SELECT that could cause the original panic + query := &backend.QueryDataRequest{ + Queries: []backend.DataQuery{ + { + RefID: "A", + JSON: []byte(`{ + "rawSql": "UPDATE test_multi_results SET name = 'updated' WHERE id = 1; SELECT id, name FROM test_multi_results WHERE id = 1;", + "format": "table" + }`), + TimeRange: backend.TimeRange{ + From: fromStart, + To: fromStart.Add(1 * time.Hour), + }, + }, + }, + } + + // This should not panic + resp, err := exe.QueryDataPGX(t.Context(), query) + require.NoError(t, err) + queryResult := resp.Responses["A"] + require.NoError(t, queryResult.Error) + + frames := queryResult.Frames + require.Len(t, frames, 1) + + // Should only contain data from the SELECT part + frame := frames[0] + require.Equal(t, 2, len(frame.Fields)) // id, name + require.Equal(t, 1, frame.Rows()) // 1 row + + // Verify the update worked + nameField := frame.Fields[1] + nameValue := nameField.At(0).(*string) + require.Equal(t, "updated", *nameValue) + }) }) } diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go index 3735bd78872..f28e6762c17 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_pgx.go @@ -388,35 +388,56 @@ func (e *DataSourceHandler) newProcessCfgPGX(queryContext context.Context, query } func convertResultsToFrame(results []*pgconn.Result, rowLimit int64) (*data.Frame, error) { - frame := data.Frame{} m := pgtype.NewMap() + // Find the first SELECT result to establish the frame structure + var firstSelectResult *pgconn.Result for _, result := range results { - // Skip non-select statements - if !result.CommandTag.Select() { - continue + if result.CommandTag.Select() { + firstSelectResult = result + break } - fields := make(data.Fields, len(result.FieldDescriptions)) - - fieldTypes, err := getFieldTypesFromDescriptions(result.FieldDescriptions, m) - if err != nil { - return nil, err - } - - for i, v := range result.FieldDescriptions { - fields[i] = data.NewFieldFromFieldType(fieldTypes[i], 0) - fields[i].Name = v.Name - } - // Create a new frame - frame = *data.NewFrame("", fields...) } - // Add rows to the frame + // If no SELECT results found, return empty frame + if firstSelectResult == nil { + return data.NewFrame(""), nil + } + + // Create frame structure based on the first SELECT result + fields := make(data.Fields, len(firstSelectResult.FieldDescriptions)) + fieldTypes, err := getFieldTypesFromDescriptions(firstSelectResult.FieldDescriptions, m) + if err != nil { + return nil, err + } + + for i, v := range firstSelectResult.FieldDescriptions { + fields[i] = data.NewFieldFromFieldType(fieldTypes[i], 0) + fields[i].Name = v.Name + } + frame := *data.NewFrame("", fields...) + + // Process all SELECT results, but validate column compatibility for _, result := range results { // Skip non-select statements if !result.CommandTag.Select() { continue } + + // Validate that this result has the same structure as the frame + if len(result.FieldDescriptions) != len(frame.Fields) { + return nil, fmt.Errorf("incompatible result structure: expected %d columns, got %d columns", + len(frame.Fields), len(result.FieldDescriptions)) + } + + // Validate column names and types match + for i, fd := range result.FieldDescriptions { + if fd.Name != frame.Fields[i].Name { + return nil, fmt.Errorf("column name mismatch at position %d: expected %q, got %q", + i, frame.Fields[i].Name, fd.Name) + } + } + fieldDescriptions := result.FieldDescriptions for rowIdx := range result.Rows { if rowIdx == int(rowLimit) { @@ -429,98 +450,25 @@ func convertResultsToFrame(results []*pgconn.Result, rowLimit int64) (*data.Fram row := make([]any, len(fieldDescriptions)) for colIdx, fd := range fieldDescriptions { rawValue := result.Rows[rowIdx][colIdx] - dataTypeOID := fd.DataTypeOID - format := fd.Format if rawValue == nil { row[colIdx] = nil continue } - // Convert based on type - switch fd.DataTypeOID { - case pgtype.Int2OID: - var d *int16 - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - row[colIdx] = d - case pgtype.Int4OID: - var d *int32 - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - row[colIdx] = d - case pgtype.Int8OID: - var d *int64 - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - row[colIdx] = d - case pgtype.NumericOID, pgtype.Float8OID, pgtype.Float4OID: - var d *float64 - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - row[colIdx] = d - case pgtype.BoolOID: - var d *bool - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - row[colIdx] = d - case pgtype.ByteaOID: - d, err := pgtype.ByteaCodec.DecodeValue(pgtype.ByteaCodec{}, m, dataTypeOID, format, rawValue) - if err != nil { - return nil, err - } - str := string(d.([]byte)) - row[colIdx] = &str - case pgtype.TimestampOID, pgtype.TimestamptzOID, pgtype.DateOID: - var d *time.Time - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - row[colIdx] = d - case pgtype.TimeOID, pgtype.TimetzOID: - var d *string - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - row[colIdx] = d - case pgtype.JSONOID, pgtype.JSONBOID: - var d *string - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - j := json.RawMessage(*d) - row[colIdx] = &j - default: - var d *string - scanPlan := m.PlanScan(dataTypeOID, format, &d) - err := scanPlan.Scan(rawValue, &d) - if err != nil { - return nil, err - } - row[colIdx] = d + convertedValue, err := convertPostgresValue(rawValue, fd, m) + if err != nil { + return nil, err } + row[colIdx] = convertedValue } + + // Validate row length matches frame field count before appending + if len(row) != len(frame.Fields) { + return nil, fmt.Errorf("row data length mismatch: expected %d values, got %d values", + len(frame.Fields), len(row)) + } + frame.AppendRow(row...) } } @@ -528,6 +476,96 @@ func convertResultsToFrame(results []*pgconn.Result, rowLimit int64) (*data.Fram return &frame, nil } +// convertPostgresValue converts a raw PostgreSQL value to the appropriate Go type +func convertPostgresValue(rawValue []byte, fd pgconn.FieldDescription, m *pgtype.Map) (interface{}, error) { + dataTypeOID := fd.DataTypeOID + format := fd.Format + + // Convert based on type + switch fd.DataTypeOID { + case pgtype.Int2OID: + var d *int16 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.Int4OID: + var d *int32 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.Int8OID: + var d *int64 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.NumericOID, pgtype.Float8OID, pgtype.Float4OID: + var d *float64 + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.BoolOID: + var d *bool + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.ByteaOID: + d, err := pgtype.ByteaCodec.DecodeValue(pgtype.ByteaCodec{}, m, dataTypeOID, format, rawValue) + if err != nil { + return nil, err + } + str := string(d.([]byte)) + return &str, nil + case pgtype.TimestampOID, pgtype.TimestamptzOID, pgtype.DateOID: + var d *time.Time + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.TimeOID, pgtype.TimetzOID: + var d *string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + case pgtype.JSONOID, pgtype.JSONBOID: + var d *string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + j := json.RawMessage(*d) + return &j, nil + default: + var d *string + scanPlan := m.PlanScan(dataTypeOID, format, &d) + err := scanPlan.Scan(rawValue, &d) + if err != nil { + return nil, err + } + return d, nil + } +} + func getFieldTypesFromDescriptions(fieldDescriptions []pgconn.FieldDescription, m *pgtype.Map) ([]data.FieldType, error) { fieldTypes := make([]data.FieldType, len(fieldDescriptions)) for i, v := range fieldDescriptions { diff --git a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_test.go b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_test.go index 4d511a53f25..f0f5f5b7a9b 100644 --- a/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_test.go +++ b/pkg/tsdb/grafana-postgresql-datasource/sqleng/sql_engine_test.go @@ -9,6 +9,8 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana-plugin-sdk-go/data/sqlutil" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -425,6 +427,246 @@ func TestSQLEngine(t *testing.T) { }) } +func TestConvertResultsToFrame(t *testing.T) { + // Import the pgx packages needed for testing + // These imports are included in the main file but need to be accessible for tests + t.Run("convertResultsToFrame with single result", func(t *testing.T) { + // Create mock field descriptions + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + {Name: "name", DataTypeOID: pgtype.TextOID}, + {Name: "value", DataTypeOID: pgtype.Float8OID}, + } + + // Create mock result data + mockRows := [][][]byte{ + {[]byte("1"), []byte("test1"), []byte("10.5")}, + {[]byte("2"), []byte("test2"), []byte("20.7")}, + } + + // Create mock result + result := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows, + } + result.CommandTag = pgconn.NewCommandTag("SELECT 2") + + results := []*pgconn.Result{result} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 3, len(frame.Fields)) + require.Equal(t, 2, frame.Rows()) + + // Verify field names + require.Equal(t, "id", frame.Fields[0].Name) + require.Equal(t, "name", frame.Fields[1].Name) + require.Equal(t, "value", frame.Fields[2].Name) + }) + + t.Run("convertResultsToFrame with multiple compatible results", func(t *testing.T) { + // Create mock field descriptions (same structure for both results) + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + {Name: "name", DataTypeOID: pgtype.TextOID}, + } + + // Create first result + mockRows1 := [][][]byte{ + {[]byte("1"), []byte("test1")}, + {[]byte("2"), []byte("test2")}, + } + result1 := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows1, + } + result1.CommandTag = pgconn.NewCommandTag("SELECT 2") + + // Create second result with same structure + mockRows2 := [][][]byte{ + {[]byte("3"), []byte("test3")}, + {[]byte("4"), []byte("test4")}, + } + result2 := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows2, + } + result2.CommandTag = pgconn.NewCommandTag("SELECT 2") + + results := []*pgconn.Result{result1, result2} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 2, len(frame.Fields)) + require.Equal(t, 4, frame.Rows()) // Should have rows from both results + + // Verify field names + require.Equal(t, "id", frame.Fields[0].Name) + require.Equal(t, "name", frame.Fields[1].Name) + }) + + t.Run("convertResultsToFrame with row limit", func(t *testing.T) { + // Create mock field descriptions + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + } + + // Create mock result data with 3 rows + mockRows := [][][]byte{ + {[]byte("1")}, + {[]byte("2")}, + {[]byte("3")}, + } + + result := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows, + } + result.CommandTag = pgconn.NewCommandTag("SELECT 3") + + results := []*pgconn.Result{result} + + // Set row limit to 2 + frame, err := convertResultsToFrame(results, 2) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 1, len(frame.Fields)) + require.Equal(t, 2, frame.Rows()) // Should be limited to 2 rows + + // Should have a notice about the limit + require.NotNil(t, frame.Meta) + require.Len(t, frame.Meta.Notices, 1) + require.Contains(t, frame.Meta.Notices[0].Text, "Results have been limited to 2") + }) + + t.Run("convertResultsToFrame with mixed SELECT and non-SELECT results", func(t *testing.T) { + // Create a non-SELECT result (should be skipped) + nonSelectResult := &pgconn.Result{} + nonSelectResult.CommandTag = pgconn.NewCommandTag("UPDATE 1") + + // Create a SELECT result + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + } + mockRows := [][][]byte{ + {[]byte("1")}, + } + selectResult := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows, + } + selectResult.CommandTag = pgconn.NewCommandTag("SELECT 1") + + results := []*pgconn.Result{nonSelectResult, selectResult} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 1, len(frame.Fields)) + require.Equal(t, 1, frame.Rows()) + }) + + t.Run("convertResultsToFrame with no SELECT results", func(t *testing.T) { + // Create only non-SELECT results + result1 := &pgconn.Result{} + result1.CommandTag = pgconn.NewCommandTag("UPDATE 1") + + result2 := &pgconn.Result{} + result2.CommandTag = pgconn.NewCommandTag("INSERT 1") + + results := []*pgconn.Result{result1, result2} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 0, len(frame.Fields)) + require.Equal(t, 0, frame.Rows()) + }) + + t.Run("convertResultsToFrame with multiple results and row limit per result", func(t *testing.T) { + // Create mock field descriptions (same structure for both results) + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + } + + // Create first result with 3 rows + mockRows1 := [][][]byte{ + {[]byte("1")}, + {[]byte("2")}, + {[]byte("3")}, + } + result1 := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows1, + } + result1.CommandTag = pgconn.NewCommandTag("SELECT 3") + + // Create second result with 3 rows + mockRows2 := [][][]byte{ + {[]byte("4")}, + {[]byte("5")}, + {[]byte("6")}, + } + result2 := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows2, + } + result2.CommandTag = pgconn.NewCommandTag("SELECT 3") + + results := []*pgconn.Result{result1, result2} + + // Set row limit to 2 (should limit each result to 2 rows) + frame, err := convertResultsToFrame(results, 2) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 1, len(frame.Fields)) + require.Equal(t, 4, frame.Rows()) // 2 rows from each result + + // Should have notices about the limit from both results + require.NotNil(t, frame.Meta) + require.Len(t, frame.Meta.Notices, 2) + require.Contains(t, frame.Meta.Notices[0].Text, "Results have been limited to 2") + require.Contains(t, frame.Meta.Notices[1].Text, "Results have been limited to 2") + }) + + t.Run("convertResultsToFrame handles null values correctly", func(t *testing.T) { + // Create mock field descriptions + fieldDescs := []pgconn.FieldDescription{ + {Name: "id", DataTypeOID: pgtype.Int4OID}, + {Name: "name", DataTypeOID: pgtype.TextOID}, + } + + // Create mock result data with null values + mockRows := [][][]byte{ + {[]byte("1"), nil}, // null name + {nil, []byte("test2")}, // null id + } + + result := &pgconn.Result{ + FieldDescriptions: fieldDescs, + Rows: mockRows, + } + result.CommandTag = pgconn.NewCommandTag("SELECT 2") + + results := []*pgconn.Result{result} + + frame, err := convertResultsToFrame(results, 1000) + require.NoError(t, err) + require.NotNil(t, frame) + require.Equal(t, 2, len(frame.Fields)) + require.Equal(t, 2, frame.Rows()) + + // Check that null values are handled correctly + // The exact representation depends on the field type, but should not panic + require.NotPanics(t, func() { + frame.Fields[0].At(1) // null id + frame.Fields[1].At(0) // null name + }) + }) +} + type testQueryResultTransformer struct { transformQueryErrorWasCalled bool } diff --git a/pkg/util/testutil/testutil.go b/pkg/util/testutil/testutil.go index 1ca3628a665..596690443d8 100644 --- a/pkg/util/testutil/testutil.go +++ b/pkg/util/testutil/testutil.go @@ -2,6 +2,7 @@ package testutil import ( "embed" + "strings" "testing" "time" ) @@ -28,3 +29,16 @@ func init() { panic("importing testing libraries in runtime code is not allowed") } } + +// SkipIntegrationTestInShortMode skips the integration test if it is running in short mode. +// This function fails is the test is not an integration test as defined in Grafana (i.e. test +// starting with TestIntegration prefix). +func SkipIntegrationTestInShortMode(t testing.TB) { + t.Helper() + if !strings.HasPrefix(t.Name(), "TestIntegration") { + t.Fatal("test is not an integration test") + } + if testing.Short() { + t.Skip("skipping integration test in short mode") + } +} diff --git a/public/app/api/clients/folder/v1beta1/hooks.ts b/public/app/api/clients/folder/v1beta1/hooks.ts index fca72781955..e79e3bab4c5 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.ts @@ -32,6 +32,7 @@ import { import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services'; import { refetchChildren, refreshParents } from '../../../../features/browse-dashboards/state/actions'; import { GENERAL_FOLDER_UID } from '../../../../features/search/constants'; +import { deletedDashboardsCache } from '../../../../features/search/service/deletedDashboardsCache'; import { useDispatch } from '../../../../types/store'; import { useLazyGetDisplayMappingQuery } from '../../iam/v0alpha1'; @@ -298,6 +299,7 @@ export function useCreateFolder() { const result = await createFolder(payload); refresh({ childrenOf: folder.parentUid }); + deletedDashboardsCache.clear(); return { ...result, diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx index 393a2a6ca97..349f7bfae0f 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryScene.tsx @@ -65,6 +65,7 @@ export const CentralAlertHistoryScene = ({ to: 'now', }, hideFilters, + hideAlertRuleColumn, }: CentralAlertHistorySceneV1Props = {}) => { //track the loading of the central alert state history @@ -134,12 +135,12 @@ export const CentralAlertHistoryScene = ({ children: [ getEventsScenesFlexItem(), new SceneFlexItem({ - body: new HistoryEventsListObject({}), + body: new HistoryEventsListObject({ hideAlertRuleColumn }), }), ], }), }); - }, [defaultLabelsFilter, defaultTimeRange, hideFilters]); + }, [defaultLabelsFilter, defaultTimeRange, hideFilters, hideAlertRuleColumn]); // we need to call this to sync the url with the scene state const isUrlSyncInitialized = useUrlSync(scene); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx index a17f390094a..3a0e9623356 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx @@ -9,6 +9,7 @@ import { CustomVariable, SceneComponentProps, SceneObjectBase, + SceneObjectState, TextBoxVariable, VariableDependencyConfig, VariableValue, @@ -55,6 +56,7 @@ interface HistoryEventsListProps { valueInStateToFilter: VariableValue; valueInStateFromFilter: VariableValue; addFilter: (key: string, value: string, type: FilterType) => void; + hideAlertRuleColumn?: boolean; } export const HistoryEventsList = ({ timeRange, @@ -62,6 +64,7 @@ export const HistoryEventsList = ({ valueInStateToFilter, valueInStateFromFilter, addFilter, + hideAlertRuleColumn, }: HistoryEventsListProps) => { const from = timeRange?.from.unix(); const to = timeRange?.to.unix(); @@ -114,7 +117,12 @@ export const HistoryEventsList = ({ )} - + ); }; @@ -129,15 +137,16 @@ interface HistoryLogEventsProps { logRecords: LogRecord[]; addFilter: (key: string, value: string, type: FilterType) => void; timeRange: TimeRange; + hideAlertRuleColumn?: boolean; } -function HistoryLogEvents({ logRecords, addFilter, timeRange }: HistoryLogEventsProps) { +function HistoryLogEvents({ logRecords, addFilter, timeRange, hideAlertRuleColumn }: HistoryLogEventsProps) { const { page, pageItems, numberOfPages, onPageChange } = usePagination(logRecords, 1, PAGE_SIZE); const styles = useStyles2(getStyles); return (
- +
@@ -151,6 +160,7 @@ function HistoryLogEvents({ logRecords, addFilter, timeRange }: HistoryLogEvents record={record} addFilter={addFilter} timeRange={timeRange} + hideAlertRuleColumn={hideAlertRuleColumn} /> ); })} @@ -161,7 +171,7 @@ function HistoryLogEvents({ logRecords, addFilter, timeRange }: HistoryLogEvents ); } -function ListHeader() { +function ListHeader({ hideAlertRuleColumn }: { hideAlertRuleColumn?: boolean }) { const styles = useStyles2(getStyles); return (
@@ -175,11 +185,13 @@ function ListHeader() { State
-
- - Alert rule - -
+ {!hideAlertRuleColumn && ( +
+ + Alert rule + +
+ )}
Instance @@ -193,8 +205,9 @@ interface EventRowProps { record: LogRecord; addFilter: (key: string, value: string, type: FilterType) => void; timeRange: TimeRange; + hideAlertRuleColumn?: boolean; } -function EventRow({ record, addFilter, timeRange }: EventRowProps) { +function EventRow({ record, addFilter, timeRange, hideAlertRuleColumn }: EventRowProps) { const styles = useStyles2(getStyles); const [isCollapsed, setIsCollapsed] = useState(true); function onLabelClick(label: string, value: string) { @@ -220,9 +233,11 @@ function EventRow({ record, addFilter, timeRange }: EventRowProps) {
-
- {record.line.labels ? : null} -
+ {!hideAlertRuleColumn && ( +
+ {record.line.labels ? : null} +
+ )}
@@ -521,7 +536,11 @@ export const getStyles = (theme: GrafanaTheme2) => { * This is a scene object that displays a list of history events. */ -export class HistoryEventsListObject extends SceneObjectBase { +interface HistoryEventsListObjectState extends SceneObjectState { + hideAlertRuleColumn?: boolean; +} + +export class HistoryEventsListObject extends SceneObjectBase { public static Component = HistoryEventsListObjectRenderer; protected _variableDependency = new VariableDependencyConfig(this, { @@ -533,7 +552,7 @@ export type FilterType = 'label' | 'stateFrom' | 'stateTo'; export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps) { // This make sure the component is re-rendered when the variables change - model.useState(); + const { hideAlertRuleColumn } = model.useState(); const { value: timeRange } = sceneGraph.getTimeRange(model).useState(); // get time range from scene graph @@ -568,6 +587,7 @@ export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps ); } else { diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index c55a6b48ea3..d9788cda50b 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -324,14 +324,14 @@ export const browseDashboardsAPI = createApi({ forceDeleteRules: false, }, }); - // Clear the deleted dashboards cache since deleting a folder also deletes its dashboards - deletedDashboardsCache.clear(); } return { data: undefined }; }, onQueryStarted: ({ folderUIDs }, { queryFulfilled, dispatch }) => { queryFulfilled.then(() => { dispatch(refreshParents(folderUIDs)); + // Clear the deleted dashboards cache since deleting a folder also deletes its dashboards + deletedDashboardsCache.clear(); }); }, }), diff --git a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx index 6db8c35928f..187f0eb1543 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx @@ -28,7 +28,7 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P folder: Object.keys(selectedItems.folder).length, }, source: 'browse_dashboards', - restore_enabled: false, + restore_enabled: Boolean(config.featureToggles.restoreDashboards), }); setIsDeleting(true); try { diff --git a/public/app/features/browse-dashboards/components/PermanentlyDeleteModal.tsx b/public/app/features/browse-dashboards/components/PermanentlyDeleteModal.tsx deleted file mode 100644 index 17bc1df184a..00000000000 --- a/public/app/features/browse-dashboards/components/PermanentlyDeleteModal.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Trans, t } from '@grafana/i18n'; -import { reportInteraction } from '@grafana/runtime'; -import { ConfirmModal, Text } from '@grafana/ui'; - -interface PermanentlyDeleteModalProps { - isOpen: boolean; - onConfirm: () => Promise; - onDismiss: () => void; - selectedDashboards: string[]; - isLoading: boolean; -} - -export const PermanentlyDeleteModal = ({ - onConfirm, - onDismiss, - selectedDashboards, - isLoading, - ...props -}: PermanentlyDeleteModalProps) => { - const numberOfDashboards = selectedDashboards.length; - - const onDelete = async () => { - reportInteraction('grafana_delete_permanently_confirm_clicked', { - item_counts: { - dashboard: numberOfDashboards, - }, - }); - await onConfirm(); - onDismiss(); - }; - return ( - - - This action will delete {{ numberOfDashboards }} dashboards. - -
- } - title={t('recently-deleted.permanently-delete-modal.title', 'Permanently Delete Dashboards')} - confirmationText={t('recently-deleted.permanently-delete-modal.confirm-text', 'Delete')} - confirmText={ - isLoading - ? t('recently-deleted.permanently-delete-modal.delete-loading', 'Deleting...') - : t('recently-deleted.permanently-delete-modal.delete-button', 'Delete') - } - confirmButtonVariant="destructive" - onConfirm={onDelete} - onDismiss={onDismiss} - {...props} - /> - ); -}; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 70dee7c9c67..5a1b3624a2b 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -1,11 +1,11 @@ -import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, VizPanel } from '@grafana/scenes'; +import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph } from '@grafana/scenes'; import { ElementSelectionContextItem, ElementSelectionContextState, ElementSelectionOnSelectOptions, } from '@grafana/ui'; -import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; +import { TabItem } from '../scene/layout-tabs/TabItem'; import { isRepeatCloneOrChildOf } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; @@ -44,6 +44,12 @@ export class DashboardEditPane extends SceneObjectBase { this.addActivationHandler(this.onActivate.bind(this)); } + private panelEditAction?: DashboardEditActionEvent; + + public setPanelEditAction(editAction: DashboardEditActionEvent) { + this.panelEditAction = editAction; + } + private onActivate() { const dashboard = getDashboardSceneFor(this); @@ -76,6 +82,22 @@ export class DashboardEditPane extends SceneObjectBase { this.forceRender(); }) ); + + if (this.panelEditAction) { + this.performPanelEditAction(this.panelEditAction); + this.panelEditAction = undefined; + } + } + + private performPanelEditAction(action: DashboardEditActionEvent) { + // Some layout items are not yet active when leaving panel edit, let's wait for them to activate + if (!action.payload.source.isActive) { + trySwitchingToSourceTab(action.payload.source); + setTimeout(() => this.performPanelEditAction(action)); + return; + } + + action.payload.source.publishEvent(action, true); } /** @@ -93,15 +115,6 @@ export class DashboardEditPane extends SceneObjectBase { this.performAction(action); this.setState({ undoStack: [...this.state.undoStack, action] }); - - // Notify repeaters that something changed - if (action.source instanceof VizPanel) { - const layoutElement = action.source.parent!; - - if (isDashboardLayoutItem(layoutElement) && layoutElement.editingCompleted) { - layoutElement.editingCompleted(true); - } - } } /** @@ -258,3 +271,19 @@ export class DashboardEditPane extends SceneObjectBase { this.state.selection?.markAsNewElement(); } } + +function trySwitchingToSourceTab(source: SceneObject) { + if (source.parent === undefined) { + return; + } + + if (source.parent instanceof TabItem) { + const tab = source.parent; + const tabsLayout = source.parent.getParentLayout(); + if (tabsLayout.state.currentTabSlug !== tab.getSlug()) { + tabsLayout.switchToTab(tab); + } + } else { + trySwitchingToSourceTab(source.parent); + } +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 1711697d937..72f0d6c97da 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -24,6 +24,7 @@ import { DashboardEditActionEvent } from '../edit-pane/shared'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { getPanelChanges } from '../saving/getDashboardChanges'; import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel'; +import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { @@ -96,13 +97,50 @@ export class PanelEditor extends SceneObjectBase { this.waitForPlugin(); return () => { - this._layoutItem.editingCompleted?.(this.state.isDirty || this._changesHaveBeenMade); + this.commitChanges(); if (deactivateParents) { deactivateParents(); } }; } + + private commitChanges() { + if (!this.state.isDirty && !this._changesHaveBeenMade) { + // Nothing to commit + return; + } + + const layoutItem = this._layoutItem; + const changedState = layoutItem.state; + const originalState = this._layoutItemState!; + + // Temp fix for old edit mode + if (this._layoutItem instanceof DashboardGridItem && !config.featureToggles.dashboardNewLayouts) { + this._layoutItem.handleEditChange(); + return; + } + + const editAction = new DashboardEditActionEvent({ + description: t('dashboard.edit-actions.panel-edit', 'Panel changes'), + source: this._layoutItem, + perform: () => { + // Because panel edit makes changes directly to layout item & panel + // we only need to do this in case we want to re-perform after undo + if (layoutItem.state !== changedState) { + layoutItem.setState(changedState); + } + }, + undo: () => layoutItem!.setState(originalState), + }); + + // sadly we cannot publish this event directly here as the main dashboard edit / undo system + // is not active while panel edit is active so we have to let the edit pane (which owns undo/redo) + // publish this event when it activates + const dashboard = getDashboardSceneFor(this); + dashboard.state.editPane.setPanelEditAction(editAction); + } + private waitForPlugin(retry = 0) { const panel = this.getPanel(); const plugin = panel.getPlugin(); @@ -166,8 +204,6 @@ export class PanelEditor extends SceneObjectBase { if (this.state.isInitializing) { this.setOriginalState(this.state.panelRef); - this._layoutItem.editingStarted?.(); - this._setupChangeDetection(); this._updateDataPane(plugin); diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx index f98461ca936..953f7b7dbb8 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItem.tsx @@ -14,6 +14,7 @@ import { import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; +import { DashboardStateChangedEvent } from '../../edit-pane/shared'; import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; @@ -54,6 +55,8 @@ export class AutoGridItem extends SceneObjectBase implements this.performRepeat(); } + this._subs.add(this.subscribeToEvent(DashboardStateChangedEvent, () => this.handleEditChange())); + const deactivate = this.state.conditionalRendering?.activate(); return () => { @@ -167,16 +170,8 @@ export class AutoGridItem extends SceneObjectBase implements }; } - public editingStarted() { - if (!this.state.variableName) { - return; - } - } - - public editingCompleted(withChanges: boolean) { - if (withChanges) { - this._prevRepeatValues = undefined; - } + public handleEditChange() { + this._prevRepeatValues = undefined; this.performRepeat(); } diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx index febb8bbaee5..0951b078cf8 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.test.tsx @@ -3,6 +3,7 @@ import { setPluginImportUtils } from '@grafana/runtime'; import { SceneGridLayout, SceneVariableSet, TestVariable, VizPanel } from '@grafana/scenes'; import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants'; +import { DashboardEditActionEvent } from '../../edit-pane/shared'; import { activateFullSceneTree, buildPanelRepeaterScene } from '../../utils/test-utils'; import { DashboardScene } from '../DashboardScene'; @@ -130,89 +131,13 @@ describe('PanelRepeaterGridItem', () => { vizPanel.setState({ title: 'Changed' }); - panel.editingCompleted(true); - // mimic returning to dashboard activateFullSceneTree(scene); - await new Promise((r) => setTimeout(r, 10)); - - expect(panel.state.repeatedPanels?.length).toBe(4); - expect((panel.state.repeatedPanels![0] as VizPanel).state.title).toBe('Changed'); - }); - - it('Should only redo the repeat of an edited panel, not all panels in dashboard', async () => { - const panel = new DashboardGridItem({ - variableName: 'server', - repeatedPanels: [], - body: new VizPanel({ - title: 'Panel $server', - }), - }); - - const panel2 = new DashboardGridItem({ - variableName: 'server', - repeatedPanels: [], - body: new VizPanel({ - title: 'Panel $server 2', - }), - }); - - const variable = new TestVariable({ - name: 'server', - query: 'A.*', - value: ALL_VARIABLE_VALUE, - text: ALL_VARIABLE_TEXT, - isMulti: true, - includeAll: true, - delayMs: 0, - optionsToReturn: [ - { label: 'A', value: '1' }, - { label: 'B', value: '2' }, - { label: 'C', value: '3' }, - { label: 'D', value: '4' }, - { label: 'E', value: '5' }, - ], - }); - - const scene = new DashboardScene({ - $variables: new SceneVariableSet({ - variables: [variable], - }), - body: new DefaultGridLayoutManager({ - grid: new SceneGridLayout({ - children: [panel, panel2], - }), - }), - }); - - const deactivate = activateFullSceneTree(scene); + panel.publishEvent(new DashboardEditActionEvent({ source: panel, perform: () => {}, undo: () => {} }), true); await new Promise((r) => setTimeout(r, 10)); - expect(panel.state.repeatedPanels?.length).toBe(4); - - const vizPanel = panel.state.body as VizPanel; - - expect(vizPanel.state.title).toBe('Panel $server'); - - // mimic going to panel edit - deactivate(); - - await new Promise((r) => setTimeout(r, 10)); - - vizPanel.setState({ title: 'Changed' }); - - panel.editingCompleted(true); - - const performRepeatMock = jest.spyOn(panel, 'performRepeat'); - - // mimic returning to dashboard - activateFullSceneTree(scene); - - await new Promise((r) => setTimeout(r, 10)); - - expect(performRepeatMock).toHaveBeenCalledTimes(1); // only for the edited panel expect(panel.state.repeatedPanels?.length).toBe(4); expect((panel.state.repeatedPanels![0] as VizPanel).state.title).toBe('Changed'); }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index 13f3cec389e..5365993335c 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -17,6 +17,7 @@ import { import { GRID_COLUMN_COUNT } from 'app/core/constants'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; +import { DashboardStateChangedEvent } from '../../edit-pane/shared'; import { getCloneKey, getLocalVariableValueSet } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; import { scrollCanvasElementIntoView, scrollIntoView } from '../layouts-shared/scrollCanvasElementIntoView'; @@ -61,6 +62,8 @@ export class DashboardGridItem private _activationHandler() { this.handleVariableName(); + this._subs.add(this.subscribeToEvent(DashboardStateChangedEvent, () => this.handleEditChange())); + return () => { this._handleGridSizeUnsubscribe(); }; @@ -114,26 +117,21 @@ export class DashboardGridItem this.setState({ body }); } - public editingStarted() { - if (!this.state.variableName) { - return; - } - } + public handleEditChange() { + this._prevRepeatValues = undefined; - public editingCompleted(withChanges: boolean) { - if (withChanges) { - this._prevRepeatValues = undefined; - if (this.parent instanceof SceneGridRow) { - const repeater = this.parent.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior); - if (repeater) { - repeater.resetPrevRepeatValues(); - } + if (this.parent instanceof SceneGridRow) { + const repeater = this.parent.state.$behaviors?.find((b) => b instanceof RowRepeaterBehavior); + if (repeater) { + repeater.resetPrevRepeatValues(); } } if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) { this.setState({ width: GRID_COLUMN_COUNT }); } + + this.performRepeat(); } public performRepeat() { diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts index 44391c27c33..c7bac407208 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts @@ -15,16 +15,6 @@ export interface DashboardLayoutItem extends SceneObject { */ getOptions?(): OptionsPaneCategoryDescriptor[]; - /** - * When going into panel edit - **/ - editingStarted?(): void; - - /** - * When coming out of panel edit - */ - editingCompleted?(withChanges: boolean): void; - /** * Change inner body / viz panel */ diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx index 32e0fecf461..08cffbb3259 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneOptions.tsx @@ -204,7 +204,4 @@ const getStyles = (theme: GrafanaTheme2) => ({ borderTop: 'none', flexGrow: 1, }), - angularDeprecationWrapper: css({ - padding: theme.spacing(1), - }), }); diff --git a/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx b/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx index e50998e3a09..25852019e1e 100644 --- a/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx +++ b/public/app/features/plugins/admin/components/PluginListItemBadges.test.tsx @@ -98,14 +98,4 @@ describe('PluginListItemBadges', () => { ); expect(screen.queryByText(/update available/i)).toBeNull(); }); - - it('does not render an angular badge (when plugin is angular), because its not loaded', () => { - render(); - expect(screen.queryByText(/angular/i)).not.toBeInTheDocument(); - }); - - it('does not render an angular badge (when plugin is not angular)', () => { - render(); - expect(screen.queryByText(/angular/i)).toBeNull(); - }); }); diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index 255a8a6161c..d5740d50b5a 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -11,6 +11,7 @@ import { import { config } from '@grafana/runtime'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; +import * as errors from './errors'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; @@ -28,7 +29,7 @@ jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), // Manually set the dev mode to false - // (to make sure that by default we are testing a production scneario) + // (to make sure that by default we are testing a production scenario) isGrafanaDevMode: jest.fn().mockReturnValue(false), })); @@ -486,7 +487,7 @@ describe('usePluginComponents()', () => { // Trying to render an extension point that is not defined in the plugin meta // (No restrictions due to isGrafanaDevMode() = false) - let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); expect(result.current.components.length).toBe(1); expect(log.error).not.toHaveBeenCalled(); }); @@ -529,7 +530,7 @@ describe('usePluginComponents()', () => { // Trying to render an extension point that is not defined in the plugin meta // (No restrictions due to being a core plugin) - let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); expect(result.current.components.length).toBe(1); expect(log.error).not.toHaveBeenCalled(); }); @@ -552,7 +553,7 @@ describe('usePluginComponents()', () => { // Trying to render an extension point that is not defined in the plugin meta // (No restrictions due to isGrafanaDevMode() = false) - let { result } = renderHook(() => usePluginComponents({ extensionPointId: 'invalid-extension-point-id' }), { + const { result } = renderHook(() => usePluginComponents({ extensionPointId: 'invalid-extension-point-id' }), { wrapper, }); expect(result.current.components.length).toBe(0); @@ -581,7 +582,7 @@ describe('usePluginComponents()', () => { ], }); - let { result } = renderHook( + const { result } = renderHook( () => usePluginComponents({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu }), { wrapper, @@ -615,7 +616,7 @@ describe('usePluginComponents()', () => { ], }); - let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { + const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper, }); expect(result.current.components.length).toBe(0); @@ -655,9 +656,10 @@ describe('usePluginComponents()', () => { }); // Trying to render an extension point that is not defined in the plugin meta - let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); expect(result.current.components.length).toBe(0); expect(log.error).toHaveBeenCalled(); + expect(log.error).toHaveBeenCalledWith(errors.EXTENSION_POINT_META_INFO_MISSING); }); it('should not log an error if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { @@ -699,7 +701,7 @@ describe('usePluginComponents()', () => { }); // Trying to render an extension point that is not defined in the plugin meta - let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper }); expect(result.current.components.length).toBe(0); expect(log.error).toHaveBeenCalled(); }); diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index d07a2e5ac07..8126838f87a 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -10,12 +10,10 @@ import { import { UsePluginComponentsOptions, UsePluginComponentsResult } from '@grafana/runtime'; import { useAddedComponentsRegistry } from './ExtensionRegistriesContext'; -import * as errors from './errors'; -import { log } from './logs/log'; import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; import { useLoadAppPlugins } from './useLoadAppPlugins'; -import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; -import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; +import { generateExtensionId, getExtensionPointPluginDependencies } from './utils'; +import { validateExtensionPoint } from './validateExtensionPoint'; // Returns an array of component extensions for the given extension point export function usePluginComponents({ @@ -28,47 +26,17 @@ export function usePluginComponents({ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); return useMemo(() => { - const isInsidePlugin = Boolean(pluginContext); - const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false; + const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins }); + + if (result) { + return { + isLoading: result.isLoading, + components: [], + }; + } + const components: Array> = []; const extensionsByPlugin: Record = {}; - const pluginId = pluginContext?.meta.id ?? ''; - const pointLog = log.child({ - pluginId, - extensionPointId, - }); - - // Don't show extensions if the extension-point id is invalid in DEV mode - if ( - isGrafanaDevMode() && - !isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog }) - ) { - return { - isLoading: false, - components: [], - }; - } - - // Don't show extensions if the extension-point misses meta info (plugin.json) in DEV mode - if ( - isGrafanaDevMode() && - !isCoreGrafanaPlugin && - pluginContext && - isExtensionPointMetaInfoMissing(extensionPointId, pluginContext) - ) { - pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); - return { - isLoading: false, - components: [], - }; - } - - if (isLoadingAppPlugins) { - return { - isLoading: true, - components: [], - }; - } for (const registryItem of registryState?.[extensionPointId] ?? []) { const { pluginId } = registryItem; diff --git a/public/app/features/plugins/extensions/usePluginFunctions.test.tsx b/public/app/features/plugins/extensions/usePluginFunctions.test.tsx new file mode 100644 index 00000000000..19e3a20fc83 --- /dev/null +++ b/public/app/features/plugins/extensions/usePluginFunctions.test.tsx @@ -0,0 +1,502 @@ +import { act, renderHook } from '@testing-library/react'; + +import { + PluginContextProvider, + PluginExtensionPoints, + PluginLoadingStrategy, + PluginMeta, + PluginType, +} from '@grafana/data'; +import { config } from '@grafana/runtime'; + +import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; +import * as errors from './errors'; +import { log } from './logs/log'; +import { resetLogMock } from './logs/testUtils'; +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry'; +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './registry/types'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; +import { usePluginFunctions } from './usePluginFunctions'; +import { isGrafanaDevMode } from './utils'; + +jest.mock('./useLoadAppPlugins'); +jest.mock('app/features/plugins/pluginSettings', () => ({ + getPluginSettings: jest.fn().mockResolvedValue({ + id: 'my-app-plugin', + enabled: true, + jsonData: {}, + type: 'panel', + name: 'My App Plugin', + module: 'app/plugins/my-app-plugin/module', + }), +})); + +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + + // Manually set the dev mode to false + // (to make sure that by default we are testing a production scenario) + isGrafanaDevMode: jest.fn().mockReturnValue(false), +})); + +jest.mock('./logs/log', () => { + const { createLogMock } = jest.requireActual('./logs/testUtils'); + const original = jest.requireActual('./logs/log'); + + return { + ...original, + log: createLogMock(), + }; +}); + +describe('usePluginFunctions()', () => { + let registries: PluginExtensionRegistries; + let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; + let pluginMeta: PluginMeta; + const pluginId = 'myorg-extensions-app'; + const extensionPointId = `${pluginId}/extension-point/v1`; + + beforeEach(() => { + jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), + }; + resetLogMock(log); + + pluginMeta = { + id: pluginId, + name: 'Extensions App', + type: PluginType.app, + module: '', + baseUrl: '', + info: { + author: { + name: 'MyOrg', + }, + description: 'App for testing extensions', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '2023-10-26T18:25:01Z', + version: '1.0.0', + }, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + addedFunctions: [], + }, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + }; + + config.apps[pluginId] = { + id: pluginId, + path: '', + version: '', + preload: false, + angular: { + detected: false, + hideDeprecation: false, + }, + loadingStrategy: PluginLoadingStrategy.fetch, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + extensions: { + addedLinks: [], + addedComponents: [], + addedFunctions: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + }); + + it('should return an empty array if there are no function extensions registered for the extension point', () => { + const { result } = renderHook( + () => + usePluginFunctions({ + extensionPointId: 'foo/bar', + }), + { wrapper } + ); + + expect(result.current.functions).toEqual([]); + }); + + it('should only return the function extensions for the given extension point ids', async () => { + registries.addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + fn: () => 'function1', + }, + { + targets: extensionPointId, + title: '2', + description: '2', + fn: () => 'function2', + }, + { + targets: 'plugins/another-extension/v1', + title: '3', + description: '3', + fn: () => 'function3', + }, + ], + }); + + const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper }); + + expect(result.current.functions.length).toBe(2); + expect(result.current.functions[0].title).toBe('1'); + expect(result.current.functions[1].title).toBe('2'); + }); + + it('should dynamically update the extensions registered for a certain extension point', () => { + let { result, rerender } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper }); + + // No extensions yet + expect(result.current.functions.length).toBe(0); + + // Add extensions to the registry + act(() => { + registries.addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + fn: () => 'function1', + }, + { + targets: extensionPointId, + title: '2', + description: '2', + fn: () => 'function2', + }, + ], + }); + }); + + // Check if the hook returns the new extensions + rerender(); + + expect(result.current.functions.length).toBe(2); + expect(result.current.functions[0].title).toBe('1'); + expect(result.current.functions[1].title).toBe('2'); + }); + + it('should honour the limitPerPlugin arg if its set', () => { + const plugins = ['my-awesome1-app', 'my-awesome2-app', 'my-awesome3-app']; + let { result, rerender } = renderHook(() => usePluginFunctions({ extensionPointId, limitPerPlugin: 2 }), { + wrapper, + }); + + // No extensions yet + expect(result.current.functions.length).toBe(0); + + // Add extensions to the registry + act(() => { + for (let pluginId of plugins) { + registries.addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + targets: [extensionPointId], + title: '1', + description: '1', + fn: () => 'function1', + }, + { + targets: [extensionPointId], + title: '2', + description: '2', + fn: () => 'function2', + }, + { + targets: [extensionPointId], + title: '3', + description: '3', + fn: () => 'function3', + }, + ], + }); + } + }); + + // Check if the hook returns the new extensions + rerender(); + + // Should only return 2 functions per plugin due to limitPerPlugin: 2 + expect(result.current.functions.length).toBe(6); + }); + + it('should return isLoading: true when app plugins are loading', () => { + jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: true }); + + const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.functions).toEqual([]); + }); + + it('should return isLoading: false when app plugins are not loading', () => { + jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); + + const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.functions).toEqual([]); + }); + + it('should not validate the extension point meta-info in production mode', () => { + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + registries.addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + fn: () => 'function1', + }, + ], + }); + + // Trying to render an extension point that is not defined in the plugin meta + // (No restrictions due to isGrafanaDevMode() = false) + const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper }); + expect(result.current.functions.length).toBe(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + // It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points. + it('should not validate the extension point meta-info for core plugins', () => { + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + const functionConfig = { + targets: extensionPointId, + title: '1', + description: '1', + fn: () => 'function1', + }; + + // The `AddedFunctionsRegistry` is validating if the function is registered in the plugin metadata (config.apps). + config.apps[pluginId].extensions.addedFunctions = [functionConfig]; + + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + registries.addedFunctionsRegistry.register({ + pluginId, + configs: [functionConfig], + }); + + // Trying to render an extension point that is not defined in the plugin meta + // (No restrictions due to being a core plugin) + const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper }); + expect(result.current.functions.length).toBe(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should not validate the extension point id in production mode', () => { + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Trying to render an extension point that is not defined in the plugin meta + // (No restrictions due to isGrafanaDevMode() = false) + const { result } = renderHook(() => usePluginFunctions({ extensionPointId: 'invalid-extension-point-id' }), { + wrapper, + }); + expect(result.current.functions.length).toBe(0); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // No plugin context -> used in Grafana core + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + // Adding an extension to the extension point + registries.addedFunctionsRegistry.register({ + pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context + configs: [ + { + targets: PluginExtensionPoints.DashboardPanelMenu, + title: '1', + description: '1', + fn: () => 'function1', + }, + ], + }); + + const { result } = renderHook( + () => usePluginFunctions({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu }), + { + wrapper, + } + ); + expect(result.current.functions.length).toBe(1); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('should not allow to create an extension point in core Grafana that is not exposed to plugins', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // No plugin context -> used in Grafana core + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const extensionPointId = 'grafana/not-exposed-extension-point/v1'; + + // Adding an extension to the extension point + registries.addedFunctionsRegistry.register({ + pluginId: 'grafana', // Only core Grafana can register extensions without a plugin context + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + fn: () => 'function1', + }, + ], + }); + + const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper }); + expect(result.current.functions.length).toBe(0); + expect(log.error).toHaveBeenCalled(); + }); + + it('should not validate the extension point id if used in Grafana core (no plugin context)', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // No plugin context -> used in Grafana core + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => usePluginFunctions({ extensionPointId: 'invalid-extension-point-id' }), { + wrapper, + }); + expect(result.current.functions.length).toBe(0); + expect(log.warning).not.toHaveBeenCalled(); + }); + + it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { + // Imitate running in dev mode + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + + // Empty list of extension points in the plugin meta (from plugin.json) + wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + // Adding an extension to the extension point - it should not be returned later + registries.addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + targets: extensionPointId, + title: '1', + description: '1', + fn: () => 'function1', + }, + ], + }); + + // Trying to render an extension point that is not defined in the plugin meta + const { result } = renderHook(() => usePluginFunctions({ extensionPointId }), { wrapper }); + expect(result.current.functions.length).toBe(0); + expect(log.error).toHaveBeenCalled(); + expect(log.error).toHaveBeenCalledWith(errors.EXTENSION_POINT_META_INFO_MISSING); + }); +}); diff --git a/public/app/features/plugins/extensions/usePluginFunctions.tsx b/public/app/features/plugins/extensions/usePluginFunctions.tsx index 8f8c989e316..df4f95678a7 100644 --- a/public/app/features/plugins/extensions/usePluginFunctions.tsx +++ b/public/app/features/plugins/extensions/usePluginFunctions.tsx @@ -5,11 +5,9 @@ import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime'; import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext'; -import * as errors from './errors'; -import { log } from './logs/log'; import { useLoadAppPlugins } from './useLoadAppPlugins'; -import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; -import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; +import { generateExtensionId, getExtensionPointPluginDependencies } from './utils'; +import { validateExtensionPoint } from './validateExtensionPoint'; // Returns an array of component extensions for the given extension point export function usePluginFunctions({ @@ -23,45 +21,17 @@ export function usePluginFunctions({ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps); return useMemo(() => { - const isInsidePlugin = Boolean(pluginContext); - const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false; + const { result } = validateExtensionPoint({ extensionPointId, pluginContext, isLoadingAppPlugins }); + + if (result) { + return { + isLoading: result.isLoading, + functions: [], + }; + } + const results: Array> = []; const extensionsByPlugin: Record = {}; - const pluginId = pluginContext?.meta.id ?? ''; - const pointLog = log.child({ - pluginId, - extensionPointId, - }); - - if ( - isGrafanaDevMode() && - !isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog }) - ) { - return { - isLoading: false, - functions: [], - }; - } - - if ( - isGrafanaDevMode() && - !isCoreGrafanaPlugin && - pluginContext && - isExtensionPointMetaInfoMissing(extensionPointId, pluginContext) - ) { - pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); - return { - isLoading: false, - functions: [], - }; - } - - if (isLoadingAppPlugins) { - return { - isLoading: true, - functions: [], - }; - } for (const registryItem of registryState?.[extensionPointId] ?? []) { const { pluginId } = registryItem; diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index 697c9dfd91f..4f018236a59 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -10,6 +10,7 @@ import { import { config } from '@grafana/runtime'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; +import * as errors from './errors'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; @@ -37,7 +38,7 @@ jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), // Manually set the dev mode to false - // (to make sure that by default we are testing a production scneario) + // (to make sure that by default we are testing a production scenario) isGrafanaDevMode: jest.fn().mockReturnValue(false), })); @@ -247,7 +248,7 @@ describe('usePluginLinks()', () => { // Trying to render an extension point that is not defined in the plugin meta // (No restrictions due to isGrafanaDevMode() = false) - let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); expect(result.current.links.length).toBe(1); expect(log.warning).not.toHaveBeenCalled(); }); @@ -290,7 +291,7 @@ describe('usePluginLinks()', () => { // Trying to render an extension point that is not defined in the plugin meta // (No restrictions due to being a core plugin) - let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); expect(result.current.links.length).toBe(1); expect(log.warning).not.toHaveBeenCalled(); }); @@ -313,7 +314,9 @@ describe('usePluginLinks()', () => { // Trying to render an extension point that is not defined in the plugin meta // (No restrictions due to isGrafanaDevMode() = false) - let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper }); + const { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { + wrapper, + }); expect(result.current.links.length).toBe(0); expect(log.warning).not.toHaveBeenCalled(); }); @@ -340,9 +343,12 @@ describe('usePluginLinks()', () => { ], }); - let { result } = renderHook(() => usePluginLinks({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu }), { - wrapper, - }); + const { result } = renderHook( + () => usePluginLinks({ extensionPointId: PluginExtensionPoints.DashboardPanelMenu }), + { + wrapper, + } + ); expect(result.current.links.length).toBe(1); expect(log.warning).not.toHaveBeenCalled(); }); @@ -371,7 +377,7 @@ describe('usePluginLinks()', () => { ], }); - let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); expect(result.current.links.length).toBe(0); expect(log.error).toHaveBeenCalled(); }); @@ -385,7 +391,9 @@ describe('usePluginLinks()', () => { {children} ); - let { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { wrapper }); + const { result } = renderHook(() => usePluginLinks({ extensionPointId: 'invalid-extension-point-id' }), { + wrapper, + }); expect(result.current.links.length).toBe(0); expect(log.warning).not.toHaveBeenCalled(); }); @@ -423,9 +431,10 @@ describe('usePluginLinks()', () => { }); // Trying to render an extension point that is not defined in the plugin meta - let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); expect(result.current.links.length).toBe(0); expect(log.error).toHaveBeenCalled(); + expect(log.error).toHaveBeenCalledWith(errors.EXTENSION_POINT_META_INFO_MISSING); }); it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => { @@ -461,7 +470,7 @@ describe('usePluginLinks()', () => { }); // Trying to render an extension point that is not defined in the plugin meta - let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); + const { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper }); expect(result.current.links.length).toBe(0); expect(log.error).toHaveBeenCalled(); }); diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 5d2b193a77c..be25ec5105a 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -6,8 +6,6 @@ import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@gr import { UsePluginLinksOptions, UsePluginLinksResult } from '@grafana/runtime'; import { useAddedLinksRegistry } from './ExtensionRegistriesContext'; -import * as errors from './errors'; -import { log } from './logs/log'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { generateExtensionId, @@ -16,9 +14,8 @@ import { getLinkExtensionOverrides, getLinkExtensionPathWithTracking, getReadOnlyProxy, - isGrafanaDevMode, } from './utils'; -import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; +import { validateExtensionPoint } from './validateExtensionPoint'; // Returns an array of component extensions for the given extension point export function usePluginLinks({ @@ -32,47 +29,15 @@ export function usePluginLinks({ const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); return useMemo(() => { - const isInsidePlugin = Boolean(pluginContext); - const pluginId = pluginContext?.meta.id ?? ''; - const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false; - const pointLog = log.child({ - pluginId, + const { result, pointLog } = validateExtensionPoint({ extensionPointId, + pluginContext, + isLoadingAppPlugins, }); - if ( - isGrafanaDevMode() && - !isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog }) - ) { + if (result) { return { - isLoading: false, - links: [], - }; - } - - if ( - isGrafanaDevMode() && - !isCoreGrafanaPlugin && - pluginContext && - isExtensionPointMetaInfoMissing(extensionPointId, pluginContext) - ) { - pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); - return { - isLoading: false, - links: [], - }; - } - - if (isLoadingAppPlugins) { - return { - isLoading: true, - links: [], - }; - } - - if (!registryState || !registryState[extensionPointId]) { - return { - isLoading: false, + isLoading: result.isLoading, links: [], }; } @@ -81,7 +46,7 @@ export function usePluginLinks({ const extensions: PluginExtensionLink[] = []; const extensionsByPlugin: Record = {}; - for (const addedLink of registryState[extensionPointId] ?? []) { + for (const addedLink of registryState?.[extensionPointId] ?? []) { const { pluginId } = addedLink; const linkLog = pointLog.child({ path: addedLink.path ?? '', diff --git a/public/app/features/plugins/extensions/validateExtensionPoint.test.ts b/public/app/features/plugins/extensions/validateExtensionPoint.test.ts new file mode 100644 index 00000000000..611db3b8aa5 --- /dev/null +++ b/public/app/features/plugins/extensions/validateExtensionPoint.test.ts @@ -0,0 +1,174 @@ +import { PluginContextType } from '@grafana/data'; + +import * as errors from './errors'; +import { ExtensionsLog } from './logs/log'; +import { isGrafanaDevMode } from './utils'; +import { validateExtensionPoint } from './validateExtensionPoint'; +import * as validators from './validators'; + +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + + // Manually set the dev mode to false + // (to make sure that by default we are testing a production scenario) + isGrafanaDevMode: jest.fn().mockReturnValue(false), +})); + +const setup = ({ + pointValid = true, + metaMissing = false, + corePlugin = false, +}: { pointValid?: boolean; metaMissing?: boolean; corePlugin?: boolean } = {}) => { + const spyIsExtensionPointIdValid = jest.spyOn(validators, 'isExtensionPointIdValid').mockReturnValue(pointValid); + const spyisExtensionPointMetaInfoMissing = jest + .spyOn(validators, 'isExtensionPointMetaInfoMissing') + .mockReturnValue(metaMissing); + const pluginId = 'myorg-extensions-app'; + const extensionPointId = `${pluginId}/extension-point/v1`; + const pluginContext = { meta: { id: pluginId, module: corePlugin ? 'core:' : '' } } as PluginContextType; + + return { + spyIsExtensionPointIdValid, + spyisExtensionPointMetaInfoMissing, + pluginId, + extensionPointId, + pluginContext, + }; +}; + +describe('getExtensionValidationResults', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when calling in production mode', () => { + beforeEach(() => { + jest.mocked(isGrafanaDevMode).mockReturnValue(false); + }); + + it('should return isLoading:true while loading app plugins', () => { + const { extensionPointId, pluginContext } = setup(); + + const actual = validateExtensionPoint({ + extensionPointId, + isLoadingAppPlugins: true, + pluginContext, + }); + + expect(actual.result).toEqual({ isLoading: true }); + expect(actual.pointLog).toBeDefined(); + }); + + it('should return null when all validations pass', () => { + const { extensionPointId, pluginContext } = setup(); + + const actual = validateExtensionPoint({ + extensionPointId, + isLoadingAppPlugins: false, + pluginContext, + }); + + expect(actual.result).toBe(null); + expect(actual.pointLog).toBeDefined(); + }); + }); + + describe('when calling in dev mode', () => { + let errorSpy: jest.SpyInstance; + beforeEach(() => { + jest.mocked(isGrafanaDevMode).mockReturnValue(true); + errorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + it('should return isLoading:false when extension point is invalid', () => { + const { + extensionPointId, + pluginContext, + pluginId, + spyIsExtensionPointIdValid, + spyisExtensionPointMetaInfoMissing, + } = setup({ pointValid: false }); + + const actual = validateExtensionPoint({ + extensionPointId, + isLoadingAppPlugins: true, + pluginContext, + }); + + expect(actual.result).toEqual({ isLoading: false }); + expect(actual.pointLog).toBeDefined(); + expect(spyisExtensionPointMetaInfoMissing).not.toHaveBeenCalled(); + expect(spyIsExtensionPointIdValid).toHaveBeenCalledTimes(1); + expect(spyIsExtensionPointIdValid).toHaveBeenCalledWith({ + extensionPointId, + pluginId, + isInsidePlugin: true, + isCoreGrafanaPlugin: false, + log: expect.any(ExtensionsLog), + }); + }); + + it('should return isLoading:false when extension point meta is missing', () => { + const { extensionPointId, pluginContext, pluginId, spyisExtensionPointMetaInfoMissing } = setup({ + metaMissing: true, + }); + + const actual = validateExtensionPoint({ + extensionPointId, + isLoadingAppPlugins: true, + pluginContext, + }); + + expect(actual.result).toEqual({ isLoading: false }); + expect(actual.pointLog).toBeDefined(); + expect(spyisExtensionPointMetaInfoMissing).toHaveBeenCalled(); + expect(spyisExtensionPointMetaInfoMissing).toHaveBeenCalledWith(extensionPointId, pluginContext); + expect(errorSpy).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(errors.EXTENSION_POINT_META_INFO_MISSING, { extensionPointId, pluginId }); + }); + + it('should ignore core plugins when extension point meta is missing', () => { + const { extensionPointId, pluginContext, spyisExtensionPointMetaInfoMissing } = setup({ + metaMissing: true, + corePlugin: true, + }); + + const actual = validateExtensionPoint({ + extensionPointId, + isLoadingAppPlugins: false, + pluginContext, + }); + + expect(actual.result).toEqual(null); + expect(actual.pointLog).toBeDefined(); + expect(spyisExtensionPointMetaInfoMissing).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('should return isLoading:true while loading app plugins', () => { + const { extensionPointId, pluginContext } = setup(); + + const actual = validateExtensionPoint({ + extensionPointId, + isLoadingAppPlugins: true, + pluginContext, + }); + + expect(actual.result).toEqual({ isLoading: true }); + expect(actual.pointLog).toBeDefined(); + }); + + it('should return null when all validations pass', () => { + const { extensionPointId, pluginContext } = setup(); + + const actual = validateExtensionPoint({ + extensionPointId, + isLoadingAppPlugins: false, + pluginContext, + }); + + expect(actual.result).toBe(null); + expect(actual.pointLog).toBeDefined(); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/validateExtensionPoint.ts b/public/app/features/plugins/extensions/validateExtensionPoint.ts new file mode 100644 index 00000000000..aef4ab1fb49 --- /dev/null +++ b/public/app/features/plugins/extensions/validateExtensionPoint.ts @@ -0,0 +1,57 @@ +import { PluginContextType } from '@grafana/data'; + +import * as errors from './errors'; +import { ExtensionsLog, log } from './logs/log'; +import { isGrafanaDevMode } from './utils'; +import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; + +interface ValidateExtensionPointOptions { + extensionPointId: string; + isLoadingAppPlugins: boolean; + pluginContext: PluginContextType | null; +} + +interface ValidateExtensionPoint { + isLoading: boolean; +} + +type ValidateExtensionPointResult = { + result: ValidateExtensionPoint | null; + pointLog: ExtensionsLog; +}; + +export function validateExtensionPoint({ + extensionPointId, + isLoadingAppPlugins, + pluginContext, +}: ValidateExtensionPointOptions): ValidateExtensionPointResult { + const isInsidePlugin = Boolean(pluginContext); + const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false; + const pluginId = pluginContext?.meta.id ?? ''; + const pointLog = log.child({ pluginId, extensionPointId }); + + // Don't show extensions if the extension-point id is invalid in DEV mode + if ( + isGrafanaDevMode() && + !isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog }) + ) { + return { result: { isLoading: false }, pointLog }; + } + + // Don't show extensions if the extension-point misses meta info (plugin.json) in DEV mode + if ( + isGrafanaDevMode() && + !isCoreGrafanaPlugin && + pluginContext && + isExtensionPointMetaInfoMissing(extensionPointId, pluginContext) + ) { + pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING); + return { result: { isLoading: false }, pointLog }; + } + + if (isLoadingAppPlugins) { + return { result: { isLoading: true }, pointLog }; + } + + return { result: null, pointLog }; +} diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index e4ce38346e8..272881a478a 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -148,8 +148,9 @@ export class UnifiedSearcher implements GrafanaSearcher { const field = first.fields.find((f) => f.name === meta.sortBy); if (field) { const name = getSortFieldDisplayName(field.name); - meta.sortBy = name; - field.name = name; // make it look nicer + // We don't want to directly change the field name, just the display name + // When the columns names get generated it uses getFieldDisplayName(), which will check if there is a field.config.displayName + field.config.displayName = name; } } diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 0ecb638099c..a80bc63ec0e 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -1,6 +1,14 @@ import { useMemo, useState } from 'react'; -import { PanelProps, DataFrameType, DashboardCursorSync } from '@grafana/data'; +import { + PanelProps, + DataFrameType, + DashboardCursorSync, + DataFrame, + alignTimeRangeCompareData, + shouldAlignTimeCompare, + FieldType, +} from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode, VizOrientation } from '@grafana/schema'; import { EventBusPlugin, KeyboardPlugin, TooltipPlugin2, usePanelContext } from '@grafana/ui'; @@ -47,7 +55,38 @@ export const TimeSeriesPanel = ({ // Vertical orientation is not available for users through config. // It is simplified version of horizontal time series panel and it does not support all plugins. const isVerticallyOriented = options.orientation === VizOrientation.Vertical; - const frames = useMemo(() => prepareGraphableFields(data.series, config.theme2, timeRange), [data.series, timeRange]); + const { frames, compareDiffMs } = useMemo(() => { + let frames = prepareGraphableFields(data.series, config.theme2, timeRange); + + if (frames != null) { + let compareDiffMs: number[] = [0]; + + frames.forEach((frame: DataFrame) => { + const diffMs = frame.meta?.timeCompare?.diffMs ?? 0; + + frame.fields.forEach((field) => { + if (field.type !== FieldType.time) { + compareDiffMs.push(diffMs); + } + }); + + if (diffMs !== 0) { + // Check if the compared frame needs time alignment + // Apply alignment when time ranges match (no shift applied yet) + const needsAlignment = shouldAlignTimeCompare(frame, frames, timeRange); + + if (needsAlignment) { + alignTimeRangeCompareData(frame, diffMs, config.theme2.colors.text.disabled); + } + } + }); + + return { frames, compareDiffMs }; + } + + return { frames }; + }, [data.series, timeRange]); + const timezones = useMemo(() => getTimezones(options.timezone, timeZone), [options.timezone, timeZone]); const suggestions = useMemo(() => { if (frames?.length && frames.every((df) => df.meta?.type === DataFrameType.TimeSeriesLong)) { @@ -141,6 +180,7 @@ export const TimeSeriesPanel = ({ replaceVariables={replaceVariables} dataLinks={dataLinks} canExecuteActions={userCanExecuteActions} + compareDiffMs={compareDiffMs} /> ); }} diff --git a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx index 8dd95160917..8c7125e6149 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesTooltip.tsx @@ -43,6 +43,7 @@ export interface TimeSeriesTooltipProps { hideZeros?: boolean; adHocFilters?: AdHocFilterModel[]; canExecuteActions?: boolean; + compareDiffMs?: number[]; } export const TimeSeriesTooltip = ({ @@ -60,9 +61,17 @@ export const TimeSeriesTooltip = ({ hideZeros, adHocFilters, canExecuteActions, + compareDiffMs, }: TimeSeriesTooltipProps) => { const xField = series.fields[0]; - const xVal = formattedValueToString(xField.display!(xField.values[dataIdxs[0]!])); + + let xVal = xField.values[dataIdxs[0]!]; + + if (compareDiffMs != null && xField.type === FieldType.time) { + xVal += compareDiffMs[seriesIdx ?? 1]; + } + + const xDisp = formattedValueToString(xField.display!(xVal)); const contentItems = getContentItems( series.fields, @@ -94,7 +103,7 @@ export const TimeSeriesTooltip = ({ const headerItem: VizTooltipItem = { label: xField.type === FieldType.time ? '' : (xField.state?.displayName ?? xField.name), - value: xVal, + value: xDisp, }; return ( diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index ac029ca0fa9..04bc7efff26 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -1715,9 +1715,6 @@ "notes": "Poznámky", "returns": "Vrácení zboží" }, - "label-picker": { - "no-options-message": "Nebyly nalezeny žádné štítky" - }, "labels-editor-modal": { "title-edit-labels": "Upravit štítky" }, @@ -1736,9 +1733,6 @@ "description": "Vyberte klíč/hodnotu štítku z možností níže nebo zadejte nový klíč/hodnotu štítku a stiskněte klávesu Enter.", "save": "Uložit" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Načítání stávajících štítků" - }, "labels-without-suggestions": { "message": { "required": "Povinné." @@ -9708,6 +9702,12 @@ "hide-search": "Zavřít vyhledávání", "hide-timestamps": "Skrýt časová razítka", "hide-unique-labels": "Skrýt jedinečné štítky", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Seřazeno od nejnovějších protokolů – kliknutím zobrazíte nejstarší protokoly jako první", "oldest-first": "Seřazeno od nejstarších protokolů – kliknutím zobrazíte nejnovější protokoly jako první", "prettify-json": "Rozbalit protokoly JSON", @@ -9716,13 +9716,16 @@ "resolution-ns": "ns", "scroll-bottom": "Posunout dolů", "scroll-top": "Posunout nahoru", - "show-ms-timestamps": "Zobrazit časová razítka v milisekundách", - "show-ns-timestamps": "Zobrazit časová razítka v nanosekundách", "show-search": "Výsledek vyhledávání v protokolech", "show-timestamps": "Zobrazit časová razítka", "show-unique-labels": "Zobrazit jedinečné štítky", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Rozbalit řádky", - "wrap-json-lines": "", "wrap-lines": "Zalomit řádky" }, "logs-navigation": { @@ -12012,7 +12015,6 @@ "expand-row": "Rozbalit řádek dotazu", "hide-response": "Skrýt odpověď", "remove-query": "Odebrat dotaz", - "replace-query-from-library": "Nahradit uloženým dotazem", "show-response": "Zobrazit odpověď" }, "query-editor-not-exported": "Doplněk zdroje dat neexportuje žádnou komponentu editoru dotazů" diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index bedca2c555f..4c7434eea6d 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Anmerkungen", "returns": "Rückgaben" }, - "label-picker": { - "no-options-message": "Keine Labels gefunden" - }, "labels-editor-modal": { "title-edit-labels": "Labels bearbeiten" }, @@ -1724,9 +1721,6 @@ "description": "Wählen Sie aus den folgenden Optionen einen Label-Key/Wert aus oder geben Sie einen neuen ein und drücken Sie dann die Eingabetaste.", "save": "Speichern" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Vorhandene Labels werden geladen" - }, "labels-without-suggestions": { "message": { "required": "Erforderlich." @@ -9650,6 +9644,12 @@ "hide-search": "Suche schließen", "hide-timestamps": "Zeitstempel ausblenden", "hide-unique-labels": "Eindeutige Labels ausblenden", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Sortiert nach neuesten Logs zuerst – klicken Sie, um die ältesten zuerst anzuzeigen", "oldest-first": "Sortiert nach ältesten Logs zuerst – klicken Sie, um die neuesten zuerst anzuzeigen", "prettify-json": "JSON-Logs ausklappen", @@ -9658,13 +9658,16 @@ "resolution-ns": "ns", "scroll-bottom": "Nach unten scrollen", "scroll-top": "Nach oben scrollen", - "show-ms-timestamps": "Millisekunden-Zeitstempel anzeigen", - "show-ns-timestamps": "Nanosekunden-Zeitstempel anzeigen", "show-search": "Ergebnis der Suche in Logs", "show-timestamps": "Zeitstempel anzeigen", "show-unique-labels": "Eindeutige Labels anzeigen", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Zeilenumbruch aufheben", - "wrap-json-lines": "", "wrap-lines": "Zeilen umbrechen" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Suchzeile erweitern", "hide-response": "Antwort ausblenden", "remove-query": "Abfrage entfernen", - "replace-query-from-library": "Durch gespeicherte Abfrage ersetzen", "show-response": "Antwort anzeigen " }, "query-editor-not-exported": "Datenquellen-Plugin exportiert keine Komponente des Abfrageeditors" diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 46459e68f27..de6aeba30cc 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -4681,6 +4681,7 @@ "move": "Move {{typeName}}", "panel-background": "Change panel background", "panel-description": "Change panel description", + "panel-edit": "Panel changes", "panel-max-repeats-per-row": "Max repeats per row", "panel-repeat-direction": "Repeat direction", "panel-repeat-variable": "Panel repeat by", @@ -11956,14 +11957,6 @@ "no-deleted-dashboards-text": "When you delete a dashboard, it will appear here for 30 days before being permanently deleted. Your organization administrator can restore recently-deleted dashboards.", "no-search-result": "No results found for your query" }, - "permanently-delete-modal": { - "confirm-text": "Delete", - "delete-button": "Delete", - "delete-loading": "Deleting...", - "text_one": "This action will delete {{numberOfDashboards}} dashboards.", - "text_other": "This action will delete {{numberOfDashboards}} dashboards.", - "title": "Permanently Delete Dashboards" - }, "restore-modal": { "folder-picker-text_one": "Please choose a folder where your dashboards will be restored.", "folder-picker-text_other": "Please choose a folder where your dashboards will be restored.", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 5384c4abb0b..4dbd91ec951 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Notas", "returns": "Devoluciones" }, - "label-picker": { - "no-options-message": "No se ha encontrado ninguna etiqueta" - }, "labels-editor-modal": { "title-edit-labels": "Editar etiquetas" }, @@ -1724,9 +1721,6 @@ "description": "Selecciona una clave/un valor de etiqueta de las opciones siguientes o escribe una nueva y pulsa Intro.", "save": "Guardar" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Cargando etiquetas existentes" - }, "labels-without-suggestions": { "message": { "required": "Este valor es obligatorio." @@ -9650,6 +9644,12 @@ "hide-search": "Cerrar la búsqueda", "hide-timestamps": "Ocultar marcas de tiempo", "hide-unique-labels": "Ocultar etiquetas únicas", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Ordenado por los logs más nuevos primero: haga clic para mostrar los más antiguos primero", "oldest-first": "Ordenado por los logs más antiguos primero: haga clic para mostrar los más nuevos primero", "prettify-json": "Expandir logs JSON", @@ -9658,13 +9658,16 @@ "resolution-ns": "ns", "scroll-bottom": "Desplazarse al final", "scroll-top": "Desplazarse al inicio", - "show-ms-timestamps": "Mostrar marcas de tiempo en milisegundos", - "show-ns-timestamps": "Mostrar marcas de tiempo en nanosegundos", "show-search": "Resultado de la búsqueda en logs", "show-timestamps": "Mostrar marcas temporales", "show-unique-labels": "Mostrar etiquetas únicas", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Desajustar líneas", - "wrap-json-lines": "", "wrap-lines": "Ajustar líneas" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Expandir la fila de la consulta", "hide-response": "Ocultar respuesta", "remove-query": "Eliminar consulta", - "replace-query-from-library": "Reemplazar con consulta guardada", "show-response": "Mostrar respuesta" }, "query-editor-not-exported": "El complemento de la fuente de datos no exporta ningún componente del editor de consultas" diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 540f2db747d..e69b03ae214 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Remarques", "returns": "Retours" }, - "label-picker": { - "no-options-message": "Aucune étiquette trouvée" - }, "labels-editor-modal": { "title-edit-labels": "Modifier les étiquettes" }, @@ -1724,9 +1721,6 @@ "description": "Sélectionnez une clé/valeur d’étiquette parmi les options ci-dessous, ou saisissez-en une nouvelle et appuyez sur Entrée.", "save": "Enregistrer" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Chargement des étiquettes existantes" - }, "labels-without-suggestions": { "message": { "required": "Obligatoire." @@ -9650,6 +9644,12 @@ "hide-search": "Fermer la recherche", "hide-timestamps": "Masquer les horodatages", "hide-unique-labels": "Masquer les étiquettes uniques", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Trié par les journaux les plus récents en premier - Cliquez pour afficher les plus anciens en premier", "oldest-first": "Trié par les journaux les plus anciens en premier - Cliquez pour afficher les plus récents en premier", "prettify-json": "Développer les journaux JSON", @@ -9658,13 +9658,16 @@ "resolution-ns": "ns", "scroll-bottom": "Faire défiler vers le bas", "scroll-top": "Retour en haut de page", - "show-ms-timestamps": "Afficher les horodatages en millisecondes", - "show-ns-timestamps": "Afficher les horodatages en nanosecondes", "show-search": "Rechercher dans les résultats de logs", "show-timestamps": "Afficher les horodatages", "show-unique-labels": "Afficher les étiquettes uniques", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Dérouler les lignes", - "wrap-json-lines": "", "wrap-lines": "Enrouler les lignes" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Développer la ligne de requête", "hide-response": "Masquer la réponse", "remove-query": "Supprimer la requête", - "replace-query-from-library": "Remplacer par une requête sauvegardée", "show-response": "Afficher la réponse" }, "query-editor-not-exported": "Le plugin source de données n'exporte aucun composant de l'éditeur de requête" diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index a886f2cbce8..bbea3a0b85e 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Megjegyzések", "returns": "Visszaadott értékek" }, - "label-picker": { - "no-options-message": "Nem található címke" - }, "labels-editor-modal": { "title-edit-labels": "Címkék szerkesztése" }, @@ -1724,9 +1721,6 @@ "description": "Válasszon egy címkekulcsot/-értéket az alábbi lehetőségek közül, vagy írjon be egy újat, és nyomja le az Enter billentyűt.", "save": "Mentés" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Meglévő címkék betöltése" - }, "labels-without-suggestions": { "message": { "required": "Szükséges." @@ -9650,6 +9644,12 @@ "hide-search": "Keresés bezárása", "hide-timestamps": "Időbélyegek elrejtése", "hide-unique-labels": "Egyedi címkék elrejtése", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Rendezés a legújabb naplók szerint – kattintson, hogy a legrégebbi naplók jelenjenek meg elsőként", "oldest-first": "Rendezés a legrégebbi naplók szerint – kattintson, hogy a legújabb naplók jelenjenek meg elsőként", "prettify-json": "JSON-naplók kibontása", @@ -9658,13 +9658,16 @@ "resolution-ns": "ns", "scroll-bottom": "Görgetés az aljára", "scroll-top": "Görgetés a tetejére", - "show-ms-timestamps": "Milliszekundumos időbélyegek megjelenítése", - "show-ns-timestamps": "Nanoszekundumos időbélyegek megjelenítése", "show-search": "Keresés a naplóeredményekben", "show-timestamps": "Időbélyegek megjelenítése", "show-unique-labels": "Egyedi címkék megjelenítése", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Sortörés megszüntetése", - "wrap-json-lines": "", "wrap-lines": "Sortörés" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Lekérdezési sor kibontása", "hide-response": "Válasz elrejtése", "remove-query": "Lekérdezés eltávolítása", - "replace-query-from-library": "Csere mentett lekérdezéssel", "show-response": "Válasz megjelenítése" }, "query-editor-not-exported": "Az adatforrás-bővítmény nem exportál egyetlen lekérdezésszerkesztő-komponenst sem" diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index cd1026d9bb6..b6ace08a95a 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -1697,9 +1697,6 @@ "notes": "Catatan", "returns": "Hasil" }, - "label-picker": { - "no-options-message": "Tidak ada label yang ditemukan" - }, "labels-editor-modal": { "title-edit-labels": "Edit label" }, @@ -1718,9 +1715,6 @@ "description": "Pilih kunci/nilai label dari opsi di bawah ini, atau ketik yang baru dan tekan Enter.", "save": "Simpan" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Memuat label yang ada" - }, "labels-without-suggestions": { "message": { "required": "Diperlukan." @@ -9621,6 +9615,12 @@ "hide-search": "Tutup pencarian", "hide-timestamps": "Sembunyikan stempel waktu", "hide-unique-labels": "Sembunyikan label unik", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Diurutkan berdasarkan log terbaru lebih dulu - Klik untuk menampilkan yang paling lama lebih dulu", "oldest-first": "Diurutkan berdasarkan log paling lama lebih dulu - Klik untuk menampilkan yang terbaru lebih dulu", "prettify-json": "Perluas log JSON", @@ -9629,13 +9629,16 @@ "resolution-ns": "nd", "scroll-bottom": "Gulir ke bawah", "scroll-top": "Gulir ke atas", - "show-ms-timestamps": "Tampilkan stempel waktu milidetik", - "show-ns-timestamps": "Tampilkan stempel waktu nanodetik", "show-search": "Cari di hasil log", "show-timestamps": "Tampilkan stempel waktu", "show-unique-labels": "Tampilkan label unik", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Batal terapkan wrap pada baris", - "wrap-json-lines": "", "wrap-lines": "Terapkan wrap pada baris" }, "logs-navigation": { @@ -11904,7 +11907,6 @@ "expand-row": "Perluas baris kueri", "hide-response": "Sembunyikan respons", "remove-query": "Hapus kueri", - "replace-query-from-library": "Ganti dengan kueri tersimpan", "show-response": "Tampilkan respons" }, "query-editor-not-exported": "Plugin sumber data tidak mengekspor komponen Editor Kueri apa pun" diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 46302ea78ad..0c71e285f3d 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Note", "returns": "Resi" }, - "label-picker": { - "no-options-message": "Nessuna etichetta trovata" - }, "labels-editor-modal": { "title-edit-labels": "Modifica etichette" }, @@ -1724,9 +1721,6 @@ "description": "Seleziona una chiave/valore dell'etichetta dalle opzioni seguenti o digitane una nuova e premi Invio.", "save": "Salva" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Caricamento delle etichette esistenti" - }, "labels-without-suggestions": { "message": { "required": "Obbligatorio." @@ -9650,6 +9644,12 @@ "hide-search": "Chiudi ricerca", "hide-timestamps": "Nascondi marca temporale", "hide-unique-labels": "Nascondi etichette univoche", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Ordine: prima i registri più recenti – Fai clic per mostrare prima i meno recenti", "oldest-first": "Ordine: prima i registri meno recenti - Fai clic per mostrare prima i più recenti", "prettify-json": "Ingrandisci i registri JSON", @@ -9658,13 +9658,16 @@ "resolution-ns": "ns", "scroll-bottom": "Scorri verso il basso", "scroll-top": "Scorri verso l'alto", - "show-ms-timestamps": "Mostra marche temporali in millisecondi", - "show-ns-timestamps": "Mostra marche temporali in nanosecondi", "show-search": "Cerca nel risultato dei registri", "show-timestamps": "Mostra marca temporale", "show-unique-labels": "Mostra etichette univoche", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Rimuovi a capo", - "wrap-json-lines": "", "wrap-lines": "A capo" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Espandi riga della query", "hide-response": "Nascondi risposta", "remove-query": "Rimuovi query", - "replace-query-from-library": "Sostituisci con query salvata", "show-response": "Mostra la risposta" }, "query-editor-not-exported": "Il plug-in dell'origine dati non esporta alcun componente dell'editor di query" diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index fe14403f739..cbe367775e4 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -1697,9 +1697,6 @@ "notes": "メモ", "returns": "返す" }, - "label-picker": { - "no-options-message": "ラベルが見つかりません" - }, "labels-editor-modal": { "title-edit-labels": "ラベルを編集" }, @@ -1718,9 +1715,6 @@ "description": "以下のオプションからラベルのキー/値を選択するか、新しいキー/値を入力してEnterキーを押してください。", "save": "保存" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "既存のラベルを読み込み中" - }, "labels-without-suggestions": { "message": { "required": "必須項目です。" @@ -9621,6 +9615,12 @@ "hide-search": "検索を閉じる", "hide-timestamps": "タイムスタンプを非表示", "hide-unique-labels": "一意のラベルを非表示", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "最新のログ順に並び替え - クリックして最も古いログを最初に表示", "oldest-first": "古いログ順に並び替え - クリックして最新のログを最初に表示", "prettify-json": "JSONログを展開", @@ -9629,13 +9629,16 @@ "resolution-ns": "ns", "scroll-bottom": "一番下までスクロール", "scroll-top": "一番上までスクロール", - "show-ms-timestamps": "ミリ秒のタイムスタンプを表示", - "show-ns-timestamps": "ナノ秒のタイムスタンプを表示", "show-search": "ログ結果内を検索", "show-timestamps": "タイムスタンプを表示", "show-unique-labels": "一意のラベルを表示", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "行の折り返しを解除", - "wrap-json-lines": "", "wrap-lines": "行を折り返す" }, "logs-navigation": { @@ -11904,7 +11907,6 @@ "expand-row": "クエリ行を展開", "hide-response": "回答を非表示にする", "remove-query": "クエリを削除", - "replace-query-from-library": "保存されたクエリに置き換える", "show-response": "応答の表示" }, "query-editor-not-exported": "データソースプラグインは、クエリエディタコンポーネントをエクスポートしません" diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index b54ac71be2e..dba29ceb054 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -1697,9 +1697,6 @@ "notes": "메모", "returns": "반환" }, - "label-picker": { - "no-options-message": "라벨을 찾을 수 없습니다" - }, "labels-editor-modal": { "title-edit-labels": "라벨 편집" }, @@ -1718,9 +1715,6 @@ "description": "아래 옵션에서 라벨 키/값을 선택하거나 새 라벨 키/값을 입력하고 엔터 키를 누릅니다.", "save": "저장" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "기존 라벨 로딩 중" - }, "labels-without-suggestions": { "message": { "required": "필수 사항입니다. " @@ -9621,6 +9615,12 @@ "hide-search": "검색 닫기", "hide-timestamps": "타임스탬프 숨기기", "hide-unique-labels": "고유 라벨 숨기기", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "최신 로그순으로 정렬 - 클릭하여 오래된 로그순으로 표시", "oldest-first": "오래된 로그순으로 정렬 - 클릭하여 최신 로그순으로 표시", "prettify-json": "JSON 로그 펼치기", @@ -9629,13 +9629,16 @@ "resolution-ns": "ns", "scroll-bottom": "맨 아래로 스크롤", "scroll-top": "맨 위로 스크롤", - "show-ms-timestamps": "타임스탬프(밀리초 단위) 표시", - "show-ns-timestamps": "타임스탬프(나노초 단위) 표시", "show-search": "로그 결과에서 검색", "show-timestamps": "타임스탬프 표시", "show-unique-labels": "고유 라벨 표시", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "줄 바꿈 제거", - "wrap-json-lines": "", "wrap-lines": "줄 바꿈" }, "logs-navigation": { @@ -11904,7 +11907,6 @@ "expand-row": "쿼리 행 펼치기", "hide-response": "응답 숨기기", "remove-query": "쿼리 제거", - "replace-query-from-library": "저장된 쿼리로 교체", "show-response": "응답 표시" }, "query-editor-not-exported": "데이터 소스 플러그인은 쿼리 편집기 구성 요소를 내보내지 않습니다." diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index f6cfdbec563..55e370ebcf8 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Opmerkingen", "returns": "Retouren" }, - "label-picker": { - "no-options-message": "Geen labels gevonden" - }, "labels-editor-modal": { "title-edit-labels": "Labels bewerken" }, @@ -1724,9 +1721,6 @@ "description": "Selecteer een labelsleutel/-waarde uit de onderstaande opties of typ een nieuwe en druk op Enter.", "save": "Opslaan" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Bestaande labels laden" - }, "labels-without-suggestions": { "message": { "required": "Vereist." @@ -9650,6 +9644,12 @@ "hide-search": "Zoekopdracht sluiten", "hide-timestamps": "Tijdstempels verbergen", "hide-unique-labels": "Unieke labels verbergen", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Gesorteerd op nieuwste logboeken eerst - klik om oudste eerst weer te geven", "oldest-first": "Gesorteerd op oudste logboeken eerst - klik om nieuwste eerst weer te geven", "prettify-json": "JSON-logs uitvouwen", @@ -9658,13 +9658,16 @@ "resolution-ns": "nsec", "scroll-bottom": "Naar beneden scrollen", "scroll-top": "Naar boven scrollen", - "show-ms-timestamps": "Tijdstempels met milliseconden weergeven", - "show-ns-timestamps": "Tijdstempels met nanoseconden weergeven", "show-search": "Zoeken in logresultaten", "show-timestamps": "Tijdstempels weergeven", "show-unique-labels": "Unieke labels weergeven", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Lijnen omsluiten", - "wrap-json-lines": "", "wrap-lines": "Lijnen omsluiten" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Queryrij uitvouwen", "hide-response": "Antwoord verbergen", "remove-query": "Query verwijderen", - "replace-query-from-library": "Vervangen door opgeslagen query", "show-response": "Antwoord weergeven" }, "query-editor-not-exported": "Gegevensbronplug-in exporteert geen Query Editor-component" diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 459b6a4ca24..1967cf4a20c 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -1715,9 +1715,6 @@ "notes": "Uwagi", "returns": "Zwroty" }, - "label-picker": { - "no-options-message": "Nie znaleziono etykiet" - }, "labels-editor-modal": { "title-edit-labels": "Edytuj etykiety" }, @@ -1736,9 +1733,6 @@ "description": "Wybierz parę klucz-wartość etykiety z poniższych opcji lub wpisz nową i naciśnij Enter.", "save": "Zapisz" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Wczytywanie istniejących etykiet" - }, "labels-without-suggestions": { "message": { "required": "Wymagane." @@ -9708,6 +9702,12 @@ "hide-search": "Zamknij wyszukiwanie", "hide-timestamps": "Ukryj znaczniki czasu", "hide-unique-labels": "Ukryj unikalne etykiety", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Sortowanie od najnowszych wpisów dziennika – kliknij, aby wyświetlić najpierw najstarsze", "oldest-first": "Sortowanie od najstarszych wpisów dziennika – kliknij, aby wyświetlić najpierw najnowsze", "prettify-json": "Rozwiń logi JSON", @@ -9716,13 +9716,16 @@ "resolution-ns": "ns", "scroll-bottom": "Przewiń w dół", "scroll-top": "Przewiń w górę", - "show-ms-timestamps": "Pokaż znaczniki czasu w milisekundach", - "show-ns-timestamps": "Pokaż znaczniki czasu w nanosekundach", "show-search": "Wynik wyszukiwania w logach", "show-timestamps": "Pokaż znaczniki czasu", "show-unique-labels": "Pokaż unikalne etykiety", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Nie zawijaj wierszy", - "wrap-json-lines": "", "wrap-lines": "Zawijaj wiersze" }, "logs-navigation": { @@ -12012,7 +12015,6 @@ "expand-row": "Rozwiń wiersz zapytania", "hide-response": "Ukryj odpowiedź", "remove-query": "Usuń zapytanie", - "replace-query-from-library": "Zastąp zapisanym zapytaniem", "show-response": "Pokaż odpowiedź" }, "query-editor-not-exported": "Wtyczka źródła danych nie eksportuje żadnego komponentu Edytora zapytań" diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 7d7b3119deb..fc7e4bd0206 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Observações", "returns": "Devoluções" }, - "label-picker": { - "no-options-message": "Nenhum rótulo foi encontrado" - }, "labels-editor-modal": { "title-edit-labels": "Editar rótulos" }, @@ -1724,9 +1721,6 @@ "description": "Selecione uma chave/valor de rótulo nas opções abaixo ou digite um novo e pressione Enter.", "save": "Salvar" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Carregando rótulos existentes" - }, "labels-without-suggestions": { "message": { "required": "Este valor é obrigatório." @@ -9650,6 +9644,12 @@ "hide-search": "Fechar busca", "hide-timestamps": "Ocultar data e hora", "hide-unique-labels": "Ocultar rótulos exclusivos", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Organizado por logs mais recentes primeiro: clique para exibir os mais antigos primeiro", "oldest-first": "Organizado por logs mais antigos primeiro: clique para exibir os mais recentes primeiro", "prettify-json": "Expandir logs JSON", @@ -9658,13 +9658,16 @@ "resolution-ns": "ns", "scroll-bottom": "Rolar para baixo", "scroll-top": "Rolar para cima", - "show-ms-timestamps": "Exibir data e hora em milissegundos", - "show-ns-timestamps": "Exibir data e hora em nanossegundos", "show-search": "Resultado da busca nos logs", "show-timestamps": "Exibir data e hora", "show-unique-labels": "Exibir rótulos únicos", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Desfazer quebra de linha", - "wrap-json-lines": "", "wrap-lines": "Aplicar quebra de linha" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Expandir linha de consulta", "hide-response": "Ocultar resposta", "remove-query": "Remover consulta", - "replace-query-from-library": "Substituir por consulta salva", "show-response": "Mostrar resposta" }, "query-editor-not-exported": "O plug-in de origem de dados não exporta nenhum componente de editor de consulta" diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 696faeff733..f4bb0d94e87 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Observações", "returns": "Devoluções" }, - "label-picker": { - "no-options-message": "Nenhuma etiqueta encontrada" - }, "labels-editor-modal": { "title-edit-labels": "Editar etiquetas" }, @@ -1724,9 +1721,6 @@ "description": "Selecione uma chave/valor de etiqueta nas opções abaixo ou insira uma nova e prima Enter.", "save": "Guardar" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "A carregar etiquetas existentes" - }, "labels-without-suggestions": { "message": { "required": "Necessário." @@ -9650,6 +9644,12 @@ "hide-search": "Fechar a pesquisa", "hide-timestamps": "Ocultar registos de hora/data", "hide-unique-labels": "Ocultar etiquetas únicas", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Ordenado por registos mais recentes primeiro - Clique para mostrar os mais antigos primeiro", "oldest-first": "Ordenado por registos mais antigos primeiro - Clique para mostrar os mais recentes primeiro", "prettify-json": "Expandir registos JSON", @@ -9658,13 +9658,16 @@ "resolution-ns": "ns", "scroll-bottom": "Rolar para baixo", "scroll-top": "Rolar para o topo", - "show-ms-timestamps": "Mostrar carimbos de data e hora em milissegundos", - "show-ns-timestamps": "Mostrar carimbos de data e hora em nanossegundos", "show-search": "Pesquisar no resultado dos registos", "show-timestamps": "Mostrar registos de data e hora", "show-unique-labels": "Mostrar etiquetas únicas", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Revelar linhas", - "wrap-json-lines": "", "wrap-lines": "Quebra de linhas" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Expandir linha de consulta", "hide-response": "Ocultar resposta", "remove-query": "Remover consulta", - "replace-query-from-library": "Substituir por consulta guardada", "show-response": "Mostrar resposta" }, "query-editor-not-exported": "O plugin de origem de dados não exporta nenhum componente do Editor de Consultas" diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 89f664e3950..ce459dd81a7 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -1715,9 +1715,6 @@ "notes": "Примечания", "returns": "Возвраты" }, - "label-picker": { - "no-options-message": "Метки не найдены" - }, "labels-editor-modal": { "title-edit-labels": "Редактирование меток" }, @@ -1736,9 +1733,6 @@ "description": "Выберите ключ/значение метки из вариантов ниже или введите новые и нажмите Enter.", "save": "Сохранить" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Загрузка существующих меток" - }, "labels-without-suggestions": { "message": { "required": "Обязательно." @@ -9708,6 +9702,12 @@ "hide-search": "Закрыть поиск", "hide-timestamps": "Скрыть метки времени", "hide-unique-labels": "Скрыть уникальные метки", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Сначала отображаются самые новые журналы. Нажмите, чтобы показать сначала самые старые", "oldest-first": "Сначала отображаются самые старые журналы. Нажмите, чтобы показать сначала самые новые", "prettify-json": "Развернуть журналы JSON", @@ -9716,13 +9716,16 @@ "resolution-ns": "нс", "scroll-bottom": "Прокрутить вниз", "scroll-top": "Прокрутить вверх", - "show-ms-timestamps": "Показать метки времени в миллисекундах", - "show-ns-timestamps": "Показать метки времени в наносекундах", "show-search": "Результат поиска по журналам", "show-timestamps": "Показать метки времени", "show-unique-labels": "Показать уникальные метки", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Не переносить строки", - "wrap-json-lines": "", "wrap-lines": "Переносить строки" }, "logs-navigation": { @@ -12012,7 +12015,6 @@ "expand-row": "Развернуть строку запроса", "hide-response": "Скрыть ответ", "remove-query": "Удалить запрос", - "replace-query-from-library": "Заменить на сохраненный запрос", "show-response": "Показать ответ" }, "query-editor-not-exported": "Плагин источника данных не экспортирует компоненты редактора запросов" diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 9a44f3770cb..4aa7acdeb4c 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Anteckningar", "returns": "Återsändningar" }, - "label-picker": { - "no-options-message": "Inga etiketter hittades" - }, "labels-editor-modal": { "title-edit-labels": "Redigera etiketter" }, @@ -1724,9 +1721,6 @@ "description": "Välj etikettnyckel/värde från alternativen nedan, eller skriv in nytt och tryck på Enter.", "save": "Spara" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Läser in befintliga etiketter" - }, "labels-without-suggestions": { "message": { "required": "Krävs." @@ -9650,6 +9644,12 @@ "hide-search": "Stäng sökning", "hide-timestamps": "Dölj tidsstämplar", "hide-unique-labels": "Dölj unika etiketter", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Sorterat efter nyaste loggar först – klicka om du vill visa äldsta först", "oldest-first": "Sorterat efter äldsta loggar först – klicka om du vill visa nyaste först", "prettify-json": "Expandera JSON-loggar", @@ -9658,13 +9658,16 @@ "resolution-ns": "ns", "scroll-bottom": "Skrolla längst ner", "scroll-top": "Skrolla till toppen", - "show-ms-timestamps": "Visa tidsstämplar i millisekunder", - "show-ns-timestamps": "Visa tidsstämplar i nanosekunder", "show-search": "Resultat av sökning i loggar", "show-timestamps": "Visa tidsstämplar", "show-unique-labels": "Visa unika etiketter", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Ta bort radbrytningar", - "wrap-json-lines": "", "wrap-lines": "Radbryt linjer" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Expandera frågeraden", "hide-response": "Dölj svar", "remove-query": "Ta bort fråga", - "replace-query-from-library": "Ersätt med sparad fråga", "show-response": "Visa svar" }, "query-editor-not-exported": "Tilläggsprogram för datakälla exporterar inte någon frågeredigerarkomponent" diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index a1f5dd6b567..2ed988557df 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -1703,9 +1703,6 @@ "notes": "Notlar", "returns": "İadeler" }, - "label-picker": { - "no-options-message": "Etiket bulunamadı" - }, "labels-editor-modal": { "title-edit-labels": "Etiketleri düzenle" }, @@ -1724,9 +1721,6 @@ "description": "Aşağıdaki seçeneklerden bir etiket anahtar/değeri seçin veya yeni bir tane yazıp Enter tuşuna a basın.", "save": "Kaydet" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "Mevcut etiketler yükleniyor" - }, "labels-without-suggestions": { "message": { "required": "Zorunlu." @@ -9650,6 +9644,12 @@ "hide-search": "Aramayı kapat", "hide-timestamps": "Zaman damgalarını gizle", "hide-unique-labels": "Benzersiz etiketleri gizle", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "Günlükler yeniden eskiye sıralandı: En eskileri göstermek için tıklayın", "oldest-first": "Günlükler eskiden yeniye sıralandı: En yenileri göstermek için tıklayın", "prettify-json": "JSON günlük kayıtlarını genişlet", @@ -9658,13 +9658,16 @@ "resolution-ns": "", "scroll-bottom": "En alta kaydır", "scroll-top": "En üste kaydır", - "show-ms-timestamps": "", - "show-ns-timestamps": "", "show-search": "Günlük sonuçlarında ara", "show-timestamps": "Zaman damgalarını göster", "show-unique-labels": "Benzersiz etiketleri göster", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "Satırları çöz", - "wrap-json-lines": "", "wrap-lines": "Satırları kaydır" }, "logs-navigation": { @@ -11940,7 +11943,6 @@ "expand-row": "Sorgu satırını genişlet", "hide-response": "Yanıtı gizle", "remove-query": "Sorguyu kaldır", - "replace-query-from-library": "", "show-response": "Yanıtı göster" }, "query-editor-not-exported": "Veri kaynağı eklentisi herhangi bir Sorgu Düzenleyici bileşeni sunmuyor/içermiyor." diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index d2faa99bfa2..177b16f68db 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -1697,9 +1697,6 @@ "notes": "备注", "returns": "退回" }, - "label-picker": { - "no-options-message": "找不到标签" - }, "labels-editor-modal": { "title-edit-labels": "编辑标签" }, @@ -1718,9 +1715,6 @@ "description": "从以下选项中选择一个标签键/值,或输入新的标签键/值,然后按 Enter 键。", "save": "保存" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "加载现有标签" - }, "labels-without-suggestions": { "message": { "required": "必填。" @@ -9621,6 +9615,12 @@ "hide-search": "关闭搜索", "hide-timestamps": "隐藏时间戳", "hide-unique-labels": "隐藏唯一标签", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "先显示最新日志 - 点击以先显示最旧日志", "oldest-first": "先显示最旧日志 - 点击以先显示最新日志", "prettify-json": "展开 JSON 日志", @@ -9629,13 +9629,16 @@ "resolution-ns": "ns", "scroll-bottom": "滚动到底部", "scroll-top": "滚动到顶部", - "show-ms-timestamps": "显示毫秒时间戳", - "show-ns-timestamps": "显示纳秒时间戳", "show-search": "在日志结果中搜索", "show-timestamps": "显示时间戳", "show-unique-labels": "显示唯一标签", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "取消多行显示", - "wrap-json-lines": "", "wrap-lines": "多行显示" }, "logs-navigation": { @@ -11904,7 +11907,6 @@ "expand-row": "展开查询行", "hide-response": "隐藏回复", "remove-query": "删除查询", - "replace-query-from-library": "替换为已保存的查询", "show-response": "显示回复" }, "query-editor-not-exported": "数据源插件不导出任何查询编辑器组件" diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index a445b4b1fbc..b4b0b39b902 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -1697,9 +1697,6 @@ "notes": "備註", "returns": "退回" }, - "label-picker": { - "no-options-message": "找不到標籤" - }, "labels-editor-modal": { "title-edit-labels": "編輯標籤" }, @@ -1718,9 +1715,6 @@ "description": "從以下選項中選擇一個標籤鍵/值,或輸入新的鍵/值,然後按 Enter。", "save": "儲存" }, - "labels-with-suggestions": { - "text-loading-existing-labels": "正在載入現有標籤" - }, "labels-without-suggestions": { "message": { "required": "必填。" @@ -9621,6 +9615,12 @@ "hide-search": "關閉搜尋", "hide-timestamps": "隱藏時間戳記", "hide-unique-labels": "隱藏唯一標籤", + "line-wrapping": { + "enable": "", + "enable-prettify": "", + "hide": "", + "label": "" + }, "newest-first": "按最新紀錄排序 - 按一下以顯示最舊紀錄", "oldest-first": "按最舊紀錄排序 - 按一下以顯示最新紀錄", "prettify-json": "展開 JSON 紀錄", @@ -9629,13 +9629,16 @@ "resolution-ns": "ns", "scroll-bottom": "滾動到底部", "scroll-top": "捲動回頂部", - "show-ms-timestamps": "顯示毫秒時間戳記", - "show-ns-timestamps": "顯示奈秒時間戳記", "show-search": "在紀錄結果中搜尋", "show-timestamps": "顯示時間戳記", "show-unique-labels": "顯示唯一標籤", + "timestamp": { + "hide": "", + "label": "", + "milliseconds": "", + "nanoseconds": "" + }, "unwrap-lines": "取消換行", - "wrap-json-lines": "", "wrap-lines": "換行" }, "logs-navigation": { @@ -11904,7 +11907,6 @@ "expand-row": "展開查詢列", "hide-response": "隱藏回應", "remove-query": "移除查詢", - "replace-query-from-library": "替換為已儲存的查詢", "show-response": "顯示回應" }, "query-editor-not-exported": "資料來源外掛程式不匯出任何查詢編輯器元件" diff --git a/yarn.lock b/yarn.lock index 78cfc86a961..e284a087ef4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2250,39 +2250,39 @@ __metadata: languageName: node linkType: hard -"@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.7.3": - version: 1.7.3 - resolution: "@floating-ui/dom@npm:1.7.3" +"@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.7.4": + version: 1.7.4 + resolution: "@floating-ui/dom@npm:1.7.4" dependencies: "@floating-ui/core": "npm:^1.7.3" "@floating-ui/utils": "npm:^0.2.10" - checksum: 10/0e91b67df31d30247a9516216b2d610a0b52f572c11d60a9875c68e5d65db0fe8819d096b823f123b45ed3ac7ef38d7462e268bdfc8b338c511c724acf098e78 + checksum: 10/d3d6a23e7b9804ba56338c7c666590258683af14b6026270d32afc1202f72b5b82cca359004bdc7830bf2463a045da6c7bd4e7d5351218cf270ff94206197971 languageName: node linkType: hard -"@floating-ui/react-dom@npm:^2.1.2, @floating-ui/react-dom@npm:^2.1.5": - version: 2.1.5 - resolution: "@floating-ui/react-dom@npm:2.1.5" +"@floating-ui/react-dom@npm:^2.1.2, @floating-ui/react-dom@npm:^2.1.6": + version: 2.1.6 + resolution: "@floating-ui/react-dom@npm:2.1.6" dependencies: - "@floating-ui/dom": "npm:^1.7.3" + "@floating-ui/dom": "npm:^1.7.4" peerDependencies: react: ">=16.8.0" react-dom: ">=16.8.0" - checksum: 10/45d7bf558cce2045f0a62454f7675e1d139e3e24fe5c14cf8e4f718c911cc378c687ad428c1c6f1b3a7925a3210f1a4a924980be29f90fbd4ee4d9a7d5bb2f7f + checksum: 10/fbfd3319b42edb9c156e4e872f500d2edb112bc9cfd1b45892bff16ccf21c2484ddc9c416f7631c2aaaadec1b2f98b205db8a3f89eb78ca870905fcfe3917c35 languageName: node linkType: hard -"@floating-ui/react@npm:0.27.15": - version: 0.27.15 - resolution: "@floating-ui/react@npm:0.27.15" +"@floating-ui/react@npm:0.27.16": + version: 0.27.16 + resolution: "@floating-ui/react@npm:0.27.16" dependencies: - "@floating-ui/react-dom": "npm:^2.1.5" + "@floating-ui/react-dom": "npm:^2.1.6" "@floating-ui/utils": "npm:^0.2.10" tabbable: "npm:^6.0.0" peerDependencies: react: ">=17.0.0" react-dom: ">=17.0.0" - checksum: 10/1b48c0956f04eb00e8d992ef27234fbf6c204ad5c266538109332a1b469ef1360b3bf98c6ebfb23b05aa29e8284a67bd01a26ba0484a9f34af4a087d67d65fcc + checksum: 10/b9baedee124035323a8f74794ec782678faf52af1c88731ce7d2641b7e7c97748fda1e711a3c4db007a0153d93158d867f4726ee632d713d3de76ec4bdfd84e1 languageName: node linkType: hard @@ -3368,7 +3368,7 @@ __metadata: resolution: "@grafana/prometheus@workspace:packages/grafana-prometheus" dependencies: "@emotion/css": "npm:11.13.5" - "@floating-ui/react": "npm:0.27.15" + "@floating-ui/react": "npm:0.27.16" "@grafana/data": "npm:12.2.0-pre" "@grafana/e2e-selectors": "npm:12.2.0-pre" "@grafana/i18n": "npm:12.2.0-pre" @@ -3475,11 +3475,11 @@ __metadata: languageName: unknown linkType: soft -"@grafana/scenes-react@npm:6.33.0": - version: 6.33.0 - resolution: "@grafana/scenes-react@npm:6.33.0" +"@grafana/scenes-react@npm:6.34.0": + version: 6.34.0 + resolution: "@grafana/scenes-react@npm:6.34.0" dependencies: - "@grafana/scenes": "npm:6.33.0" + "@grafana/scenes": "npm:6.34.0" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3491,13 +3491,13 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/fbb6c2ee108496a6ba3dc704f902d9a88ae317ceba3ecb89be4bfb3c317cf107444c105d1a7c21836b73df36e93cabacc6c1eb131f137476f27df514493e226c + checksum: 10/a95e18c3e8e303c88ac307e37ce5795325593e134ce90e069675b223f82966d4fe27c8d09f8c4dbc259db46a572f669f3571adf4d967a36639fa55128f619f2e languageName: node linkType: hard -"@grafana/scenes@npm:6.33.0": - version: 6.33.0 - resolution: "@grafana/scenes@npm:6.33.0" +"@grafana/scenes@npm:6.34.0": + version: 6.34.0 + resolution: "@grafana/scenes@npm:6.34.0" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3517,7 +3517,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/5fc020c210e8a1c8e629bbb2be84e30a08e58b2b53f97ebd3f770cd03878eb2c0760148d7fa1fe5e852c6857b8f9a43b14d1ededb7fb439f1de287648c870cf2 + checksum: 10/16e3c0309b2af0d655215ef7b73a254667bad7b2e3e40af9480a70b1610cd50fa48927bb81b477ea9f543791b7aed6e6e21189e608feadf64cc29b96d10d9de5 languageName: node linkType: hard @@ -3612,7 +3612,7 @@ __metadata: "@emotion/react": "npm:11.14.0" "@emotion/serialize": "npm:1.3.3" "@faker-js/faker": "npm:^9.0.0" - "@floating-ui/react": "npm:0.27.15" + "@floating-ui/react": "npm:0.27.16" "@grafana/data": "npm:12.2.0-pre" "@grafana/e2e-selectors": "npm:12.2.0-pre" "@grafana/faro-web-sdk": "npm:^1.13.2" @@ -18097,7 +18097,7 @@ __metadata: "@emotion/eslint-plugin": "npm:11.12.0" "@emotion/react": "npm:11.14.0" "@fingerprintjs/fingerprintjs": "npm:^3.4.2" - "@floating-ui/react": "npm:0.27.15" + "@floating-ui/react": "npm:0.27.16" "@formatjs/intl-durationformat": "npm:^0.7.0" "@glideapps/glide-data-grid": "npm:^6.0.0" "@grafana/alerting": "workspace:*" @@ -18122,8 +18122,8 @@ __metadata: "@grafana/plugin-ui": "npm:^0.10.10" "@grafana/prometheus": "workspace:*" "@grafana/runtime": "workspace:*" - "@grafana/scenes": "npm:6.33.0" - "@grafana/scenes-react": "npm:6.33.0" + "@grafana/scenes": "npm:6.34.0" + "@grafana/scenes-react": "npm:6.34.0" "@grafana/schema": "workspace:*" "@grafana/sql": "workspace:*" "@grafana/test-utils": "workspace:*"