Implement a basic operator to reconcile the folder hierarchy from Unistore to Zanzana (#109705)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user