Files
grafana/pkg/registry/apis/provisioning/repository/go-git/wrapper.go
T
Roberto Jiménez Sánchez 4f05b42275 Provisioning: initial unit tests push job (#103577)
* Add repository resources interface for export worker

* Add mocks for repository resources

* Add unit tests for ExportWorker's IsSupported method

* Add unit tests for ExportWorker's Process method, covering scenarios for missing export settings, write permissions, branch restrictions, and client creation failures.

* Fix unit tests

* Single function

* Add more unit tests

* Add test for failed folder

* Fail export folder errors

* Add another test

* Positive folder export

* Too many folder export errors

* Too many errors on folder export

* Partial folder errors

* Add test for nested folder

* Add test dashboard export

* More cases

* Ignore existing dashboards

* Fix folder tests

* Fix clonable test

* Add clone failure test

* Add test clean up without push

* Working tests

* Use mock clonable

* Add unit tests for IsWriteAllowed

* Add behaviour to cover ref equal to configured branch

* Fix worker test

* Fix linting

* Split clone and push

* Wrapper for clone and push
2025-04-08 12:44:11 +02:00

430 lines
11 KiB
Go

package gogit
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"strings"
"time"
"github.com/go-git/go-billy/v5/util"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/client"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
)
const (
// maxOperationBytes is the maximum size of a git operation in bytes (1 GB)
maxOperationBytes = int64(1 << 30)
maxOperationTimeout = 10 * time.Minute
)
func init() {
// Create a size-limited writer that will cancel the context if size is exceeded
limitedTransport := NewByteLimitedTransport(http.DefaultTransport, maxOperationBytes)
httpClient := githttp.NewClient(&http.Client{
Transport: limitedTransport,
})
client.InstallProtocol("https", httpClient)
client.InstallProtocol("http", httpClient)
}
var _ repository.Repository = (*GoGitRepo)(nil)
type GoGitRepo struct {
config *provisioning.Repository
decryptedPassword string
opts repository.CloneOptions
repo *git.Repository
tree *git.Worktree
dir string // file path to worktree root (necessary? should use billy)
}
// This will create a new clone every time
// As structured, it is valid for one context and should not be shared across multiple requests
func Clone(
ctx context.Context,
root string,
config *provisioning.Repository,
opts repository.CloneOptions,
secrets secrets.Service,
) (repository.ClonedRepository, error) {
if root == "" {
return nil, fmt.Errorf("missing root config")
}
if opts.BeforeFn != nil {
if err := opts.BeforeFn(); err != nil {
return nil, err
}
}
// add a timeout to the operation
timeout := maxOperationTimeout
if opts.Timeout > 0 {
timeout = opts.Timeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
decrypted, err := secrets.Decrypt(ctx, config.Spec.GitHub.EncryptedToken)
if err != nil {
return nil, fmt.Errorf("error decrypting token: %w", err)
}
if err := os.MkdirAll(root, 0700); err != nil {
return nil, fmt.Errorf("create root dir: %w", err)
}
dir, err := mkdirTempClone(root, config)
if err != nil {
return nil, fmt.Errorf("create temp clone dir: %w", err)
}
progress := opts.Progress
if progress == nil {
progress = io.Discard
}
repo, worktree, err := clone(ctx, config, opts, decrypted, dir, progress)
if err != nil {
if err := os.RemoveAll(dir); err != nil {
return nil, fmt.Errorf("remove temp clone dir after clone failed: %w", err)
}
return nil, fmt.Errorf("clone: %w", err)
}
return &GoGitRepo{
config: config,
tree: worktree,
opts: opts,
decryptedPassword: string(decrypted),
repo: repo,
dir: dir,
}, nil
}
func clone(ctx context.Context, config *provisioning.Repository, opts repository.CloneOptions, decrypted []byte, dir string, progress io.Writer) (*git.Repository, *git.Worktree, error) {
gitcfg := config.Spec.GitHub
url := fmt.Sprintf("%s.git", gitcfg.URL)
branch := plumbing.NewBranchReferenceName(gitcfg.Branch)
cloneOpts := &git.CloneOptions{
ReferenceName: branch,
Auth: &githttp.BasicAuth{
Username: "grafana", // this can be anything except an empty string for PAT
Password: string(decrypted), // TODO... will need to get from a service!
},
URL: url,
Progress: progress,
}
repo, err := git.PlainCloneContext(ctx, dir, false, cloneOpts)
if errors.Is(err, plumbing.ErrReferenceNotFound) && opts.CreateIfNotExists {
cloneOpts.ReferenceName = "" // empty
repo, err = git.PlainCloneContext(ctx, dir, false, cloneOpts)
if err == nil {
worktree, err := repo.Worktree()
if err != nil {
return nil, nil, err
}
err = worktree.Checkout(&git.CheckoutOptions{
Branch: branch,
Force: true,
Create: true,
})
if err != nil {
return nil, nil, fmt.Errorf("unable to create new branch: %w", err)
}
}
} else if err != nil {
return nil, nil, fmt.Errorf("clone error: %w", err)
}
rcfg, err := repo.Config()
if err != nil {
return nil, nil, fmt.Errorf("error reading repository config %w", err)
}
origin := rcfg.Remotes["origin"]
if origin == nil {
return nil, nil, fmt.Errorf("missing origin remote %w", err)
}
if url != origin.URLs[0] {
return nil, nil, fmt.Errorf("unexpected remote (expected: %s, found: %s)", url, origin.URLs[0])
}
worktree, err := repo.Worktree()
if err != nil {
return nil, nil, fmt.Errorf("get worktree: %w", err)
}
return repo, worktree, nil
}
func mkdirTempClone(root string, config *provisioning.Repository) (string, error) {
if config.Namespace == "" {
return "", fmt.Errorf("config is missing namespace")
}
if config.Name == "" {
return "", fmt.Errorf("config is missing name")
}
return os.MkdirTemp(root, fmt.Sprintf("clone-%s-%s-", config.Namespace, config.Name))
}
// After making changes to the worktree, push changes
func (g *GoGitRepo) Push(ctx context.Context, opts repository.PushOptions) error {
timeout := maxOperationTimeout
if opts.Timeout > 0 {
timeout = opts.Timeout
}
progress := opts.Progress
if progress == nil {
progress = io.Discard
}
if opts.BeforeFn != nil {
if err := opts.BeforeFn(); err != nil {
return err
}
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if !g.opts.PushOnWrites {
_, err := g.tree.Commit("exported from grafana", &git.CommitOptions{
All: true, // Add everything that changed
})
if err != nil {
// empty commit is fine -- no change
if !errors.Is(err, git.ErrEmptyCommit) {
return err
}
}
}
err := g.repo.PushContext(ctx, &git.PushOptions{
Progress: progress,
Force: true, // avoid fast-forward-errors
Auth: &githttp.BasicAuth{ // reuse logic from clone?
Username: "grafana",
Password: g.decryptedPassword,
},
})
if errors.Is(err, git.NoErrAlreadyUpToDate) {
return nil // same as the target
}
return err
}
func (g *GoGitRepo) Remove(ctx context.Context) error {
return os.RemoveAll(g.dir)
}
// Config implements repository.Repository.
func (g *GoGitRepo) Config() *provisioning.Repository {
return g.config
}
// ReadTree implements repository.Repository.
func (g *GoGitRepo) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
var treePath string
if g.config.Spec.GitHub.Path != "" {
treePath = g.config.Spec.GitHub.Path
}
treePath = safepath.Clean(treePath)
entries := make([]repository.FileTreeEntry, 0, 100)
err := util.Walk(g.tree.Filesystem, treePath, func(path string, info fs.FileInfo, err error) error {
// We already have an error, just pass it onwards.
if err != nil ||
// This is the root of the repository (or should pretend to be)
safepath.Clean(path) == "" || path == treePath ||
// This is the Git data
(treePath == "" && strings.HasPrefix(path, ".git/")) {
return err
}
if treePath != "" {
path = strings.TrimPrefix(path, treePath)
}
entry := repository.FileTreeEntry{
Path: strings.TrimLeft(path, "/"),
Size: info.Size(),
}
if !info.IsDir() {
entry.Blob = true
// For a real instance, this will likely be based on:
// https://github.com/go-git/go-git/blob/main/_examples/ls/main.go#L25
entry.Hash = fmt.Sprintf("TODO/%d", info.Size()) // but not used for
}
entries = append(entries, entry)
return err
})
if errors.Is(err, fs.ErrNotExist) {
// We intentionally ignore this case, as
} else if err != nil {
return nil, fmt.Errorf("failed to walk tree for ref '%s': %w", ref, err)
}
return entries, nil
}
func (g *GoGitRepo) Test(ctx context.Context) (*provisioning.TestResults, error) {
return &provisioning.TestResults{
Success: g.tree != nil,
}, nil
}
// Update implements repository.Repository.
func (g *GoGitRepo) Update(ctx context.Context, path string, ref string, data []byte, message string) error {
return g.Write(ctx, path, ref, data, message)
}
// Create implements repository.Repository.
func (g *GoGitRepo) Create(ctx context.Context, path string, ref string, data []byte, message string) error {
return g.Write(ctx, path, ref, data, message)
}
// Write implements repository.Repository.
func (g *GoGitRepo) Write(ctx context.Context, fpath string, ref string, data []byte, message string) error {
fpath = safepath.Join(g.config.Spec.GitHub.Path, fpath)
if err := verifyPathWithoutRef(fpath, ref); err != nil {
return err
}
// For folders, just create the folder and ignore the commit
if safepath.IsDir(fpath) {
return g.tree.Filesystem.MkdirAll(fpath, 0750)
}
dir := safepath.Dir(fpath)
if dir != "" {
err := g.tree.Filesystem.MkdirAll(dir, 0750)
if err != nil {
return err
}
}
file, err := g.tree.Filesystem.Create(fpath)
if err != nil {
return err
}
_, err = file.Write(data)
if err != nil {
return err
}
_, err = g.tree.Add(fpath)
if err != nil {
return err
}
// Skip commit for each file
if !g.opts.PushOnWrites {
return nil
}
opts := &git.CommitOptions{}
sig := repository.GetAuthorSignature(ctx)
if sig != nil {
opts.Author = &object.Signature{
Name: sig.Name,
Email: sig.Email,
When: sig.When,
}
}
_, err = g.tree.Commit(message, opts)
if errors.Is(err, git.ErrEmptyCommit) {
return nil // empty commit is fine -- no change
}
return err
}
// Delete implements repository.Repository.
func (g *GoGitRepo) Delete(ctx context.Context, path string, ref string, message string) error {
if _, err := g.tree.Remove(safepath.Join(g.config.Spec.GitHub.Path, path)); err != nil {
return err
}
return nil
}
// Read implements repository.Repository.
func (g *GoGitRepo) Read(ctx context.Context, path string, ref string) (*repository.FileInfo, error) {
readPath := safepath.Join(g.config.Spec.GitHub.Path, path)
stat, err := g.tree.Filesystem.Lstat(readPath)
if err != nil {
return nil, fmt.Errorf("failed to stat path '%s': %w", readPath, err)
}
info := &repository.FileInfo{
Path: path,
Modified: &metav1.Time{
Time: stat.ModTime(),
},
}
if !stat.IsDir() {
f, err := g.tree.Filesystem.Open(readPath)
if err != nil {
return nil, err
}
info.Data, err = io.ReadAll(f)
if err != nil {
return nil, err
}
}
return info, err
}
func verifyPathWithoutRef(path string, ref string) error {
if path == "" {
return fmt.Errorf("expected path")
}
if ref != "" {
return fmt.Errorf("ref unsupported")
}
return nil
}
// History implements repository.Repository.
func (g *GoGitRepo) History(ctx context.Context, path string, ref string) ([]provisioning.HistoryItem, error) {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: "history is not yet implemented",
Code: http.StatusNotImplemented,
},
}
}
// Validate implements repository.Repository.
func (g *GoGitRepo) Validate() field.ErrorList {
return nil
}
// Webhook implements repository.Repository.
func (g *GoGitRepo) Webhook(ctx context.Context, req *http.Request) (*provisioning.WebhookResponse, error) {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: "history is not yet implemented",
Code: http.StatusNotImplemented,
},
}
}