From f1dffca140bc23f870b30e13558a7d9cb2060e7c Mon Sep 17 00:00:00 2001 From: Mihai Turdean <6640685+mihai-turdean@users.noreply.github.com> Date: Thu, 4 Sep 2025 08:22:17 -0600 Subject: [PATCH] IAM Folder Reconciler: Make operator available as a target when running the grafana binary. (#110567) --- .github/CODEOWNERS | 1 + apps/iam/cmd/operator/main.go | 2 +- apps/iam/pkg/app/app.go | 4 +- go.mod | 1 + go.sum | 2 + pkg/operators/iam/README.md | 12 ++ .../iam/zanzana_folder_reconciler.go | 184 ++++++++++++++++++ pkg/operators/register.go | 7 + 8 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 pkg/operators/iam/README.md create mode 100644 pkg/operators/iam/zanzana_folder_reconciler.go diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dc6cdfacb31..b2aa0f1969a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -139,6 +139,7 @@ /pkg/apimachinery @grafana/grafana-app-platform-squad /pkg/apimachinery/identity/ @grafana/identity-squad /pkg/apimachinery/errutil/ @grafana/grafana-backend-group +/pkg/operators/iam @grafana/access-squad /pkg/promlib @grafana/oss-big-tent /pkg/storage/ @grafana/grafana-search-and-storage /pkg/storage/secret/ @grafana/grafana-operator-experience-squad diff --git a/apps/iam/cmd/operator/main.go b/apps/iam/cmd/operator/main.go index 6d4a0982f9d..786673a8665 100644 --- a/apps/iam/cmd/operator/main.go +++ b/apps/iam/cmd/operator/main.go @@ -67,7 +67,7 @@ func main() { // Create app config from operator config appCfg := app.AppConfig{ - ZanzanaCfg: cfg.ZanzanaClient, + ZanzanaClientCfg: cfg.ZanzanaClient, FolderReconcilerNamespace: cfg.FolderReconciler.Namespace, } diff --git a/apps/iam/pkg/app/app.go b/apps/iam/pkg/app/app.go index ac63c7fb001..1a1ddca4e92 100644 --- a/apps/iam/pkg/app/app.go +++ b/apps/iam/pkg/app/app.go @@ -18,7 +18,7 @@ var appManifestData = app.ManifestData{ } type AppConfig struct { - ZanzanaCfg authz.ZanzanaClientConfig + ZanzanaClientCfg authz.ZanzanaClientConfig FolderReconcilerNamespace string } @@ -33,7 +33,7 @@ func New(cfg app.Config) (app.App, error) { } folderReconciler, err := reconcilers.NewFolderReconciler(reconcilers.ReconcilerConfig{ - ZanzanaCfg: appSpecificConfig.ZanzanaCfg, + ZanzanaCfg: appSpecificConfig.ZanzanaClientCfg, KubeConfig: &cfg.KubeConfig, FolderReconcilerNamespace: appSpecificConfig.FolderReconcilerNamespace, }) diff --git a/go.mod b/go.mod index 8e5cdc08481..649998bacdf 100644 --- a/go.mod +++ b/go.mod @@ -98,6 +98,7 @@ 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 diff --git a/go.sum b/go.sum index a9f74536af2..0d6155913f5 100644 --- a/go.sum +++ b/go.sum @@ -1605,6 +1605,8 @@ github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhck github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk= github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE= github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU= +github.com/grafana/grafana-app-sdk/plugin v0.40.3 h1:uH0oFZnYOUL+OXcyhd5NVYwoM+Wa0WUXvZ2Om1M91r0= +github.com/grafana/grafana-app-sdk/plugin v0.40.3/go.mod h1:+ylwE0P8WgPu5zURK5aDnVJpwRpuK3573rwrVV28qzQ= github.com/grafana/grafana-aws-sdk v1.1.0 h1:G0fvwbQmHw14c5RXPd7Gnw9ZQcgzl139LtMDoe0KhmE= github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8= github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA= diff --git a/pkg/operators/iam/README.md b/pkg/operators/iam/README.md new file mode 100644 index 00000000000..6fb3b088099 --- /dev/null +++ b/pkg/operators/iam/README.md @@ -0,0 +1,12 @@ +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 +token_exchange_url = http://host.docker.internal:8080/v1/sign-access-token +token = ProvisioningAdminToken +``` +After that, you can run it using: `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=iam-folder-reconciler ./bin/linux-arm64/grafana server target --config=conf/operator.ini`. Beware that you will also need a TokenExchanger, a Zanzana Server and a Folder app running for the operator to behave. diff --git a/pkg/operators/iam/zanzana_folder_reconciler.go b/pkg/operators/iam/zanzana_folder_reconciler.go new file mode 100644 index 00000000000..fcaf42dc32f --- /dev/null +++ b/pkg/operators/iam/zanzana_folder_reconciler.go @@ -0,0 +1,184 @@ +package iam + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/grafana/grafana-app-sdk/k8s" + "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana-app-sdk/operator" + "github.com/grafana/grafana/apps/iam/pkg/app" + "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/services/apiserver/standalone" + "github.com/grafana/grafana/pkg/setting" + "github.com/urfave/cli/v2" + "k8s.io/client-go/rest" + "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" +) + +func init() { + server.RegisterOperator(server.Operator{ + Name: "iam-folder-reconciler", + Description: "Watch folder resources and manage IAM permissions with Zanzana", + RunFunc: RunIAMFolderReconciler, + }) +} + +func RunIAMFolderReconciler(opts standalone.BuildInfo, c *cli.Context, cfg *setting.Cfg) error { + logger := logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + })).With("logger", "iam-folder-reconciler") + logger.Info("Starting IAM folder reconciler operator") + + // Get configuration from Grafana settings + iamConfig, err := buildIAMConfigFromSettings(cfg) + if err != nil { + return fmt.Errorf("failed to build IAM config: %w", err) + } + + runner, err := operator.NewRunner(iamConfig.RunnerConfig) + if err != nil { + logger.Error("failed to create IAM operator runner", "error", err) + return fmt.Errorf("failed to create IAM operator runner: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + fmt.Println("Received shutdown signal, stopping IAM operator") + cancel() + }() + + logger.Info("Starting IAM folder reconciler") + err = runner.Run(ctx, app.Provider(iamConfig.AppConfig)) + if err != nil && !errors.Is(err, context.Canceled) { + return fmt.Errorf("IAM operator exited with error: %w", err) + } + + logger.Info("IAM folder reconciler stopped") + return nil +} + +type iamConfig struct { + RunnerConfig operator.RunnerConfig + 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("") + if token == "" { + return nil, fmt.Errorf("token is required in [iam_folder_reconciler] section") + } + iamCfg.AppConfig.ZanzanaClientCfg.Token = token + + folderAppURL := iamFolderReconcilerSec.Key("folder_app_url").MustString("") + folderAppNamespace := iamFolderReconcilerSec.Key("folder_app_namespace").MustString("default") + + kubeConfig, err := buildKubeConfigFromFolderAppURL(folderAppURL, tokenExchangeURL, token, folderAppNamespace) + 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, + }, + } + + return &iamCfg, nil +} + +func buildKubeConfigFromFolderAppURL(folderAppURL, exchangeUrl, authToken, namespace string) (*kubeconfig.NamespacedConfig, error) { + tokenExchangeClient, err := authn.NewTokenExchangeClient(authn.TokenExchangeConfig{ + TokenExchangeURL: exchangeUrl, + Token: authToken, + }) + if err != nil { + 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, + }, nil +} + +type authRoundTripper struct { + tokenExchangeClient *authn.TokenExchangeClient + transport http.RoundTripper +} + +func (t *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + tokenResponse, err := t.tokenExchangeClient.Exchange(req.Context(), authn.TokenExchangeRequest{ + Audiences: []string{"folder.grafana.app"}, + Namespace: "*", + }) + if err != nil { + return nil, fmt.Errorf("failed to exchange token: %w", err) + } + + // clone the request as RTs are not expected to mutate the passed request + req = utilnet.CloneRequest(req) + + req.Header.Set("X-Access-Token", "Bearer "+tokenResponse.Token) + return t.transport.RoundTrip(req) +} diff --git a/pkg/operators/register.go b/pkg/operators/register.go index a69327c26dc..0eaa337eae8 100644 --- a/pkg/operators/register.go +++ b/pkg/operators/register.go @@ -1,6 +1,7 @@ package operators import ( + "github.com/grafana/grafana/pkg/operators/iam" "github.com/grafana/grafana/pkg/operators/provisioning" "github.com/grafana/grafana/pkg/server" ) @@ -17,4 +18,10 @@ func init() { Description: "Watch provisioning repositories", RunFunc: provisioning.RunRepoController, }) + + server.RegisterOperator(server.Operator{ + Name: "iam-folder-reconciler", + Description: "Reconcile folder resources into Zanzana", + RunFunc: iam.RunIAMFolderReconciler, + }) }