Merge branch 'main' into kristina/config-query-mapping
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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:
|
||||
|
||||
+3
-3
@@ -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:*",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | 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();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ export {
|
||||
isTimeSeriesField,
|
||||
getRowUniqueId,
|
||||
addRow,
|
||||
alignTimeRangeCompareData,
|
||||
shouldAlignTimeCompare,
|
||||
} from './dataframe/utils';
|
||||
export {
|
||||
StreamingDataFrame,
|
||||
|
||||
@@ -229,6 +229,7 @@ export type CentralAlertHistorySceneV1Props = {
|
||||
defaultLabelsFilter?: string;
|
||||
defaultTimeRange?: { from: string; to: string };
|
||||
hideFilters?: boolean;
|
||||
hideAlertRuleColumn?: boolean;
|
||||
};
|
||||
|
||||
export type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context = {
|
||||
|
||||
@@ -47,6 +47,7 @@ export enum VariableHide {
|
||||
dontHide,
|
||||
hideLabel,
|
||||
hideVariable,
|
||||
inControlsMenu,
|
||||
}
|
||||
|
||||
export interface AdHocVariableFilter {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum VariableHide {
|
||||
dontHide,
|
||||
hideLabel,
|
||||
hideVariable,
|
||||
inControlsMenu,
|
||||
}
|
||||
|
||||
export interface VariableModel extends Omit<raw.VariableModel, 'datasource'> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -103,7 +103,11 @@ export const ConfirmContent = ({
|
||||
<div className={styles.confirmationInput}>
|
||||
<Stack alignItems="flex-start">
|
||||
<Field disabled={disabled}>
|
||||
<Input placeholder={placeholder} onChange={onConfirmationTextChange} />
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
onChange={onConfirmationTextChange}
|
||||
data-testid={selectors.pages.ConfirmModal.input}
|
||||
/>
|
||||
</Field>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
To run locally, run `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-repo ./bin/darwin-arm64/grafana server target --config=conf/operator.ini`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pkg/registry/apis/iam/legacy/testdata/mysql--create_service_account-create_service_account_basic.sql
Vendored
Executable
+7
@@ -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')
|
||||
Vendored
Executable
+7
@@ -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')
|
||||
+1
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
Executable
+7
@@ -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')
|
||||
Vendored
Executable
+7
@@ -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')
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
Executable
+7
@@ -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')
|
||||
Vendored
Executable
+7
@@ -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')
|
||||
+1
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -37,7 +37,6 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro
|
||||
},
|
||||
cfg.GrafanaComAPIURL,
|
||||
cfg.DisablePlugins,
|
||||
cfg.HideAngularDeprecation,
|
||||
cfg.ForwardHostEnvVars,
|
||||
cfg.GrafanaComSSOAPIToken,
|
||||
), nil
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,7 +205,6 @@ type Cfg struct {
|
||||
PluginForcePublicKeyDownload bool
|
||||
PluginSkipPublicKeyDownload bool
|
||||
DisablePlugins []string
|
||||
HideAngularDeprecation []string
|
||||
ForwardHostEnvVars []string
|
||||
PreinstallPluginsAsync []InstallPlugin
|
||||
PreinstallPluginsSync []InstallPlugin
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user