mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
K8s: Add Aggregation to Backend Service (#81591)
Co-authored-by: Charandas Batra <charandas.batra@grafana.com>
This commit is contained in:
parent
6d5211e172
commit
d6e6298103
@ -173,6 +173,7 @@ Experimental features might be changed or removed without prior notice.
|
|||||||
| `promQLScope` | In-development feature that will allow injection of labels into prometheus queries. |
|
| `promQLScope` | In-development feature that will allow injection of labels into prometheus queries. |
|
||||||
| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph |
|
| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph |
|
||||||
| `newPDFRendering` | New implementation for the dashboard to PDF rendering |
|
| `newPDFRendering` | New implementation for the dashboard to PDF rendering |
|
||||||
|
| `kubernetesAggregator` | Enable grafana aggregator |
|
||||||
|
|
||||||
## Development feature toggles
|
## Development feature toggles
|
||||||
|
|
||||||
|
@ -176,4 +176,5 @@ export interface FeatureToggles {
|
|||||||
nodeGraphDotLayout?: boolean;
|
nodeGraphDotLayout?: boolean;
|
||||||
groupToNestedTableTransformation?: boolean;
|
groupToNestedTableTransformation?: boolean;
|
||||||
newPDFRendering?: boolean;
|
newPDFRendering?: boolean;
|
||||||
|
kubernetesAggregator?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,10 @@ func NewServiceAPIBuilder() *ServiceAPIBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *ServiceAPIBuilder {
|
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar) *ServiceAPIBuilder {
|
||||||
|
if !features.IsEnabledGlobally(featuremgmt.FlagKubernetesAggregator) {
|
||||||
|
return nil // skip registration unless opting into aggregator mode
|
||||||
|
}
|
||||||
|
|
||||||
builder := NewServiceAPIBuilder()
|
builder := NewServiceAPIBuilder()
|
||||||
apiregistration.RegisterAPI(NewServiceAPIBuilder())
|
apiregistration.RegisterAPI(NewServiceAPIBuilder())
|
||||||
return builder
|
return builder
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
[feature_toggles]
|
[feature_toggles]
|
||||||
grafanaAPIServer = true
|
|
||||||
kubernetesPlaylists = true
|
kubernetesPlaylists = true
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -51,6 +50,10 @@ data/grafana-apiserver
|
|||||||
└── hi.json
|
└── hi.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Enable aggregation
|
||||||
|
|
||||||
|
See [aggregator/README.md](./aggregator/README.md) for more information.
|
||||||
|
|
||||||
### `kubectl` access
|
### `kubectl` access
|
||||||
|
|
||||||
For kubectl to work, grafana needs to run over https. To simplify development, you can use:
|
For kubectl to work, grafana needs to run over https. To simplify development, you can use:
|
||||||
@ -59,7 +62,6 @@ For kubectl to work, grafana needs to run over https. To simplify development,
|
|||||||
app_mode = development
|
app_mode = development
|
||||||
|
|
||||||
[feature_toggles]
|
[feature_toggles]
|
||||||
grafanaAPIServer = true
|
|
||||||
grafanaAPIServerEnsureKubectlAccess = true
|
grafanaAPIServerEnsureKubectlAccess = true
|
||||||
kubernetesPlaylists = true
|
kubernetesPlaylists = true
|
||||||
```
|
```
|
||||||
|
@ -15,10 +15,13 @@ roll out features for each service without downtime.
|
|||||||
To read more about the concept, see
|
To read more about the concept, see
|
||||||
[here](https://kubernetes.io/docs/tasks/extend-kubernetes/setup-extension-api-server/).
|
[here](https://kubernetes.io/docs/tasks/extend-kubernetes/setup-extension-api-server/).
|
||||||
|
|
||||||
Note that, this aggregation will be a totally internal detail to Grafana. External fully functional APIServers that
|
Note that this aggregation will be a totally internal detail to Grafana. External fully functional API Servers that
|
||||||
may themselves act as parent API Servers to Grafana will never be made aware of them. Any of the `APIService`
|
may themselves act as parent API Servers to Grafana will never be made aware of internal Grafana API Servers.
|
||||||
related to Grafana Groups registered in a real K8s environment will take the address of Grafana's
|
Thus, any `APIService` objects corresponding to Grafana's API groups will take the address of
|
||||||
parent server (which will bundle grafana-aggregator).
|
Grafana's main API Server (the one that bundles grafana-aggregator).
|
||||||
|
|
||||||
|
Also, note that the single binary OSS offering of Grafana doesn't make use of the aggregator component at all, instead
|
||||||
|
opting for local installation of all the Grafana API groups.
|
||||||
|
|
||||||
### kube-aggregator versus grafana-aggregator
|
### kube-aggregator versus grafana-aggregator
|
||||||
|
|
||||||
@ -41,7 +44,65 @@ live under that instead.
|
|||||||
|
|
||||||
### Gotchas (Pay Attention)
|
### Gotchas (Pay Attention)
|
||||||
|
|
||||||
1. `grafana-aggregator` uses file storage under `data/grafana-aggregator` (`apiregistration.k8s.io`,
|
1. `grafana-aggregator` uses file storage under `data/grafana-apiserver` (`apiregistration.k8s.io`,
|
||||||
`service.grafana.app`) and `data/grafana-apiextensions` (`apiextensions.k8s.io`).
|
`service.grafana.app`). Thus, any restarts will still have any prior configured aggregation in effect.
|
||||||
2. Since `grafana-aggregator` outputs configuration (TLS and kubeconfig) that is used in the invocation of aggregated
|
2. During local development, ensure you start the aggregated service after launching the aggregator. This is
|
||||||
servers, ensure you start the aggregated service after launching the aggregator during local development.
|
so you have TLS and kubeconfig available for use with example aggregated api servers.
|
||||||
|
3. Ensure you have `grafanaAPIServerWithExperimentalAPIs = false` in your custom.ini. Otherwise, the example
|
||||||
|
service the following guide uses for the aggregation test is bundled as a `Local` `APIService` and will cause
|
||||||
|
configuration overwrites on startup.
|
||||||
|
|
||||||
|
## Testing aggregation locally
|
||||||
|
|
||||||
|
1. Generate the PKI using `openssl` (for development purposes, we will use the CN of `system:masters`):
|
||||||
|
```shell
|
||||||
|
./hack/make-aggregator-pki.sh
|
||||||
|
```
|
||||||
|
2. Configure the aggregator:
|
||||||
|
```ini
|
||||||
|
[feature_toggles]
|
||||||
|
grafanaAPIServerEnsureKubectlAccess = true
|
||||||
|
; disable the experimental APIs flag to disable bundling of the example service locally
|
||||||
|
grafanaAPIServerWithExperimentalAPIs = false
|
||||||
|
kubernetesAggregator = true
|
||||||
|
|
||||||
|
[grafana-apiserver]
|
||||||
|
proxy_client_cert_file = ./data/grafana-aggregator/client.crt
|
||||||
|
proxy_client_key_file = ./data/grafana-aggregator/client.key
|
||||||
|
```
|
||||||
|
3. Start the server
|
||||||
|
```shell
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
4. In another tab, apply the manifests:
|
||||||
|
```shell
|
||||||
|
export KUBECONFIG=$PWD/data/grafana-apiserver/grafana.kubeconfig
|
||||||
|
kubectl apply -f ./pkg/services/apiserver/aggregator/examples/
|
||||||
|
# SAMPLE OUTPUT
|
||||||
|
# apiservice.apiregistration.k8s.io/v0alpha1.example.grafana.app created
|
||||||
|
# externalname.service.grafana.app/example-apiserver created
|
||||||
|
|
||||||
|
kubectl get apiservice
|
||||||
|
# SAMPLE OUTPUT
|
||||||
|
# NAME SERVICE AVAILABLE AGE
|
||||||
|
# v0alpha1.example.grafana.app grafana/example-apiserver False (FailedDiscoveryCheck) 29m
|
||||||
|
```
|
||||||
|
5. In another tab, start the example microservice that will be aggregated by the parent apiserver:
|
||||||
|
```shell
|
||||||
|
go run ./pkg/cmd/grafana apiserver \
|
||||||
|
--runtime-config=example.grafana.app/v0alpha1=true \
|
||||||
|
--secure-port 7443 \
|
||||||
|
--client-ca-file=$PWD/data/grafana-aggregator/ca.crt
|
||||||
|
```
|
||||||
|
6. After 10 seconds, check `APIService` again. It should report as available.
|
||||||
|
```shell
|
||||||
|
export KUBECONFIG=$PWD/data/grafana-apiserver/grafana.kubeconfig
|
||||||
|
kubectl get apiservice
|
||||||
|
# SAMPLE OUTPUT
|
||||||
|
# NAME SERVICE AVAILABLE AGE
|
||||||
|
# v0alpha1.example.grafana.app grafana/example-apiserver True 30m
|
||||||
|
```
|
||||||
|
7. For tear down of the above test:
|
||||||
|
```shell
|
||||||
|
kubectl delete -f ./pkg/services/apiserver/aggregator/examples/
|
||||||
|
```
|
||||||
|
@ -104,9 +104,7 @@ func CreateAggregatorServer(aggregatorConfig *aggregatorapiserver.Config, shared
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = aggregatorServer.GenericAPIServer.AddPostStartHook("grafana-apiserver-autoregistration", func(context genericapiserver.PostStartHookContext) error {
|
err = aggregatorServer.GenericAPIServer.AddPostStartHook("grafana-apiserver-autoregistration", func(context genericapiserver.PostStartHookContext) error {
|
||||||
go func() {
|
go autoRegistrationController.Run(5, context.StopCh)
|
||||||
autoRegistrationController.Run(5, context.StopCh)
|
|
||||||
}()
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -198,16 +196,20 @@ func makeAPIServiceAvailableHealthCheck(name string, apiServices []*v1.APIServic
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Watch add/update events for APIServices
|
// Watch add/update events for APIServices
|
||||||
_, _ = apiServiceInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
|
_, err := apiServiceInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
|
||||||
AddFunc: func(obj interface{}) { handleAPIServiceChange(obj.(*v1.APIService)) },
|
AddFunc: func(obj interface{}) { handleAPIServiceChange(obj.(*v1.APIService)) },
|
||||||
UpdateFunc: func(old, new interface{}) { handleAPIServiceChange(new.(*v1.APIService)) },
|
UpdateFunc: func(old, new interface{}) { handleAPIServiceChange(new.(*v1.APIService)) },
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
klog.Errorf("Failed to watch APIServices for health check: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Don't return healthy until the pending list is empty
|
// Don't return healthy until the pending list is empty
|
||||||
return healthz.NamedCheck(name, func(r *http.Request) error {
|
return healthz.NamedCheck(name, func(r *http.Request) error {
|
||||||
pendingServiceNamesLock.RLock()
|
pendingServiceNamesLock.RLock()
|
||||||
defer pendingServiceNamesLock.RUnlock()
|
defer pendingServiceNamesLock.RUnlock()
|
||||||
if pendingServiceNames.Len() > 0 {
|
if pendingServiceNames.Len() > 0 {
|
||||||
|
klog.Error("APIServices not yet available", "services", pendingServiceNames.List())
|
||||||
return fmt.Errorf("missing APIService: %v", pendingServiceNames.List())
|
return fmt.Errorf("missing APIService: %v", pendingServiceNames.List())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
14
pkg/services/apiserver/aggregator/examples/apiservice.yaml
Normal file
14
pkg/services/apiserver/aggregator/examples/apiservice.yaml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: apiregistration.k8s.io/v1
|
||||||
|
kind: APIService
|
||||||
|
metadata:
|
||||||
|
name: v0alpha1.example.grafana.app
|
||||||
|
spec:
|
||||||
|
version: v0alpha1
|
||||||
|
insecureSkipTLSVerify: true
|
||||||
|
group: example.grafana.app
|
||||||
|
groupPriorityMinimum: 1000
|
||||||
|
versionPriority: 15
|
||||||
|
service:
|
||||||
|
name: example-apiserver
|
||||||
|
namespace: grafana
|
||||||
|
port: 7443
|
@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: service.grafana.app/v0alpha1
|
||||||
|
kind: ExternalName
|
||||||
|
metadata:
|
||||||
|
name: example-apiserver
|
||||||
|
namespace: grafana
|
||||||
|
spec:
|
||||||
|
host: localhost
|
@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
k8suser "k8s.io/apiserver/pkg/authentication/user"
|
||||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||||
|
"k8s.io/apiserver/pkg/authorization/authorizerfactory"
|
||||||
"k8s.io/apiserver/pkg/authorization/union"
|
"k8s.io/apiserver/pkg/authorization/union"
|
||||||
|
|
||||||
orgsvc "github.com/grafana/grafana/pkg/services/org"
|
orgsvc "github.com/grafana/grafana/pkg/services/org"
|
||||||
@ -21,6 +23,7 @@ type GrafanaAuthorizer struct {
|
|||||||
func NewGrafanaAuthorizer(cfg *setting.Cfg, orgService orgsvc.Service) *GrafanaAuthorizer {
|
func NewGrafanaAuthorizer(cfg *setting.Cfg, orgService orgsvc.Service) *GrafanaAuthorizer {
|
||||||
authorizers := []authorizer.Authorizer{
|
authorizers := []authorizer.Authorizer{
|
||||||
&impersonationAuthorizer{},
|
&impersonationAuthorizer{},
|
||||||
|
authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup),
|
||||||
}
|
}
|
||||||
|
|
||||||
// In Hosted grafana, the StackID replaces the orgID as a valid namespace
|
// In Hosted grafana, the StackID replaces the orgID as a valid namespace
|
||||||
|
@ -29,20 +29,25 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o
|
|||||||
|
|
||||||
host := fmt.Sprintf("%s:%d", ip, port)
|
host := fmt.Sprintf("%s:%d", ip, port)
|
||||||
|
|
||||||
o.RecommendedOptions.Etcd.StorageConfig.Transport.ServerList = cfg.SectionWithEnvOverrides("grafana-apiserver").Key("etcd_servers").Strings(",")
|
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
|
||||||
|
|
||||||
|
o.RecommendedOptions.Etcd.StorageConfig.Transport.ServerList = apiserverCfg.Key("etcd_servers").Strings(",")
|
||||||
|
|
||||||
o.RecommendedOptions.SecureServing.BindAddress = ip
|
o.RecommendedOptions.SecureServing.BindAddress = ip
|
||||||
o.RecommendedOptions.SecureServing.BindPort = port
|
o.RecommendedOptions.SecureServing.BindPort = port
|
||||||
o.RecommendedOptions.Authentication.RemoteKubeConfigFileOptional = true
|
o.RecommendedOptions.Authentication.RemoteKubeConfigFileOptional = true
|
||||||
o.RecommendedOptions.Authorization.RemoteKubeConfigFileOptional = true
|
o.RecommendedOptions.Authorization.RemoteKubeConfigFileOptional = true
|
||||||
|
|
||||||
|
o.AggregatorOptions.ProxyClientCertFile = apiserverCfg.Key("proxy_client_cert_file").MustString("")
|
||||||
|
o.AggregatorOptions.ProxyClientKeyFile = apiserverCfg.Key("proxy_client_key_file").MustString("")
|
||||||
|
|
||||||
o.RecommendedOptions.Admission = nil
|
o.RecommendedOptions.Admission = nil
|
||||||
o.RecommendedOptions.CoreAPI = nil
|
o.RecommendedOptions.CoreAPI = nil
|
||||||
|
|
||||||
o.StorageOptions.StorageType = options.StorageType(cfg.SectionWithEnvOverrides("grafana-apiserver").Key("storage_type").MustString(string(options.StorageTypeLegacy)))
|
o.StorageOptions.StorageType = options.StorageType(apiserverCfg.Key("storage_type").MustString(string(options.StorageTypeLegacy)))
|
||||||
o.StorageOptions.DataPath = filepath.Join(cfg.DataPath, "grafana-apiserver")
|
o.StorageOptions.DataPath = filepath.Join(cfg.DataPath, "grafana-apiserver")
|
||||||
o.ExtraOptions.DevMode = features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess)
|
o.ExtraOptions.DevMode = features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess)
|
||||||
o.ExtraOptions.ExternalAddress = host
|
o.ExtraOptions.ExternalAddress = host
|
||||||
o.ExtraOptions.APIURL = apiURL
|
o.ExtraOptions.APIURL = apiURL
|
||||||
o.ExtraOptions.Verbosity = defaultLogLevel
|
o.ExtraOptions.Verbosity = apiserverCfg.Key("log_level").MustInt(defaultLogLevel)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,132 @@
|
|||||||
|
package responsewriter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"k8s.io/apiserver/pkg/endpoints/responsewriter"
|
||||||
|
"k8s.io/klog/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ responsewriter.CloseNotifierFlusher = (*ResponseAdapter)(nil)
|
||||||
|
var _ http.ResponseWriter = (*ResponseAdapter)(nil)
|
||||||
|
var _ io.ReadCloser = (*ResponseAdapter)(nil)
|
||||||
|
|
||||||
|
func WrapHandler(handler http.Handler) func(req *http.Request) (*http.Response, error) {
|
||||||
|
// ignore the lint error because the response is passed directly to the client,
|
||||||
|
// so the client will be responsible for closing the response body.
|
||||||
|
//nolint:bodyclose
|
||||||
|
return func(req *http.Request) (*http.Response, error) {
|
||||||
|
w := NewAdapter(req)
|
||||||
|
resp := w.Response()
|
||||||
|
go func() {
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if err := w.CloseWriter(); err != nil {
|
||||||
|
klog.Errorf("error closing writer: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseAdapter is an implementation of [http.ResponseWriter] that allows conversion to a [http.Response].
|
||||||
|
type ResponseAdapter struct {
|
||||||
|
req *http.Request
|
||||||
|
res *http.Response
|
||||||
|
reader io.ReadCloser
|
||||||
|
writer io.WriteCloser
|
||||||
|
buffered *bufio.ReadWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdapter returns an initialized [ResponseAdapter].
|
||||||
|
func NewAdapter(req *http.Request) *ResponseAdapter {
|
||||||
|
r, w := io.Pipe()
|
||||||
|
writer := bufio.NewWriter(w)
|
||||||
|
reader := bufio.NewReader(r)
|
||||||
|
buffered := bufio.NewReadWriter(reader, writer)
|
||||||
|
return &ResponseAdapter{
|
||||||
|
req: req,
|
||||||
|
res: &http.Response{
|
||||||
|
Proto: req.Proto,
|
||||||
|
ProtoMajor: req.ProtoMajor,
|
||||||
|
ProtoMinor: req.ProtoMinor,
|
||||||
|
Header: make(http.Header),
|
||||||
|
},
|
||||||
|
reader: r,
|
||||||
|
writer: w,
|
||||||
|
buffered: buffered,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header implements [http.ResponseWriter].
|
||||||
|
// It returns the response headers to mutate within a handler.
|
||||||
|
func (ra *ResponseAdapter) Header() http.Header {
|
||||||
|
return ra.res.Header
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements [http.ResponseWriter].
|
||||||
|
func (ra *ResponseAdapter) Write(buf []byte) (int, error) {
|
||||||
|
return ra.buffered.Write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read implements [io.Reader].
|
||||||
|
func (ra *ResponseAdapter) Read(buf []byte) (int, error) {
|
||||||
|
return ra.buffered.Read(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader implements [http.ResponseWriter].
|
||||||
|
func (ra *ResponseAdapter) WriteHeader(code int) {
|
||||||
|
ra.res.StatusCode = code
|
||||||
|
ra.res.Status = fmt.Sprintf("%03d %s", code, http.StatusText(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush implements [http.Flusher].
|
||||||
|
func (ra *ResponseAdapter) Flush() {
|
||||||
|
if ra.buffered.Writer.Buffered() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ra.buffered.Writer.Flush(); err != nil {
|
||||||
|
klog.Error("Error flushing response buffer: ", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response returns the [http.Response] generated by the [http.Handler].
|
||||||
|
func (ra *ResponseAdapter) Response() *http.Response {
|
||||||
|
// make sure to set the status code to 200 if the request is a watch
|
||||||
|
// this is to ensure that client-go uses a streamwatcher:
|
||||||
|
// https://github.com/kubernetes/client-go/blob/76174b8af8cfd938018b04198595d65b48a69334/rest/request.go#L737
|
||||||
|
if ra.res.StatusCode == 0 && ra.req.URL.Query().Get("watch") == "true" {
|
||||||
|
ra.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
ra.res.Body = ra
|
||||||
|
return ra.res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decorate implements [responsewriter.UserProvidedDecorator].
|
||||||
|
func (ra *ResponseAdapter) Unwrap() http.ResponseWriter {
|
||||||
|
return ra
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseNotify implements [http.CloseNotifier].
|
||||||
|
func (ra *ResponseAdapter) CloseNotify() <-chan bool {
|
||||||
|
ch := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
<-ra.req.Context().Done()
|
||||||
|
ch <- true
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close implements [io.Closer].
|
||||||
|
func (ra *ResponseAdapter) Close() error {
|
||||||
|
return ra.reader.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseWriter should be called after the http.Handler has returned.
|
||||||
|
func (ra *ResponseAdapter) CloseWriter() error {
|
||||||
|
ra.Flush()
|
||||||
|
return ra.writer.Close()
|
||||||
|
}
|
@ -0,0 +1,136 @@
|
|||||||
|
package responsewriter_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
grafanaresponsewriter "github.com/grafana/grafana/pkg/services/apiserver/endpoints/responsewriter"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResponseAdapter(t *testing.T) {
|
||||||
|
t.Run("should handle synchronous write", func(t *testing.T) {
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &roundTripperFunc{
|
||||||
|
ready: make(chan struct{}),
|
||||||
|
// ignore the lint error because the response is passed directly to the client,
|
||||||
|
// so the client will be responsible for closing the response body.
|
||||||
|
//nolint:bodyclose
|
||||||
|
fn: grafanaresponsewriter.WrapHandler(http.HandlerFunc(syncHandler)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
close(client.Transport.(*roundTripperFunc).ready)
|
||||||
|
req, err := http.NewRequest("GET", "http://localhost/test", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "OK", string(bodyBytes))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle synchronous write", func(t *testing.T) {
|
||||||
|
generateRandomStrings(10)
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &roundTripperFunc{
|
||||||
|
ready: make(chan struct{}),
|
||||||
|
// ignore the lint error because the response is passed directly to the client,
|
||||||
|
// so the client will be responsible for closing the response body.
|
||||||
|
//nolint:bodyclose
|
||||||
|
fn: grafanaresponsewriter.WrapHandler(http.HandlerFunc(asyncHandler)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
close(client.Transport.(*roundTripperFunc).ready)
|
||||||
|
req, err := http.NewRequest("GET", "http://localhost/test?watch=true", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
err := resp.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// ensure that watch request is a 200
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
// limit to 100 bytes to test the reader buffer
|
||||||
|
buf := make([]byte, 100)
|
||||||
|
// holds the read bytes between iterations
|
||||||
|
cache := []byte{}
|
||||||
|
|
||||||
|
for i := 0; i < 10; {
|
||||||
|
n, err := resp.Body.Read(buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cache = append(cache, buf[:n]...)
|
||||||
|
|
||||||
|
if len(cache) >= len(randomStrings[i]) {
|
||||||
|
str := cache[:len(randomStrings[i])]
|
||||||
|
require.Equal(t, randomStrings[i], string(str))
|
||||||
|
cache = cache[len(randomStrings[i]):]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("OK"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func asyncHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
for _, s := range randomStrings {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
// write the current iteration
|
||||||
|
_, _ = w.Write([]byte(s))
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var randomStrings = []string{}
|
||||||
|
|
||||||
|
func generateRandomStrings(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
randomString := generateRandomString(1000 * (i + 1))
|
||||||
|
randomStrings = append(randomStrings, randomString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomString(n int) string {
|
||||||
|
gen := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
var chars = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
||||||
|
b := make([]rune, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = chars[gen.Intn(len(chars))]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripperFunc struct {
|
||||||
|
ready chan struct{}
|
||||||
|
fn func(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if f.fn == nil {
|
||||||
|
<-f.ready
|
||||||
|
}
|
||||||
|
res, err := f.fn(req)
|
||||||
|
return res, err
|
||||||
|
}
|
@ -97,6 +97,9 @@ func (o *AggregatorServerOptions) ApplyTo(aggregatorConfig *aggregatorapiserver.
|
|||||||
}
|
}
|
||||||
genericConfig.MergedResourceConfig = mergedResourceConfig
|
genericConfig.MergedResourceConfig = mergedResourceConfig
|
||||||
|
|
||||||
|
aggregatorConfig.ExtraConfig.ProxyClientCertFile = o.ProxyClientCertFile
|
||||||
|
aggregatorConfig.ExtraConfig.ProxyClientKeyFile = o.ProxyClientKeyFile
|
||||||
|
|
||||||
namer := openapinamer.NewDefinitionNamer(aggregatorscheme.Scheme)
|
namer := openapinamer.NewDefinitionNamer(aggregatorscheme.Scheme)
|
||||||
genericConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(o.getMergedOpenAPIDefinitions, namer)
|
genericConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config(o.getMergedOpenAPIDefinitions, namer)
|
||||||
genericConfig.OpenAPIV3Config.Info.Title = "Kubernetes"
|
genericConfig.OpenAPIV3Config.Info.Title = "Kubernetes"
|
||||||
|
@ -75,6 +75,9 @@ func (o *Options) Validate() []error {
|
|||||||
func (o *Options) ApplyTo(serverConfig *genericapiserver.RecommendedConfig) error {
|
func (o *Options) ApplyTo(serverConfig *genericapiserver.RecommendedConfig) error {
|
||||||
serverConfig.AggregatedDiscoveryGroupManager = aggregated.NewResourceManager("apis")
|
serverConfig.AggregatedDiscoveryGroupManager = aggregated.NewResourceManager("apis")
|
||||||
|
|
||||||
|
// avoid picking up an in-cluster service account token
|
||||||
|
o.RecommendedOptions.Authentication.SkipInClusterLookup = true
|
||||||
|
|
||||||
if err := o.ExtraOptions.ApplyTo(serverConfig); err != nil {
|
if err := o.ExtraOptions.ApplyTo(serverConfig); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -87,12 +90,8 @@ func (o *Options) ApplyTo(serverConfig *genericapiserver.RecommendedConfig) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if o.ExtraOptions.DevMode {
|
if err := o.RecommendedOptions.Authentication.ApplyTo(&serverConfig.Authentication, serverConfig.SecureServing, serverConfig.OpenAPIConfig); err != nil {
|
||||||
// NOTE: Only consider authn for dev mode - resolves the failure due to missing extension apiserver auth-config
|
return err
|
||||||
// in parent k8s
|
|
||||||
if err := o.RecommendedOptions.Authentication.ApplyTo(&serverConfig.Authentication, serverConfig.SecureServing, serverConfig.OpenAPIConfig); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !o.ExtraOptions.DevMode {
|
if !o.ExtraOptions.DevMode {
|
||||||
|
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
"github.com/grafana/dskit/services"
|
"github.com/grafana/dskit/services"
|
||||||
@ -26,8 +25,10 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/middleware"
|
"github.com/grafana/grafana/pkg/middleware"
|
||||||
"github.com/grafana/grafana/pkg/modules"
|
"github.com/grafana/grafana/pkg/modules"
|
||||||
"github.com/grafana/grafana/pkg/registry"
|
"github.com/grafana/grafana/pkg/registry"
|
||||||
|
"github.com/grafana/grafana/pkg/services/apiserver/aggregator"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
"github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||||
|
grafanaresponsewriter "github.com/grafana/grafana/pkg/services/apiserver/endpoints/responsewriter"
|
||||||
grafanaapiserveroptions "github.com/grafana/grafana/pkg/services/apiserver/options"
|
grafanaapiserveroptions "github.com/grafana/grafana/pkg/services/apiserver/options"
|
||||||
entitystorage "github.com/grafana/grafana/pkg/services/apiserver/storage/entity"
|
entitystorage "github.com/grafana/grafana/pkg/services/apiserver/storage/entity"
|
||||||
filestorage "github.com/grafana/grafana/pkg/services/apiserver/storage/file"
|
filestorage "github.com/grafana/grafana/pkg/services/apiserver/storage/file"
|
||||||
@ -189,12 +190,17 @@ func (s *service) start(ctx context.Context) error {
|
|||||||
|
|
||||||
groupVersions := make([]schema.GroupVersion, 0, len(builders))
|
groupVersions := make([]schema.GroupVersion, 0, len(builders))
|
||||||
// Install schemas
|
// Install schemas
|
||||||
for _, b := range builders {
|
for i, b := range builders {
|
||||||
groupVersions = append(groupVersions, b.GetGroupVersion())
|
groupVersions = append(groupVersions, b.GetGroupVersion())
|
||||||
if err := b.InstallSchema(Scheme); err != nil {
|
if err := b.InstallSchema(Scheme); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAggregator) {
|
||||||
|
// set the priority for the group+version
|
||||||
|
aggregator.APIVersionPriorities[b.GetGroupVersion()] = aggregator.Priority{Group: 15000, Version: int32(i + 1)}
|
||||||
|
}
|
||||||
|
|
||||||
auth := b.GetAuthorizer()
|
auth := b.GetAuthorizer()
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
s.authorizer.Register(b.GetGroupVersion(), auth)
|
s.authorizer.Register(b.GetGroupVersion(), auth)
|
||||||
@ -216,7 +222,7 @@ func (s *service) start(ctx context.Context) error {
|
|||||||
serverConfig.Authorization.Authorizer = s.authorizer
|
serverConfig.Authorization.Authorizer = s.authorizer
|
||||||
serverConfig.TracerProvider = s.tracing.GetTracerProvider()
|
serverConfig.TracerProvider = s.tracing.GetTracerProvider()
|
||||||
|
|
||||||
// setup loopback transport
|
// setup loopback transport for the aggregator server
|
||||||
transport := &roundTripperFunc{ready: make(chan struct{})}
|
transport := &roundTripperFunc{ready: make(chan struct{})}
|
||||||
serverConfig.LoopbackClientConfig.Transport = transport
|
serverConfig.LoopbackClientConfig.Transport = transport
|
||||||
serverConfig.LoopbackClientConfig.TLSClientConfig = clientrest.TLSClientConfig{}
|
serverConfig.LoopbackClientConfig.TLSClientConfig = clientrest.TLSClientConfig{}
|
||||||
@ -283,41 +289,100 @@ func (s *service) start(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dual writing is only enabled when the storage type is not legacy.
|
||||||
|
// this is needed to support setting a default RESTOptionsGetter for new APIs that don't
|
||||||
|
// support the legacy storage type.
|
||||||
dualWriteEnabled := o.StorageOptions.StorageType != grafanaapiserveroptions.StorageTypeLegacy
|
dualWriteEnabled := o.StorageOptions.StorageType != grafanaapiserveroptions.StorageTypeLegacy
|
||||||
|
|
||||||
// Install the API Group+version
|
// Install the API group+version
|
||||||
err = builder.InstallAPIs(Scheme, Codecs, server, serverConfig.RESTOptionsGetter, builders, dualWriteEnabled)
|
err = builder.InstallAPIs(Scheme, Codecs, server, serverConfig.RESTOptionsGetter, builders, dualWriteEnabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// set the transport function and signal that it's ready
|
// stash the options for later use
|
||||||
transport.fn = func(req *http.Request) (*http.Response, error) {
|
s.options = o
|
||||||
w := newWrappedResponseWriter()
|
|
||||||
resp := responsewriter.WrapForHTTP1Or2(w)
|
|
||||||
server.Handler.ServeHTTP(resp, req)
|
|
||||||
return w.Result(), nil
|
|
||||||
}
|
|
||||||
close(transport.ready)
|
|
||||||
|
|
||||||
// only write kubeconfig in dev mode
|
var runningServer *genericapiserver.GenericAPIServer
|
||||||
if o.ExtraOptions.DevMode {
|
if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAggregator) {
|
||||||
if err := ensureKubeConfig(server.LoopbackClientConfig, o.StorageOptions.DataPath); err != nil {
|
runningServer, err = s.startAggregator(transport, serverConfig, server)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runningServer, err = s.startCoreServer(transport, serverConfig, server)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used by the proxy wrapper registered in ProvideService
|
// only write kubeconfig in dev mode
|
||||||
s.handler = server.Handler
|
if o.ExtraOptions.DevMode {
|
||||||
s.restConfig = server.LoopbackClientConfig
|
if err := ensureKubeConfig(runningServer.LoopbackClientConfig, o.StorageOptions.DataPath); err != nil {
|
||||||
s.options = o
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// used by the proxy wrapper registered in ProvideService
|
||||||
|
s.handler = runningServer.Handler
|
||||||
|
// used by local clients to make requests to the server
|
||||||
|
s.restConfig = runningServer.LoopbackClientConfig
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) startCoreServer(
|
||||||
|
transport *roundTripperFunc,
|
||||||
|
serverConfig *genericapiserver.RecommendedConfig,
|
||||||
|
server *genericapiserver.GenericAPIServer,
|
||||||
|
) (*genericapiserver.GenericAPIServer, error) {
|
||||||
|
// setup the loopback transport and signal that it's ready.
|
||||||
|
// ignore the lint error because the response is passed directly to the client,
|
||||||
|
// so the client will be responsible for closing the response body.
|
||||||
|
// nolint:bodyclose
|
||||||
|
transport.fn = grafanaresponsewriter.WrapHandler(server.Handler)
|
||||||
|
close(transport.ready)
|
||||||
|
|
||||||
prepared := server.PrepareRun()
|
prepared := server.PrepareRun()
|
||||||
|
go func() {
|
||||||
|
s.stoppedCh <- prepared.Run(s.stopCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return server, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) startAggregator(
|
||||||
|
transport *roundTripperFunc,
|
||||||
|
serverConfig *genericapiserver.RecommendedConfig,
|
||||||
|
server *genericapiserver.GenericAPIServer,
|
||||||
|
) (*genericapiserver.GenericAPIServer, error) {
|
||||||
|
aggregatorConfig, aggregatorInformers, err := aggregator.CreateAggregatorConfig(s.options, *serverConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregatorServer, err := aggregator.CreateAggregatorServer(aggregatorConfig, aggregatorInformers, server)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// setup the loopback transport for the aggregator server and signal that it's ready
|
||||||
|
// ignore the lint error because the response is passed directly to the client,
|
||||||
|
// so the client will be responsible for closing the response body.
|
||||||
|
// nolint:bodyclose
|
||||||
|
transport.fn = grafanaresponsewriter.WrapHandler(aggregatorServer.GenericAPIServer.Handler)
|
||||||
|
close(transport.ready)
|
||||||
|
|
||||||
|
prepared, err := aggregatorServer.PrepareRun()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
s.stoppedCh <- prepared.Run(s.stopCh)
|
s.stoppedCh <- prepared.Run(s.stopCh)
|
||||||
}()
|
}()
|
||||||
return nil
|
|
||||||
|
return aggregatorServer.GenericAPIServer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config {
|
func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config {
|
||||||
@ -325,9 +390,8 @@ func (s *service) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Co
|
|||||||
Transport: &roundTripperFunc{
|
Transport: &roundTripperFunc{
|
||||||
fn: func(req *http.Request) (*http.Response, error) {
|
fn: func(req *http.Request) (*http.Response, error) {
|
||||||
ctx := appcontext.WithUser(req.Context(), c.SignedInUser)
|
ctx := appcontext.WithUser(req.Context(), c.SignedInUser)
|
||||||
w := httptest.NewRecorder()
|
wrapped := grafanaresponsewriter.WrapHandler(s.handler)
|
||||||
s.handler.ServeHTTP(w, req.WithContext(ctx))
|
return wrapped(req.WithContext(ctx))
|
||||||
return w.Result(), nil
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -367,24 +431,3 @@ func (f *roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error)
|
|||||||
}
|
}
|
||||||
return f.fn(req)
|
return f.fn(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ http.ResponseWriter = (*wrappedResponseWriter)(nil)
|
|
||||||
var _ responsewriter.UserProvidedDecorator = (*wrappedResponseWriter)(nil)
|
|
||||||
|
|
||||||
type wrappedResponseWriter struct {
|
|
||||||
*httptest.ResponseRecorder
|
|
||||||
}
|
|
||||||
|
|
||||||
func newWrappedResponseWriter() *wrappedResponseWriter {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
return &wrappedResponseWriter{w}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrappedResponseWriter) Unwrap() http.ResponseWriter {
|
|
||||||
return w.ResponseRecorder
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *wrappedResponseWriter) CloseNotify() <-chan bool {
|
|
||||||
// TODO: this is probably not the right thing to do here
|
|
||||||
return make(<-chan bool)
|
|
||||||
}
|
|
||||||
|
@ -12,6 +12,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bwmarrin/snowflake"
|
"github.com/bwmarrin/snowflake"
|
||||||
@ -57,8 +58,16 @@ var ErrFileNotExists = fmt.Errorf("file doesn't exist")
|
|||||||
// ErrNamespaceNotExists means the directory for the namespace doesn't actually exist.
|
// ErrNamespaceNotExists means the directory for the namespace doesn't actually exist.
|
||||||
var ErrNamespaceNotExists = errors.New("namespace does not exist")
|
var ErrNamespaceNotExists = errors.New("namespace does not exist")
|
||||||
|
|
||||||
|
var (
|
||||||
|
node *snowflake.Node
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
func getResourceVersion() (*uint64, error) {
|
func getResourceVersion() (*uint64, error) {
|
||||||
node, err := snowflake.NewNode(1)
|
var err error
|
||||||
|
once.Do(func() {
|
||||||
|
node, err = snowflake.NewNode(1)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -1176,6 +1176,13 @@ var (
|
|||||||
Stage: FeatureStageExperimental,
|
Stage: FeatureStageExperimental,
|
||||||
Owner: grafanaSharingSquad,
|
Owner: grafanaSharingSquad,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "kubernetesAggregator",
|
||||||
|
Description: "Enable grafana aggregator",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
Owner: grafanaAppPlatformSquad,
|
||||||
|
RequiresRestart: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -157,3 +157,4 @@ promQLScope,experimental,@grafana/observability-metrics,false,false,false
|
|||||||
nodeGraphDotLayout,experimental,@grafana/observability-traces-and-profiling,false,false,true
|
nodeGraphDotLayout,experimental,@grafana/observability-traces-and-profiling,false,false,true
|
||||||
groupToNestedTableTransformation,preview,@grafana/dataviz-squad,false,false,true
|
groupToNestedTableTransformation,preview,@grafana/dataviz-squad,false,false,true
|
||||||
newPDFRendering,experimental,@grafana/sharing-squad,false,false,false
|
newPDFRendering,experimental,@grafana/sharing-squad,false,false,false
|
||||||
|
kubernetesAggregator,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||||
|
|
@ -638,4 +638,8 @@ const (
|
|||||||
// FlagNewPDFRendering
|
// FlagNewPDFRendering
|
||||||
// New implementation for the dashboard to PDF rendering
|
// New implementation for the dashboard to PDF rendering
|
||||||
FlagNewPDFRendering = "newPDFRendering"
|
FlagNewPDFRendering = "newPDFRendering"
|
||||||
|
|
||||||
|
// FlagKubernetesAggregator
|
||||||
|
// Enable grafana aggregator
|
||||||
|
FlagKubernetesAggregator = "kubernetesAggregator"
|
||||||
)
|
)
|
||||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user