Files
grafana/pkg/operators/provisioning/config.go
T
Roberto Jimenez Sanchez 5192e038ae fix(operators): add ExtraAudience for dashboards/folders API servers
Operators connecting to dashboards and folders API servers need to include
the provisioning group audience in addition to the target API server's
audience to pass the enforceManagerProperties check.
2025-12-16 18:37:17 +01:00

391 lines
14 KiB
Go

package provisioning
import (
"context"
"crypto/x509"
"fmt"
"net/http"
"os"
"time"
"github.com/grafana/authlib/authn"
"github.com/prometheus/client_golang/prometheus"
"k8s.io/client-go/rest"
"k8s.io/client-go/transport"
"k8s.io/client-go/util/flowcontrol"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified"
"github.com/grafana/grafana/pkg/storage/unified/resource"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
authrt "github.com/grafana/grafana/apps/provisioning/pkg/auth"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/git"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/github"
"github.com/grafana/grafana/apps/provisioning/pkg/repository/local"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks"
secretdecrypt "github.com/grafana/grafana/pkg/registry/apis/secret/decrypt"
)
// provisioningControllerConfig contains the configuration that overlaps for the jobs and repo controllers
type provisioningControllerConfig struct {
provisioningClient *client.Clientset
resyncInterval time.Duration
repoFactory repository.Factory
unified resources.ResourceStore
clients resources.ClientFactory
tokenExchangeClient *authn.TokenExchangeClient
tlsConfig rest.TLSClientConfig
}
// expects:
// [grpc_client_authentication]
// token =
// token_exchange_url =
// [secrets_manager]
// grpc_server_address =
// grpc_server_tls_server_name =
// grpc_server_use_tls =
// grpc_server_tls_ca_file =
// grpc_server_tls_skip_verify =
// [unified_storage]
// grpc_address =
// grpc_index_address =
// allow_insecure =
// audiences =
// [operator]
// provisioning_server_url =
// provisioning_server_public_url =
// dashboards_server_url =
// folders_server_url =
// tls_insecure =
// tls_cert_file =
// tls_key_file =
// tls_ca_file =
// resync_interval =
// home_path =
// local_permitted_prefixes =
// [provisioning]
// repository_types =
func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controllerCfg *provisioningControllerConfig, err error) {
if cfg == nil {
return nil, fmt.Errorf("no configuration available")
}
// TODO: we should setup tracing properly
// https://github.com/grafana/git-ui-sync-project/issues/507
tracer := tracing.NewNoopTracerService()
gRPCAuth := cfg.SectionWithEnvOverrides("grpc_client_authentication")
token := gRPCAuth.Key("token").String()
if token == "" {
return nil, fmt.Errorf("token is required in [grpc_client_authentication] section")
}
tokenExchangeURL := gRPCAuth.Key("token_exchange_url").String()
if tokenExchangeURL == "" {
return nil, fmt.Errorf("token_exchange_url is required in [grpc_client_authentication] section")
}
operatorSec := cfg.SectionWithEnvOverrides("operator")
provisioningServerURL := operatorSec.Key("provisioning_server_url").String()
if provisioningServerURL == "" {
return nil, fmt.Errorf("provisioning_server_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()
tokenExchangeClient, err := authn.NewTokenExchangeClient(authn.TokenExchangeConfig{
TokenExchangeURL: tokenExchangeURL,
Token: token,
})
if err != nil {
return nil, fmt.Errorf("failed to create token exchange client: %w", err)
}
tlsConfig, err := buildTLSConfig(tlsInsecure, tlsCertFile, tlsKeyFile, tlsCAFile)
if err != nil {
return nil, fmt.Errorf("failed to build TLS configuration: %w", err)
}
config := &rest.Config{
APIPath: "/apis",
Host: provisioningServerURL,
WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper {
return authrt.NewRoundTripper(tokenExchangeClient, rt, provisioning.GROUP)
}),
TLSClientConfig: tlsConfig,
RateLimiter: flowcontrol.NewFakeAlwaysRateLimiter(),
}
provisioningClient, err := client.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("failed to create provisioning client: %w", err)
}
decrypter, err := setupDecrypter(cfg, tracer, tokenExchangeClient)
if err != nil {
return nil, fmt.Errorf("failed to setup decrypter: %w", err)
}
repoFactory, err := setupRepoFactory(cfg, decrypter, provisioningClient, registry)
if err != nil {
return nil, fmt.Errorf("failed to setup repository getter: %w", err)
}
// HACK: This logic directly connects to unified storage. We are doing this for now as there is no global
// search endpoint. But controllers, in general, should not connect directly to unified storage and instead
// go through the api server. Once there is a global search endpoint, we will switch to that here as well.
resourceClientCfg := resource.RemoteResourceClientConfig{
Token: token,
TokenExchangeURL: tokenExchangeURL,
Namespace: gRPCAuth.Key("token_namespace").String(),
}
unified, err := setupUnifiedStorageClient(cfg, tracer, resourceClientCfg)
if err != nil {
return nil, fmt.Errorf("failed to setup unified storage: %w", err)
}
dashboardsServerURL := operatorSec.Key("dashboards_server_url").String()
if dashboardsServerURL == "" {
return nil, fmt.Errorf("dashboards_server_url is required in [operator] section")
}
foldersServerURL := operatorSec.Key("folders_server_url").String()
if foldersServerURL == "" {
return nil, fmt.Errorf("folders_server_url is required in [operator] section")
}
apiServerURLs := map[string]string{
resources.DashboardResource.Group: dashboardsServerURL,
resources.FolderResource.Group: foldersServerURL,
provisioning.GROUP: provisioningServerURL,
}
configProviders := make(map[string]apiserver.RestConfigProvider)
tlsConfigForTransport, err := rest.TLSConfigFor(&rest.Config{TLSClientConfig: tlsConfig})
if err != nil {
return nil, fmt.Errorf("failed to convert TLS config for transport: %w", err)
}
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, group, authrt.ExtraAudience(provisioning.GROUP))
}),
Transport: &http.Transport{
MaxConnsPerHost: 100,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
TLSClientConfig: tlsConfigForTransport,
},
RateLimiter: flowcontrol.NewFakeAlwaysRateLimiter(),
}
configProviders[group] = NewDirectConfigProvider(config)
}
clients := resources.NewClientFactoryForMultipleAPIServers(configProviders)
return &provisioningControllerConfig{
provisioningClient: provisioningClient,
repoFactory: repoFactory,
unified: unified,
clients: clients,
resyncInterval: operatorSec.Key("resync_interval").MustDuration(60 * time.Second),
tokenExchangeClient: tokenExchangeClient,
tlsConfig: 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
}
func setupRepoFactory(
cfg *setting.Cfg,
decrypter repository.Decrypter,
provisioningClient *client.Clientset,
registry prometheus.Registerer,
) (repository.Factory, error) {
operatorSec := cfg.SectionWithEnvOverrides("operator")
provisioningSec := cfg.SectionWithEnvOverrides("provisioning")
repoTypes := provisioningSec.Key("repository_types").Strings("|")
if len(repoTypes) == 0 {
repoTypes = []string{"github"}
}
// TODO: This depends on the different flavor of Grafana
// https://github.com/grafana/git-ui-sync-project/issues/495
extras := make([]repository.Extra, 0)
alreadyRegistered := make(map[provisioning.RepositoryType]struct{})
for _, t := range repoTypes {
if _, ok := alreadyRegistered[provisioning.RepositoryType(t)]; ok {
continue
}
alreadyRegistered[provisioning.RepositoryType(t)] = struct{}{}
switch provisioning.RepositoryType(t) {
case provisioning.GitRepositoryType:
extras = append(extras, git.Extra(decrypter))
case provisioning.GitHubRepositoryType:
var webhook *webhooks.WebhookExtraBuilder
provisioningAppURL := operatorSec.Key("provisioning_server_public_url").String()
if provisioningAppURL != "" {
webhook = webhooks.ProvideWebhooks(provisioningAppURL, registry)
}
extras = append(extras, github.Extra(
decrypter,
github.ProvideFactory(),
webhook,
),
)
case provisioning.LocalRepositoryType:
homePath := operatorSec.Key("home_path").String()
if homePath == "" {
return nil, fmt.Errorf("home_path is required in [operator] section for local repository type")
}
permittedPrefixes := operatorSec.Key("local_permitted_prefixes").Strings("|")
if len(permittedPrefixes) == 0 {
return nil, fmt.Errorf("local_permitted_prefixes is required in [operator] section for local repository type")
}
extras = append(extras, local.Extra(
homePath,
permittedPrefixes,
))
default:
return nil, fmt.Errorf("unsupported repository type: %s", t)
}
}
repoFactory, err := repository.ProvideFactory(alreadyRegistered, extras)
if err != nil {
return nil, fmt.Errorf("create repository factory: %w", err)
}
return repoFactory, nil
}
func setupDecrypter(cfg *setting.Cfg, tracer tracing.Tracer, tokenExchangeClient *authn.TokenExchangeClient) (decrypter repository.Decrypter, err error) {
secretsSec := cfg.SectionWithEnvOverrides("secrets_manager")
if secretsSec == nil {
return nil, fmt.Errorf("no [secrets_manager] section found in config")
}
address := secretsSec.Key("grpc_server_address").String()
if address == "" {
return nil, fmt.Errorf("grpc_server_address is required in [secrets_manager] section")
}
secretsTls := secretdecrypt.TLSConfig{
UseTLS: secretsSec.Key("grpc_server_use_tls").MustBool(true),
CAFile: secretsSec.Key("grpc_server_tls_ca_file").String(),
ServerName: secretsSec.Key("grpc_server_tls_server_name").String(),
InsecureSkipVerify: secretsSec.Key("grpc_server_tls_skip_verify").MustBool(false),
}
decryptSvc, err := secretdecrypt.NewGRPCDecryptClientWithTLS(
tokenExchangeClient,
tracer,
address,
secretsTls,
secretsSec.Key("grpc_client_load_balancing").MustBool(false),
)
if err != nil {
return nil, fmt.Errorf("create decrypt service: %w", err)
}
return repository.ProvideDecrypter(decryptSvc), nil
}
// HACK: This logic directly connects to unified storage. We are doing this for now as there is no global
// search endpoint. But controllers, in general, should not connect directly to unified storage and instead
// go through the api server. Once there is a global search endpoint, we will switch to that here as well.
func setupUnifiedStorageClient(cfg *setting.Cfg, tracer tracing.Tracer, resourceClientCfg resource.RemoteResourceClientConfig) (resources.ResourceStore, error) {
unifiedStorageSec := cfg.SectionWithEnvOverrides("unified_storage")
// Connect to Server
address := unifiedStorageSec.Key("grpc_address").String()
if address == "" {
return nil, fmt.Errorf("grpc_address is required in [unified_storage] section")
}
// FIXME: These metrics are not going to show up in /metrics
registry := prometheus.NewPedanticRegistry()
conn, err := unified.GrpcConn(address, registry)
if err != nil {
return nil, fmt.Errorf("create unified storage gRPC connection: %w", err)
}
// Connect to Index
indexConn := conn
indexAddress := unifiedStorageSec.Key("grpc_index_address").String()
if indexAddress != "" {
// FIXME: These metrics are not going to show up in /metrics. We will also need to wrap these metrics
// to start with something else so it doesn't collide with the storage api metrics.
registry2 := prometheus.NewPedanticRegistry()
indexConn, err = unified.GrpcConn(indexAddress, registry2)
if err != nil {
return nil, fmt.Errorf("create unified storage index gRPC connection: %w", err)
}
}
// Create client
resourceClientCfg.AllowInsecure = unifiedStorageSec.Key("allow_insecure").MustBool(false)
resourceClientCfg.Audiences = unifiedStorageSec.Key("audiences").Strings("|")
client, err := resource.NewRemoteResourceClient(tracer, conn, indexConn, resourceClientCfg)
if err != nil {
return nil, fmt.Errorf("create unified storage client: %w", err)
}
return client, nil
}
// directConfigProvider is a simple RestConfigProvider that always returns the same rest.Config
// it implements apiserver.RestConfigProvider
type directConfigProvider struct {
cfg *rest.Config
}
func NewDirectConfigProvider(cfg *rest.Config) apiserver.RestConfigProvider {
return &directConfigProvider{cfg: cfg}
}
func (r *directConfigProvider) GetRestConfig(ctx context.Context) (*rest.Config, error) {
return r.cfg, nil
}