Implement a basic operator to reconcile the folder hierarchy from Unistore to Zanzana (#109705)

This commit is contained in:
Mihai Turdean 2025-08-20 11:14:06 -06:00 committed by GitHub
parent f7d39204cd
commit c8b0fd685b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 3317 additions and 9 deletions

View File

@ -1,5 +1,7 @@
include ../sdk.mk
OPERATOR_DOCKERIMAGE := "github.com/grafana/grafana/apps/iam/operator"
.PHONY: generate
generate: install-app-sdk update-app-sdk ## Run Grafana App SDK code generation
@$(APP_SDK_BIN) generate \
@ -8,4 +10,45 @@ generate: install-app-sdk update-app-sdk ## Run Grafana App SDK code generation
--grouping=group \
--defencoding=none \
--noschemasinmanifest \
--postprocess \
--postprocess
.PHONY: deps
deps:
@go mod tidy
@GOWORK=off go mod vendor
.PHONY: build
build: build/operator
.PHONY: build/operator
build/operator:
docker build -t $(OPERATOR_DOCKERIMAGE) -f cmd/operator/Dockerfile .
.PHONY: local/up
local/up:
@./local/scripts/cluster.sh create "local/k3d-config.json"
@cd local && tilt up
.PHONY: local/generate
local/generate:
@grafana-app-sdk project local generate
.PHONY: local/down
local/down:
@cd local && tilt down
.PHONY: local/deploy_plugin
local/deploy_plugin:
-tilt disable grafana
cp -R plugin/dist local/mounted-files/plugin/dist
-tilt enable grafana
.PHONY: local/push_operator
local/push_operator: build/operator
# Tag the docker image as part of localhost, which is what the generated k8s uses to avoid confusion with the real operator image
@docker tag "$(OPERATOR_DOCKERIMAGE):latest" "localhost/$(OPERATOR_DOCKERIMAGE):latest"
@./local/scripts/push_image.sh "localhost/$(OPERATOR_DOCKERIMAGE):latest"
.PHONY: local/clean
local/clean: local/down
@./local/scripts/cluster.sh delete

View File

@ -0,0 +1,16 @@
FROM golang:1.24-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
COPY vendor* ./vendor
RUN test -f vendor/modules.txt || go mod download
COPY cmd cmd
COPY pkg pkg
RUN go build -o "target/operator" cmd/operator/*.go
FROM alpine AS runtime
COPY --from=builder /build/target/operator /usr/bin/operator
ENTRYPOINT ["/usr/bin/operator"]

View File

@ -0,0 +1,31 @@
package main
import (
"fmt"
"net/http"
utilnet "k8s.io/apimachinery/pkg/util/net"
"github.com/grafana/authlib/authn"
)
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)
}

View File

@ -0,0 +1,120 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"github.com/grafana/grafana-app-sdk/plugin/kubeconfig"
"github.com/grafana/grafana-app-sdk/simple"
)
const (
ConnTypeGRPC = "grpc"
ConnTypeHTTP = "http"
)
type Config struct {
OTelConfig simple.OpenTelemetryConfig
WebhookServer WebhookServerConfig
KubeConfig *kubeconfig.NamespacedConfig
ZanzanaClient ZanzanaClientConfig
FolderReconciler FolderReconcilerConfig
}
type WebhookServerConfig struct {
Port int
TLSCertPath string
TLSKeyPath string
}
type ZanzanaClientConfig struct {
Addr string
}
type FolderReconcilerConfig struct {
Namespace string
}
func LoadConfigFromEnv() (*Config, error) {
cfg := Config{}
cfg.OTelConfig.ServiceName = os.Getenv("OTEL_SERVICE_NAME")
switch strings.ToLower(os.Getenv("OTEL_CONN_TYPE")) {
case ConnTypeGRPC:
cfg.OTelConfig.ConnType = ConnTypeGRPC
case ConnTypeHTTP:
cfg.OTelConfig.ConnType = ConnTypeHTTP
case "":
// Default
cfg.OTelConfig.ConnType = ConnTypeHTTP
default:
return nil, fmt.Errorf("unknown OTEL_CONN_TYPE '%s'", os.Getenv("OTEL_CONN_TYPE"))
}
cfg.OTelConfig.Host = os.Getenv("OTEL_HOST")
portStr := os.Getenv("OTEL_PORT")
if portStr == "" {
if cfg.OTelConfig.ConnType == ConnTypeGRPC {
// Default OTel GRPC port
cfg.OTelConfig.Port = 4317
} else {
// Default OTel HTTP port
cfg.OTelConfig.Port = 4318
}
} else {
var err error
cfg.OTelConfig.Port, err = strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid OTEL_PORT '%s': %w", portStr, err)
}
}
whPortStr := os.Getenv("WEBHOOK_PORT")
if whPortStr == "" {
cfg.WebhookServer.Port = 8443
} else {
var err error
cfg.WebhookServer.Port, err = strconv.Atoi(whPortStr)
if err != nil {
return nil, fmt.Errorf("invalid WEBHOOK_PORT '%s': %w", whPortStr, err)
}
}
cfg.WebhookServer.TLSCertPath = os.Getenv("WEBHOOK_CERT_PATH")
cfg.WebhookServer.TLSKeyPath = os.Getenv("WEBHOOK_KEY_PATH")
// Load the kube config
kubeConfigFile := os.Getenv("KUBE_CONFIG_FILE")
if kubeConfigFile != "" {
kubeConfig, err := LoadKubeConfigFromFile(kubeConfigFile)
if err != nil {
return nil, fmt.Errorf("unable to load kubernetes configuration from file '%s': %w", kubeConfigFile, err)
}
cfg.KubeConfig = kubeConfig
} else if folderAppURL := os.Getenv("FOLDER_APP_URL"); folderAppURL != "" {
exchangeUrl := os.Getenv("AUTH_TOKEN_EXCHANGE_URL")
authToken := os.Getenv("AUTH_TOKEN")
namespace := os.Getenv("FOLDER_APP_NAMESPACE")
if exchangeUrl == "" || authToken == "" {
return nil, fmt.Errorf("AUTH_TOKEN_EXCHANGE_URL and AUTH_TOKEN must be set when FOLDER_APP_URL is set")
}
kubeConfig, err := LoadKubeConfigFromFolderAppURL(folderAppURL, exchangeUrl, authToken, namespace)
if err != nil {
return nil, fmt.Errorf("unable to load kubernetes configuration from folder app URL '%s': %w", folderAppURL, err)
}
cfg.KubeConfig = kubeConfig
} else {
kubeConfig, err := LoadInClusterConfig()
if err != nil {
return nil, fmt.Errorf("unable to load in-cluster kubernetes configuration: %w", err)
}
cfg.KubeConfig = kubeConfig
}
cfg.ZanzanaClient.Addr = os.Getenv("ZANZANA_ADDR")
cfg.FolderReconciler.Namespace = os.Getenv("FOLDER_RECONCILER_NAMESPACE")
return &cfg, nil
}

View File

@ -0,0 +1,85 @@
package main
import (
"fmt"
"net/http"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/transport"
"github.com/grafana/authlib/authn"
"github.com/grafana/grafana-app-sdk/plugin/kubeconfig"
)
// LoadInClusterConfig loads a kubernetes in-cluster config.
// Since the in-cluster config doesn't have a namespace, it defaults to "default"
func LoadInClusterConfig() (*kubeconfig.NamespacedConfig, error) {
cfg, err := rest.InClusterConfig()
if err != nil {
return nil, err
}
cfg.APIPath = "/apis"
return &kubeconfig.NamespacedConfig{
RestConfig: *cfg,
Namespace: "default",
}, nil
}
// LoadKubeConfigFromEnv loads a NamespacedConfig from the value of an environment variable
func LoadKubeConfigFromFolderAppURL(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
}
// LoadKubeConfigFromFile loads a NamespacedConfig from a file on-disk (such as a mounted secret)
func LoadKubeConfigFromFile(configPath string) (*kubeconfig.NamespacedConfig, error) {
// Load the kubeconfig file
config, err := clientcmd.LoadFromFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to load kubeconfig from %s: %w", configPath, err)
}
// Build the REST config from the kubeconfig
restConfig, err := clientcmd.NewDefaultClientConfig(*config, &clientcmd.ConfigOverrides{}).ClientConfig()
if err != nil {
return nil, fmt.Errorf("failed to create REST config: %w", err)
}
// Get the namespace from the current context, default to "default" if not set
namespace := "default"
if config.CurrentContext != "" {
if context, exists := config.Contexts[config.CurrentContext]; exists && context.Namespace != "" {
namespace = context.Namespace
}
}
restConfig.APIPath = "/apis"
return &kubeconfig.NamespacedConfig{
RestConfig: *restConfig,
Namespace: namespace,
}, nil
}

View File

@ -0,0 +1,82 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"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-app-sdk/simple"
"github.com/grafana/grafana/apps/iam/pkg/app"
)
func main() {
// Configure the default logger to use slog
logging.DefaultLogger = logging.NewSLogLogger(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
//Load the config from the environment
cfg, err := LoadConfigFromEnv()
if err != nil {
logging.DefaultLogger.With("error", err).Error("Unable to load config from environment")
panic(err)
}
// Set up tracing
if cfg.OTelConfig.Host != "" {
err = simple.SetTraceProvider(simple.OpenTelemetryConfig{
Host: cfg.OTelConfig.Host,
Port: cfg.OTelConfig.Port,
ConnType: cfg.OTelConfig.ConnType,
ServiceName: cfg.OTelConfig.ServiceName,
})
if err != nil {
logging.DefaultLogger.With("error", err).Error("Unable to set trace provider")
panic(err)
}
}
// Create the operator config and the runner
operatorConfig := operator.RunnerConfig{
KubeConfig: cfg.KubeConfig.RestConfig,
WebhookConfig: operator.RunnerWebhookConfig{
Port: cfg.WebhookServer.Port,
TLSConfig: k8s.TLSConfig{
CertPath: cfg.WebhookServer.TLSCertPath,
KeyPath: cfg.WebhookServer.TLSKeyPath,
},
},
MetricsConfig: operator.RunnerMetricsConfig{
Enabled: true,
},
}
runner, err := operator.NewRunner(operatorConfig)
if err != nil {
logging.DefaultLogger.With("error", err).Error("Unable to create operator runner")
panic(err)
}
// Context and cancel for the operator's Run method
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
defer cancel()
// Create app config from operator config
appCfg := app.AppConfig{
ZanzanaAddr: cfg.ZanzanaClient.Addr,
FolderReconcilerNamespace: cfg.FolderReconciler.Namespace,
}
// Run
logging.DefaultLogger.Info("Starting operator")
err = runner.Run(ctx, app.Provider(appCfg))
if err != nil {
logging.DefaultLogger.With("error", err).Error("Operator exited with error")
panic(err)
}
logging.DefaultLogger.Info("Normal operator exit")
}

View File

@ -2,65 +2,352 @@ module github.com/grafana/grafana/apps/iam
go 1.24.6
replace github.com/grafana/grafana => ../../
replace github.com/grafana/grafana/apps/folder => ../folder
replace github.com/grafana/grafana/apps/dashboard => ../dashboard
replace github.com/grafana/grafana/apps/secret => ../secret
replace github.com/grafana/grafana/apps/provisioning => ../provisioning
replace github.com/grafana/grafana/pkg/apimachinery => ../../pkg/apimachinery
replace github.com/grafana/grafana/pkg/apiserver => ../../pkg/apiserver
replace github.com/grafana/grafana/pkg/aggregator => ../../pkg/aggregator
replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-alertmanager v0.25.1-0.20250620093340-be61a673dee6
require (
github.com/grafana/authlib v0.0.0-20250710201142-9542f2f28d43
github.com/grafana/grafana v0.0.0-00010101000000-000000000000
github.com/grafana/grafana-app-sdk v0.40.3
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e
github.com/grafana/grafana-app-sdk/logging v0.40.2
github.com/grafana/grafana-app-sdk/plugin v0.40.3
github.com/grafana/grafana/apps/folder v0.0.0
github.com/grafana/grafana/pkg/apimachinery v0.0.0
google.golang.org/grpc v1.74.2
k8s.io/apimachinery v0.33.3
k8s.io/client-go v0.33.3
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff
)
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cuelang.org/go v0.11.1 // indirect
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // indirect
github.com/Yiling-J/theine-go v0.6.1 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/apache/arrow-go/v18 v18.3.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/at-wat/mqtt-go v0.19.4 // indirect
github.com/aws/aws-sdk-go v1.55.7 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect
github.com/aws/smithy-go v1.22.4 // indirect
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/bluele/gcache v0.0.2 // indirect
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/bufbuild/protocompile v0.4.0 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cheekybits/genny v1.0.0 // indirect
github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect
github.com/dlmiddlecote/sqlstats v1.0.2 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect
github.com/dolthub/go-icu-regex v0.0.0-20250327004329-6799764f2dad // indirect
github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // indirect
github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect
github.com/dolthub/vitess v0.0.0-20250410090211-143e6b272ad4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elazarl/goproxy v1.7.2 // indirect
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fullstorydev/grpchan v1.1.1 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gchaincl/sqlhooks v1.3.0 // indirect
github.com/getkin/kin-openapi v0.132.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.23.0 // indirect
github.com/go-openapi/errors v0.22.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/loads v0.22.0 // indirect
github.com/go-openapi/runtime v0.28.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/strfmt v0.23.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-test/deep v1.1.1 // indirect
github.com/go-openapi/validate v0.24.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.3.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/googleapis v1.4.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gogo/status v1.1.1 // indirect
github.com/golang-migrate/migrate/v4 v4.7.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.25.0 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/grafana/grafana-app-sdk/logging v0.40.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/wire v0.6.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grafana/alerting v0.0.0-20250812203446-a38b2187d4e1 // indirect
github.com/grafana/authlib/types v0.0.0-20250710201142-9542f2f28d43 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20250611075409-46f51e1ce914 // indirect
github.com/grafana/grafana-aws-sdk v1.1.0 // indirect
github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // indirect
github.com/grafana/grafana-plugin-sdk-go v0.278.0 // indirect
github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect
github.com/grafana/grafana/pkg/apiserver v0.0.0 // indirect
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect
github.com/grafana/sqlds/v4 v4.2.4 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-plugin v1.6.3 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/memberlist v0.5.2 // indirect
github.com/hashicorp/yamux v0.1.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jaegertracing/jaeger-idl v0.5.0 // indirect
github.com/jessevdk/go-flags v1.6.1 // indirect
github.com/jhump/protoreflect v1.15.1 // indirect
github.com/jmespath-community/go-jmespath v1.1.1 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jszwedko/go-datemath v0.1.1-0.20230526204004-640a500621d6 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lestrrat-go/strftime v1.0.4 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattetti/filebuffer v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/mdlayher/vsock v1.2.1 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.63 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/mithrandie/csvq v1.18.1 // indirect
github.com/mithrandie/csvq-driver v1.7.0 // indirect
github.com/mithrandie/go-file/v2 v2.1.0 // indirect
github.com/mithrandie/go-text v1.6.0 // indirect
github.com/mithrandie/ternary v1.1.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
github.com/natefinch/wrap v0.2.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/nikunjy/rules v1.5.0 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/oklog/ulid/v2 v2.1.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/open-feature/go-sdk v1.14.1 // indirect
github.com/open-feature/go-sdk-contrib/providers/go-feature-flag v0.2.3 // indirect
github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5 // indirect
github.com/openfga/api/proto v0.0.0-20250127102726-f9709139a369 // indirect
github.com/openfga/language/pkg/go v0.2.0-beta.2.0.20250220223040-ed0cfba54336 // indirect
github.com/openfga/openfga v1.8.13 // indirect
github.com/opentracing-contrib/go-stdlib v1.0.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pressly/goose/v3 v3.24.3 // indirect
github.com/prometheus/alertmanager v0.28.0 // indirect
github.com/prometheus/client_golang v1.23.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/common/sigv4 v0.1.0 // indirect
github.com/prometheus/exporter-toolkit v0.14.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/shurcooL/httpfs v0.0.0-20230704072500-f1e31cf0ba5c // indirect
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/spf13/viper v1.20.1 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wazero v1.8.2 // indirect
github.com/thomaspoignant/go-feature-flag v1.42.0 // indirect
github.com/tjhop/slog-gokit v0.1.3 // indirect
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect
github.com/unknwon/com v1.0.1 // indirect
github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect
github.com/urfave/cli v1.22.16 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.mongodb.org/mongo-driver v1.17.3 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.61.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.36.0 // indirect
go.opentelemetry.io/contrib/samplers/jaegerremote v0.30.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.12.2 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.12.2 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.59.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.12.2 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 // indirect
go.opentelemetry.io/otel/log v0.12.2 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.12.2 // indirect
go.opentelemetry.io/otel/sdk/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/mock v0.5.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/term v0.33.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.11.0 // indirect
golang.org/x/tools v0.35.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
gonum.org/v1/gonum v0.16.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/mail.v2 v2.3.1 // indirect
gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect
gopkg.in/telebot.v3 v3.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/client-go v0.33.3 // indirect
k8s.io/api v0.33.3 // indirect
k8s.io/apiextensions-apiserver v0.33.3 // indirect
k8s.io/apiserver v0.33.3 // indirect
k8s.io/component-base v0.33.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
modernc.org/libc v1.65.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect
sigs.k8s.io/yaml v1.5.0 // indirect
xorm.io/builder v0.3.6 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,7 @@ manifest: {
v0alpha1: {
kinds: [
globalrolev0alpha1,
globalrolev0alpha1,
globalrolebindingv0alpha1,
corerolev0alpha1,
rolev0alpha1,
@ -21,4 +21,4 @@ v0alpha1: {
teambindingv0alpha1,
serviceaccountv0alpha1,
]
}
}

57
apps/iam/local/Tiltfile Normal file
View File

@ -0,0 +1,57 @@
# version_settings() enforces a minimum Tilt version
# https://docs.tilt.dev/api.html#api.version_settings
version_settings(constraint='>=0.22.2')
def name(c):
return c['metadata']['name']
def namespace(c):
if 'namespace' in c['metadata']:
return c['metadata']['namespace']
return ''
def decode(yaml):
resources = decode_yaml_stream(yaml)
# workaround a bug in decode_yaml_stream where it returns duplicates
# This bug has been fixed in Tilt v0.17.3+
filtered = []
names = {}
for r in resources:
if r == None:
continue
n = '%s:%s:%s' % (name(r), r['kind'], namespace(r))
if n in names:
continue
names[n] = True
filtered.append(r)
return filtered
def find_overlapping(o, yamls):
for elem in yamls:
if name(o) == name(elem) and o['kind'] == elem['kind'] and namespace(o) == namespace(elem):
return elem
return None
yaml_objects = []
# Parse all YAML files in our "yamls" directory
for filename in listdir('yamls'):
if filename.lower().endswith(('.yaml', '.yml')):
decoded = decode(read_file(filename))
for o in decoded:
present = find_overlapping(o, yaml_objects)
if present != None:
print("Overlapping resource found: %s", filename)
exit(1)
yaml_objects += decoded
bundle = encode_yaml_stream(yaml_objects)
# k8s_yaml automatically creates resources in Tilt for the entities
# and will inject any images referenced in the Tiltfile when deploying
# https://docs.tilt.dev/api.html#api.k8s_yaml
k8s_yaml(bundle)

View File

@ -0,0 +1,16 @@
{
"apiVersion": "k3d.io/v1alpha3",
"kind": "Simple",
"kubeAPI": {
"hostPort": "8556"
},
"options": {
"k3d": {
"wait": true
},
"kubeconfig": {
"switchCurrentContext": true,
"updateDefaultKubeconfig": true
}
}
}

View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -eufo pipefail
CLUSTER_NAME="grafana-iam-operator"
create_cluster() {
K3D_CONFIG="${1:-k3d-config.json}"
if ! k3d cluster list "${CLUSTER_NAME}" >/dev/null 2>&1; then
# Array of extra options to add to the k3d cluster create command
EXTRA_K3D_OPTS=()
# Bug in k3d for btrfs filesystems workaround, see https://k3d.io/v5.2.2/faq/faq/#issues-with-btrfs
# Apple is APFS/HFS and stat has a different API, so we might as well skip that
if [[ "${OSTYPE}" != "darwin*" ]]; then
ROOTFS="$(stat -f --format="%T" "/")"
if [[ "${ROOTFS}" == "btrfs" ]]; then
EXTRA_K3D_OPTS+=("-v" "/dev/mapper:/dev/mapper")
fi
fi
k3d cluster create "${CLUSTER_NAME}" --config "${K3D_CONFIG}" ${EXTRA_K3D_OPTS[@]+"${EXTRA_K3D_OPTS[@]}"}
else
echo "Cluster already exists"
fi
}
delete_cluster() {
k3d cluster delete "${CLUSTER_NAME}"
}
if [ $# -lt 1 ]; then
echo "Usage: ./cluster.sh [create|delete]"
exit 1
fi
if [ $1 == "create" ]; then
create_cluster $2
elif [ $1 == "delete" ]; then
delete_cluster
else
echo "Unknown argument ${1}"
fi

View File

@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -eufo pipefail
CLUSTER_NAME="grafana-iam-operator"
IMAGE="$1"
if [[ "$IMAGE" == "" ]]; then
echo "usage: push_image.sh <image_name:tag>"
exit 1
fi
k3d image import "${IMAGE}" -c "${CLUSTER_NAME}"

View File

@ -0,0 +1,45 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: operator
namespace: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iam-app-operator
namespace: default
spec:
minReadySeconds: 10
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
name: iam-app-operator
template:
metadata:
labels:
name: iam-app-operator
spec:
serviceAccount: operator
containers:
- image: localhost/github.com/grafana/grafana/apps/iam/operator:latest
imagePullPolicy: IfNotPresent
name: iam-app-operator
command: ["/bin/sh"]
args:
- -c
- exec /usr/bin/operator
env:
- name: ZANZANA_ADDR
value: zanzana.default.svc.cluster.local:50051
- name: FOLDER_APP_URL
value: https://host.docker.internal:6446
- name: AUTH_TOKEN_EXCHANGE_URL
value: http://host.docker.internal:8080/v1/sign-access-token
- name: AUTH_TOKEN
value: ""
- name: FOLDER_APP_NAMESPACE
value: "grafana-folder"
- name: FOLDER_RECONCILER_NAMESPACE
value: "default"

View File

@ -0,0 +1,177 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:15.7
env:
- name: POSTGRES_USER
value: grafana
- name: POSTGRES_PASSWORD
value: password
- name: POSTGRES_DB
value: grafana
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
ports:
- containerPort: 5432
name: postgres
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
readinessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -U $POSTGRES_USER -d $POSTGRES_DB
initialDelaySeconds: 15
periodSeconds: 5
livenessProbe:
exec:
command:
- /bin/sh
- -c
- pg_isready -U $POSTGRES_USER -d $POSTGRES_DB
initialDelaySeconds: 30
periodSeconds: 10
volumes:
- name: postgres-storage
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: default
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
name: postgres
type: ClusterIP
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: zanzana
namespace: default
---
apiVersion: v1
kind: ConfigMap
metadata:
name: zanzana-config
namespace: default
labels:
name: zanzana
data:
grafana.ini: |
app_mode = development
target = zanzana-server
[log]
level = info
[database]
type = postgres
host = postgres.default.svc:5432
name = grafana
user = grafana
password = password
[feature_toggles]
zanzana = true
authZGRPCServer = true
[zanzana.server]
allow_insecure = true
check_query_cache = true
http_addr = 0.0.0.0:8080
[grpc_server]
enabled = true
address = 0.0.0.0:50051
enable_logging = true
---
apiVersion: v1
kind: Service
metadata:
name: zanzana
namespace: default
labels:
name: zanzana
spec:
ports:
- name: http
port: 8080
targetPort: 8080
- name: grpc
port: 50051
targetPort: 50051
selector:
name: zanzana
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: zanzana
namespace: default
spec:
minReadySeconds: 10
replicas: 1
revisionHistoryLimit: 10
selector:
matchLabels:
name: zanzana
template:
metadata:
labels:
name: zanzana
spec:
containers:
- command:
- grafana-server
- target
- --config=/etc/grafana-config/grafana.ini
- --homepath=/usr/share/grafana
env:
- name: GF_PATHS_CONFIG
value: /etc/grafana-config/grafana.ini
image: grafana/grafana-oss-dev:12.2.0-257970
imagePullPolicy: IfNotPresent
name: zanzana
volumeMounts:
- mountPath: /etc/grafana-config
name: zanzana-config
- mountPath: /var/lib/grafana
name: zanzana-storage
serviceAccount: zanzana
volumes:
- configMap:
name: zanzana-config
name: zanzana-config
- emptyDir: {}
name: zanzana-storage
---

63
apps/iam/pkg/app/app.go Normal file
View File

@ -0,0 +1,63 @@
package app
import (
"context"
"fmt"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-app-sdk/simple"
foldersKind "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/apps/iam/pkg/reconcilers"
)
type AppConfig = reconcilers.AppConfig
var appManifestData = app.ManifestData{
AppName: "iam-folder-reconciler",
Group: "iam.grafana.app",
}
func Provider(appCfg AppConfig) app.Provider {
return simple.NewAppProvider(app.NewEmbeddedManifest(appManifestData), appCfg, New)
}
func New(cfg app.Config) (app.App, error) {
folderReconciler, err := reconcilers.NewFolderReconciler(cfg)
if err != nil {
return nil, fmt.Errorf("unable to create FolderReconciler: %w", err)
}
logging.DefaultLogger.Info("FolderReconciler created")
config := simple.AppConfig{
Name: cfg.ManifestData.AppName,
KubeConfig: cfg.KubeConfig,
InformerConfig: simple.AppInformerConfig{
ErrorHandler: func(ctx context.Context, err error) {
// FIXME: add your own error handling here
logging.FromContext(ctx).With("error", err).Error("Informer processing error")
},
},
UnmanagedKinds: []simple.AppUnmanagedKind{
{
Kind: foldersKind.FolderKind(),
Reconciler: folderReconciler,
ReconcileOptions: simple.BasicReconcileOptions{
Namespace: cfg.SpecificConfig.(AppConfig).FolderReconcilerNamespace,
},
},
},
}
// Create the App
a, err := simple.NewApp(config)
if err != nil {
return nil, err
}
// Validate the capabilities against the provided manifest to make sure there isn't a mismatch
err = a.ValidateManifest(cfg.ManifestData)
return a, err
}

View File

@ -0,0 +1,173 @@
package reconcilers
import (
"context"
"fmt"
"time"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-app-sdk/operator"
foldersKind "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// FolderStore interface for retrieving folder information
type FolderStore interface {
GetFolderParent(ctx context.Context, namespace, uid string) (string, error)
}
// PermissionStore interface for managing folder permissions
type PermissionStore interface {
GetFolderParents(ctx context.Context, namespace, folderUID string) ([]string, error)
SetFolderParent(ctx context.Context, namespace, folderUID, parentUID string) error
DeleteFolderParents(ctx context.Context, namespace, folderUID string) error
}
// AppConfig represents the app-specific configuration
type AppConfig struct {
ZanzanaAddr string
FolderReconcilerNamespace string
}
type FolderReconciler struct {
permissionStore PermissionStore
folderStore FolderStore
}
func NewFolderReconciler(cfg app.Config) (operator.Reconciler, error) {
// Extract Zanzana address from config
appCfg, ok := cfg.SpecificConfig.(AppConfig)
if !ok {
return nil, fmt.Errorf("invalid config type: expected AppConfig, got %T", cfg.SpecificConfig)
}
// Create Zanzana client
zanzanaClient, err := getZanzanaClient(appCfg.ZanzanaAddr)
if err != nil {
return nil, fmt.Errorf("unable to create zanzana client: %w", err)
}
// Create dependencies
folderStore := NewAPIFolderStore(&cfg.KubeConfig)
permissionStore := NewZanzanaPermissionStore(zanzanaClient)
folderReconciler := &FolderReconciler{
permissionStore: permissionStore,
folderStore: folderStore,
}
reconciler := &operator.TypedReconciler[*foldersKind.Folder]{
ReconcileFunc: folderReconciler.reconcile,
}
return reconciler, nil
}
func getZanzanaClient(addr string) (zanzana.Client, error) {
transportCredentials := insecure.NewCredentials()
dialOptions := []grpc.DialOption{
grpc.WithTransportCredentials(transportCredentials),
}
conn, err := grpc.NewClient(addr, dialOptions...)
if err != nil {
return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err)
}
client, err := zanzana.NewClient(conn)
if err != nil {
return nil, fmt.Errorf("failed to initialize zanzana client: %w", err)
}
return client, nil
}
func (r *FolderReconciler) reconcile(ctx context.Context, req operator.TypedReconcileRequest[*foldersKind.Folder]) (operator.ReconcileResult, error) {
// Add timeout to prevent hanging operations
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
logger := logging.FromContext(ctx)
logger.Info("Reconciling request", "req", req)
err := validateFolder(req.Object)
if err != nil {
return operator.ReconcileResult{}, err
}
switch req.Action {
case operator.ReconcileActionCreated:
return r.handleUpdateFolder(ctx, req.Object)
case operator.ReconcileActionUpdated:
return r.handleUpdateFolder(ctx, req.Object)
case operator.ReconcileActionDeleted:
return r.handleDeleteFolder(ctx, req.Object)
default:
return operator.ReconcileResult{}, nil
}
}
func (r *FolderReconciler) handleUpdateFolder(ctx context.Context, folder *foldersKind.Folder) (operator.ReconcileResult, error) {
logger := logging.FromContext(ctx)
folderUID := folder.Name
namespace := folder.Namespace
parentUID, err := r.folderStore.GetFolderParent(ctx, namespace, folderUID)
if err != nil {
return operator.ReconcileResult{}, err
}
parents, err := r.permissionStore.GetFolderParents(ctx, namespace, folderUID)
if err != nil {
return operator.ReconcileResult{}, err
}
if (len(parents) == 0 && parentUID == "") || (len(parents) == 1 && parents[0] == parentUID) {
// Folder is already reconciled
logger.Info("Folder is already reconciled", "folder", folderUID, "parent", parentUID, "namespace", namespace)
return operator.ReconcileResult{}, nil
}
err = r.permissionStore.SetFolderParent(ctx, namespace, folderUID, parentUID)
if err != nil {
return operator.ReconcileResult{}, err
}
logger.Info("Folder parent set in permission store", "folder", folderUID, "parent", parentUID, "namespace", namespace)
return operator.ReconcileResult{}, nil
}
func (r *FolderReconciler) handleDeleteFolder(ctx context.Context, folder *foldersKind.Folder) (operator.ReconcileResult, error) {
logger := logging.FromContext(ctx)
namespace := folder.Namespace
folderUID := folder.Name
err := r.permissionStore.DeleteFolderParents(ctx, namespace, folderUID)
if err != nil {
return operator.ReconcileResult{}, err
}
logger.Info("Folder deleted from permission store", "folder", folderUID, "namespace", namespace)
return operator.ReconcileResult{}, nil
}
func validateFolder(folder *foldersKind.Folder) error {
if folder == nil {
return fmt.Errorf("folder is nil")
}
if folder.Name == "" {
return fmt.Errorf("folder UID (ObjectMeta.Name) is empty")
}
if folder.Namespace == "" {
return fmt.Errorf("folder namespace is empty")
}
return nil
}

View File

@ -0,0 +1,50 @@
package reconcilers
import (
"context"
"fmt"
foldersKind "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
)
var _ FolderStore = (*APIFolderStore)(nil)
func NewAPIFolderStore(config *rest.Config) FolderStore {
return &APIFolderStore{config}
}
type APIFolderStore struct {
config *rest.Config
}
func (s *APIFolderStore) GetFolderParent(ctx context.Context, namespace, uid string) (string, error) {
client, err := s.client(namespace)
if err != nil {
return "", fmt.Errorf("create resource client: %w", err)
}
// Get the folder by UID
unstructuredObj, err := client.Get(ctx, uid, metav1.GetOptions{})
if err != nil {
return "", fmt.Errorf("get folder %s: %w", uid, err)
}
object, err := utils.MetaAccessor(unstructuredObj)
if err != nil {
return "", fmt.Errorf("get meta accessor: %w", err)
}
return object.GetFolder(), nil
}
func (s *APIFolderStore) client(namespace string) (dynamic.ResourceInterface, error) {
client, err := dynamic.NewForConfig(s.config)
if err != nil {
return nil, err
}
return client.Resource(foldersKind.FolderResourceInfo.GroupVersionResource()).Namespace(namespace), nil
}

View File

@ -0,0 +1,163 @@
package reconcilers
import (
"context"
"fmt"
"strings"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
)
type ZanzanaPermissionStore struct {
zanzanaClient zanzana.Client
}
var _ PermissionStore = (*ZanzanaPermissionStore)(nil)
func NewZanzanaPermissionStore(zanzanaClient zanzana.Client) PermissionStore {
return &ZanzanaPermissionStore{zanzanaClient}
}
func (c *ZanzanaPermissionStore) SetFolderParent(ctx context.Context, namespace, folderUID, parentUID string) error {
err := c.DeleteFolderParents(ctx, namespace, folderUID)
if err != nil {
return err
}
user, err := toFolderTuple(parentUID)
if err != nil {
return err
}
object, err := toFolderTuple(folderUID)
if err != nil {
return err
}
if err := c.zanzanaClient.Write(ctx, &authzextv1.WriteRequest{
Namespace: namespace,
Writes: &authzextv1.WriteRequestWrites{
TupleKeys: []*authzextv1.TupleKey{{
User: user,
Relation: zanzana.RelationParent,
Object: object,
}},
},
}); err != nil {
return err
}
return nil
}
func (c *ZanzanaPermissionStore) GetFolderParents(ctx context.Context, namespace, folderUID string) ([]string, error) {
tuples, err := c.listFolderParentRelations(ctx, namespace, folderUID)
if err != nil {
return nil, err
}
parents := make([]string, 0, len(tuples))
for _, t := range tuples {
// Extract UID from format "folder:UID" or "folder:UID#relation"
userParts := strings.Split(t.Key.User, ":")
if len(userParts) == 2 {
// Remove any relation part after #
uidAndRelationParts := strings.Split(userParts[1], "#")
if len(uidAndRelationParts) > 0 {
parents = append(parents, uidAndRelationParts[0])
} else {
return nil, fmt.Errorf("invalid user format: %s, expected format: folder:UID or folder:UID#relation", t.Key.User)
}
} else {
return nil, fmt.Errorf("invalid user format: %s, expected format: folder:UID or folder:UID#relation", t.Key.User)
}
}
return parents, nil
}
func (c *ZanzanaPermissionStore) DeleteFolderParents(ctx context.Context, namespace, folderUID string) error {
tuples, err := c.listFolderParentRelations(ctx, namespace, folderUID)
if err != nil {
return err
}
if len(tuples) > 0 {
err = c.deleteTuples(ctx, namespace, tuples)
if err != nil {
return err
}
}
return nil
}
// listFolderParentRelations lists parent relations where the given folder is the object.
// It returns tuples where other folders are parents of this folder, not children.
func (c *ZanzanaPermissionStore) listFolderParentRelations(ctx context.Context, namespace, folderUID string) ([]*authzextv1.Tuple, error) {
object, err := toFolderTuple(folderUID)
if err != nil {
return nil, err
}
relation := zanzana.RelationParent
list, err := c.zanzanaClient.Read(ctx, &authzextv1.ReadRequest{
Namespace: namespace,
TupleKey: &authzextv1.ReadRequestTupleKey{
Object: object,
Relation: relation,
},
})
if err != nil {
return nil, err
}
continuationToken := list.ContinuationToken
for continuationToken != "" {
res, err := c.zanzanaClient.Read(ctx, &authzextv1.ReadRequest{
ContinuationToken: continuationToken,
Namespace: namespace,
TupleKey: &authzextv1.ReadRequestTupleKey{
Object: object,
Relation: relation,
},
})
if err != nil {
return nil, err
}
continuationToken = res.ContinuationToken
list.Tuples = append(list.Tuples, res.Tuples...)
}
return list.Tuples, nil
}
func (c *ZanzanaPermissionStore) deleteTuples(ctx context.Context, namespace string, tuples []*authzextv1.Tuple) error {
tupleKeys := make([]*authzextv1.TupleKeyWithoutCondition, 0, len(tuples))
for _, t := range tuples {
tupleKeys = append(tupleKeys, &authzextv1.TupleKeyWithoutCondition{
User: t.Key.User,
Relation: t.Key.Relation,
Object: t.Key.Object,
})
}
return c.zanzanaClient.Write(ctx, &authzextv1.WriteRequest{
Namespace: namespace,
Deletes: &authzextv1.WriteRequestDeletes{
TupleKeys: tupleKeys,
},
})
}
func toFolderTuple(UID string) (string, error) {
if strings.ContainsAny(UID, "#:") {
return "", fmt.Errorf("UID contains invalid characters: %s", UID)
}
return zanzana.NewTupleEntry(zanzana.TypeFolder, UID, ""), nil
}

View File

@ -973,15 +973,25 @@ github.com/grafana/authlib/types v0.0.0-20250120145936-5f0e28e7a87c/go.mod h1:qY
github.com/grafana/authlib/types v0.0.0-20250314102521-a77865c746c0/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4Vp68H0tp/0iN17DM2ehRo1rLEdOFe/gB8I=
github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw=
github.com/grafana/cog v0.0.38 h1:V7gRRn/mh7Bg1ptrCxo0bv6K0SnG9TiDZk+3Ppftn6s=
github.com/grafana/cog v0.0.38/go.mod h1:UDstzYqMdgIROmbfkHL8fB9XWQO2lnf5z+4W/eJo4Dc=
github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak=
github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90=
github.com/grafana/gomemcache v0.0.0-20250228145437-da7b95fd2ac1/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw=
github.com/grafana/grafana-app-sdk v0.40.1/go.mod h1:4P8h7VB6KcDjX9bAoBQc6IP8iNylxe6bSXLR9gA39gM=
github.com/grafana/grafana-app-sdk v0.41.0 h1:SYHN3U7B1myRKY3UZZDkFsue9TDmAOap0UrQVTqtYBU=
github.com/grafana/grafana-app-sdk v0.41.0/go.mod h1:Wg/3vEZfok1hhIWiHaaJm+FwkosfO98o8KbeLFEnZpY=
github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk=
github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4=
github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grafana/grafana-app-sdk/logging v0.39.1/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk=
github.com/grafana/grafana-app-sdk/logging v0.40.0/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU=
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-app-sdk/plugin v0.41.0 h1:ShUvGpAVzM3UxcsfwS6l/lwW4ytDeTbCQXf8w2P8Yp8=
github.com/grafana/grafana-app-sdk/plugin v0.41.0/go.mod h1:YIhimVfAqtOp3kdhxOanaSZjypVKh/bYxf9wfFfhDm0=
github.com/grafana/grafana-aws-sdk v0.38.2 h1:TzQD0OpWsNjtldi5G5TLDlBRk8OyDf+B5ujcoAu4Dp0=
github.com/grafana/grafana-aws-sdk v0.38.2/go.mod h1:j3vi+cXYHEFqjhBGrI6/lw1TNM+dl0Y3f0cSnDOPy+s=
github.com/grafana/grafana-aws-sdk v1.0.2 h1:98eBuHYFmgvH0xO9kKf4RBsEsgQRp8EOA/9yhDIpkss=
@ -1686,6 +1696,7 @@ golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=