The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/pkg/registry/apis/provisioning/repository/git/repository.go

788 lines
21 KiB

package git
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/validation/field"
"github.com/grafana/grafana-app-sdk/logging"
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"
"github.com/grafana/nanogit"
"github.com/grafana/nanogit/log"
"github.com/grafana/nanogit/options"
"github.com/grafana/nanogit/protocol"
"github.com/grafana/nanogit/protocol/hash"
)
//nolint:gosec // This is a constant for a secret suffix
const gitTokenSecretSuffix = "-git-token"
type RepositoryConfig struct {
URL string
Branch string
TokenUser string
Token string
EncryptedToken []byte
Path string
}
// Make sure all public functions of this struct call the (*gitRepository).logger function, to ensure the Git repo details are included.
type gitRepository struct {
config *provisioning.Repository
gitConfig RepositoryConfig
client nanogit.Client
secrets secrets.RepositorySecrets
}
func NewGitRepository(
ctx context.Context,
config *provisioning.Repository,
gitConfig RepositoryConfig,
secrets secrets.RepositorySecrets,
) (GitRepository, error) {
var opts []options.Option
if len(gitConfig.Token) > 0 {
tokenUser := gitConfig.TokenUser
if tokenUser == "" {
tokenUser = "git"
}
opts = append(opts, options.WithBasicAuth(tokenUser, gitConfig.Token))
}
client, err := nanogit.NewHTTPClient(gitConfig.URL, opts...)
if err != nil {
return nil, fmt.Errorf("create nanogit client: %w", err)
}
return &gitRepository{
config: config,
gitConfig: gitConfig,
client: client,
secrets: secrets,
}, nil
}
func (r *gitRepository) URL() string {
return r.gitConfig.URL
}
func (r *gitRepository) Branch() string {
return r.gitConfig.Branch
}
func (r *gitRepository) Config() *provisioning.Repository {
return r.config
}
// Validate implements provisioning.Repository.
func (r *gitRepository) Validate() (list field.ErrorList) {
cfg := r.gitConfig
t := string(r.config.Spec.Type)
if cfg.URL == "" {
list = append(list, field.Required(field.NewPath("spec", t, "url"), "a git url is required"))
} else {
if !isValidGitURL(cfg.URL) {
list = append(list, field.Invalid(field.NewPath("spec", t, "url"), cfg.URL, "invalid git URL format"))
}
}
if cfg.Branch == "" {
list = append(list, field.Required(field.NewPath("spec", t, "branch"), "a git branch is required"))
} else if !IsValidGitBranchName(cfg.Branch) {
list = append(list, field.Invalid(field.NewPath("spec", t, "branch"), cfg.Branch, "invalid branch name"))
}
// If the repository has workflows, we require a token or encrypted token
if len(r.config.Spec.Workflows) > 0 {
if cfg.Token == "" && len(cfg.EncryptedToken) == 0 {
list = append(list, field.Required(field.NewPath("spec", t, "token"), "a git access token is required"))
}
}
if err := safepath.IsSafe(cfg.Path); err != nil {
list = append(list, field.Invalid(field.NewPath("spec", t, "path"), cfg.Path, err.Error()))
}
if safepath.IsAbs(cfg.Path) {
list = append(list, field.Invalid(field.NewPath("spec", t, "path"), cfg.Path, "path must be relative"))
}
return list
}
func isValidGitURL(gitURL string) bool {
// Parse URL
parsed, err := url.Parse(gitURL)
if err != nil {
return false
}
// Must be HTTPS
if parsed.Scheme != "https" {
return false
}
// Must have a host
if parsed.Host == "" {
return false
}
// Must have a path
if parsed.Path == "" || parsed.Path == "/" {
return false
}
return true
}
// Test implements provisioning.Repository.
func (r *gitRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
ctx, _ = r.logger(ctx, "")
t := string(r.config.Spec.Type)
if ok, err := r.client.IsAuthorized(ctx); err != nil || !ok {
detail := "not authorized"
if err != nil {
detail = fmt.Sprintf("failed check if authorized: %v", err)
}
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", t, "token").String(),
Detail: detail,
}},
}, nil
}
if ok, err := r.client.RepoExists(ctx); err != nil || !ok {
detail := "repository not found"
if err != nil {
detail = fmt.Sprintf("failed check if repository exists: %v", err)
}
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", t, "url").String(),
Detail: detail,
}},
}, nil
}
// Test basic connectivity by getting the branch reference
_, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", r.gitConfig.Branch))
if err != nil {
detail := "branch not found"
if errors.Is(err, nanogit.ErrObjectNotFound) {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", t, "branch").String(),
Detail: detail,
}},
}, nil
}
detail = fmt.Sprintf("failed to check if branch exists: %v", err)
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []provisioning.ErrorDetails{{
Type: metav1.CauseTypeFieldValueInvalid,
Field: field.NewPath("spec", t, "branch").String(),
Detail: detail,
}},
}, nil
}
return &provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil
}
// Read implements provisioning.Repository.
func (r *gitRepository) Read(ctx context.Context, filePath, ref string) (*repository.FileInfo, error) {
ctx, _ = r.logger(ctx, ref)
finalPath := safepath.Join(r.gitConfig.Path, filePath)
// Resolve ref to commit hash
refHash, err := r.resolveRefToHash(ctx, ref)
if err != nil {
return nil, err
}
// get root hash
// TODO: Fix GetTree in nanogit as it does not work commit hash
commit, err := r.client.GetCommit(ctx, refHash)
if err != nil {
return nil, fmt.Errorf("get commit: %w", err)
}
// Check if the path represents a directory
if safepath.IsDir(filePath) {
tree, err := r.client.GetTreeByPath(ctx, commit.Tree, finalPath)
if err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return nil, repository.ErrFileNotFound
}
return nil, fmt.Errorf("get tree by path: %w", err)
}
return &repository.FileInfo{
Path: filePath,
Ref: refHash.String(),
Hash: tree.Hash.String(),
}, nil
}
blob, err := r.client.GetBlobByPath(ctx, commit.Tree, finalPath)
if err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return nil, repository.ErrFileNotFound
}
return nil, fmt.Errorf("read blob: %w", err)
}
return &repository.FileInfo{
Path: filePath,
Ref: ref,
Data: blob.Content,
Hash: blob.Hash.String(),
}, nil
}
func (r *gitRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
ctx, _ = r.logger(ctx, ref)
// Resolve ref to commit hash
refHash, err := r.resolveRefToHash(ctx, ref)
if err != nil {
return nil, err
}
// Get flat tree using nanogit's GetFlatTree
tree, err := r.client.GetFlatTree(ctx, refHash)
if err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return nil, repository.ErrRefNotFound
}
return nil, fmt.Errorf("get flat tree: %w", err)
}
entries := make([]repository.FileTreeEntry, 0, len(tree.Entries))
for _, entry := range tree.Entries {
isBlob := entry.Type == protocol.ObjectTypeBlob
// Apply path prefix filtering
relativePath, err := safepath.RelativeTo(entry.Path, r.gitConfig.Path)
if err != nil {
// File is outside configured path, skip it
continue
}
filePath := relativePath
if !isBlob && !safepath.IsDir(filePath) {
filePath = filePath + "/"
}
converted := repository.FileTreeEntry{
Path: filePath,
// TODO: Remove size from repository.FileTreeEntry. We don't need it per se.
Size: 0, // FlatTreeEntry doesn't have size, set to 0
Hash: entry.Hash.String(),
Blob: isBlob,
}
entries = append(entries, converted)
}
return entries, nil
}
func (r *gitRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error {
if ref == "" {
ref = r.gitConfig.Branch
}
ctx, _ = r.logger(ctx, ref)
branchRef, err := r.ensureBranchExists(ctx, ref)
if err != nil {
return err
}
writer, err := r.client.NewStagedWriter(ctx, branchRef)
if err != nil {
return fmt.Errorf("create staged writer: %w", err)
}
if err := r.create(ctx, path, data, writer); err != nil {
return err
}
return r.commitAndPush(ctx, writer, comment)
}
func (r *gitRepository) create(ctx context.Context, path string, data []byte, writer nanogit.StagedWriter) error {
finalPath := safepath.Join(r.gitConfig.Path, path)
// Create .keep file if it is a directory
if safepath.IsDir(finalPath) {
if data != nil {
return apierrors.NewBadRequest("data cannot be provided for a directory")
}
finalPath = safepath.Join(finalPath, ".keep")
data = []byte{}
}
if _, err := writer.CreateBlob(ctx, finalPath, data); err != nil {
if errors.Is(err, nanogit.ErrObjectAlreadyExists) {
return repository.ErrFileAlreadyExists
}
return fmt.Errorf("create blob: %w", err)
}
return nil
}
func (r *gitRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error {
if ref == "" {
ref = r.gitConfig.Branch
}
ctx, _ = r.logger(ctx, ref)
// Check if trying to update a directory
if safepath.IsDir(path) {
return apierrors.NewBadRequest("cannot update a directory")
}
branchRef, err := r.ensureBranchExists(ctx, ref)
if err != nil {
return err
}
// Create a staged writer
writer, err := r.client.NewStagedWriter(ctx, branchRef)
if err != nil {
return fmt.Errorf("create staged writer: %w", err)
}
if err := r.update(ctx, path, data, writer); err != nil {
return err
}
return r.commitAndPush(ctx, writer, comment)
}
func (r *gitRepository) update(ctx context.Context, path string, data []byte, writer nanogit.StagedWriter) error {
// Check if trying to update a directory
if safepath.IsDir(path) {
return apierrors.NewBadRequest("cannot update a directory")
}
finalPath := safepath.Join(r.gitConfig.Path, path)
if _, err := writer.UpdateBlob(ctx, finalPath, data); err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return repository.ErrFileNotFound
}
return fmt.Errorf("update blob: %w", err)
}
return nil
}
func (r *gitRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error {
if ref == "" {
ref = r.gitConfig.Branch
}
ctx, _ = r.logger(ctx, ref)
info, err := r.Read(ctx, path, ref)
if err != nil && !(errors.Is(err, repository.ErrFileNotFound)) {
return fmt.Errorf("check if file exists before writing: %w", err)
}
if err == nil {
// If the value already exists and is the same, we don't need to do anything
if bytes.Equal(info.Data, data) {
return nil
}
return r.Update(ctx, path, ref, data, message)
}
return r.Create(ctx, path, ref, data, message)
}
func (r *gitRepository) Delete(ctx context.Context, path, ref, comment string) error {
if ref == "" {
ref = r.gitConfig.Branch
}
ctx, _ = r.logger(ctx, ref)
branchRef, err := r.ensureBranchExists(ctx, ref)
if err != nil {
return err
}
// Create a staged writer
writer, err := r.client.NewStagedWriter(ctx, branchRef)
if err != nil {
return fmt.Errorf("create staged writer: %w", err)
}
if err := r.delete(ctx, path, writer); err != nil {
return err
}
return r.commitAndPush(ctx, writer, comment)
}
func (r *gitRepository) delete(ctx context.Context, path string, writer nanogit.StagedWriter) error {
finalPath := safepath.Join(r.gitConfig.Path, path)
// Check if it's a directory - use DeleteTree for directories, DeleteBlob for files
if safepath.IsDir(path) {
trimmed := strings.TrimSuffix(finalPath, "/")
if _, err := writer.DeleteTree(ctx, trimmed); err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return repository.ErrFileNotFound
}
return fmt.Errorf("delete tree: %w", err)
}
} else {
if _, err := writer.DeleteBlob(ctx, finalPath); err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return repository.ErrFileNotFound
}
return fmt.Errorf("delete blob: %w", err)
}
}
return nil
}
func (r *gitRepository) History(_ context.Context, _ string, _ string) ([]provisioning.HistoryItem, error) {
return nil, &apierrors.StatusError{ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusNotImplemented,
Reason: metav1.StatusReasonMethodNotAllowed,
Message: "history is not supported for pure git repositories",
}}
}
func (r *gitRepository) ListRefs(ctx context.Context) ([]provisioning.RefItem, error) {
refs, err := r.client.ListRefs(ctx)
if err != nil {
return nil, fmt.Errorf("list refs: %w", err)
}
refItems := make([]provisioning.RefItem, 0, len(refs))
for _, ref := range refs {
// Only branches
if !strings.HasPrefix(ref.Name, "refs/heads/") {
continue
}
refItems = append(refItems, provisioning.RefItem{
Name: strings.TrimPrefix(ref.Name, "refs/heads/"),
Hash: ref.Hash.String(),
})
}
return refItems, nil
}
func (r *gitRepository) LatestRef(ctx context.Context) (string, error) {
ctx, _ = r.logger(ctx, "")
branchRef, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", r.gitConfig.Branch))
if err != nil {
return "", fmt.Errorf("get branch ref: %w", err)
}
return branchRef.Hash.String(), nil
}
func (r *gitRepository) CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error) {
if base == "" && ref == "" {
return nil, fmt.Errorf("base and ref cannot be empty")
}
if ref == "" {
return nil, fmt.Errorf("ref cannot be empty")
}
ctx, logger := r.logger(ctx, ref)
// Resolve base ref to hash
var baseHash hash.Hash
if base != "" {
var err error
baseHash, err = r.resolveRefToHash(ctx, base)
if err != nil {
return nil, fmt.Errorf("resolve base ref: %w", err)
}
}
// Resolve ref to hash
refHash, err := r.resolveRefToHash(ctx, ref)
if err != nil {
return nil, fmt.Errorf("resolve ref: %w", err)
}
// Get commit hashes for base and ref
// Compare commits using nanogit
files, err := r.client.CompareCommits(ctx, baseHash, refHash)
if err != nil {
return nil, fmt.Errorf("compare commits: %w", err)
}
changes := make([]repository.VersionedFileChange, 0)
for _, f := range files {
switch f.Status {
case protocol.FileStatusAdded:
currentPath, err := safepath.RelativeTo(f.Path, r.gitConfig.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, repository.VersionedFileChange{
Path: currentPath,
Ref: ref,
Action: repository.FileActionCreated,
})
case protocol.FileStatusModified:
currentPath, err := safepath.RelativeTo(f.Path, r.gitConfig.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, repository.VersionedFileChange{
Path: currentPath,
Ref: ref,
Action: repository.FileActionUpdated,
})
case protocol.FileStatusDeleted:
currentPath, err := safepath.RelativeTo(f.Path, r.gitConfig.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, repository.VersionedFileChange{
Ref: ref,
PreviousRef: base,
Path: currentPath,
PreviousPath: currentPath,
Action: repository.FileActionDeleted,
})
case protocol.FileStatusTypeChanged:
// Handle type changes as modifications
currentPath, err := safepath.RelativeTo(f.Path, r.gitConfig.Path)
if err != nil {
// do nothing as it's outside of configured path
continue
}
changes = append(changes, repository.VersionedFileChange{
Path: currentPath,
Ref: ref,
Action: repository.FileActionUpdated,
})
default:
logger.Error("ignore unhandled file", "file", f.Path, "status", string(f.Status))
}
}
return changes, nil
}
func (r *gitRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) {
return NewStagedGitRepository(ctx, r, opts)
}
// resolveRefToHash resolves a ref (branch name or commit hash) to a commit hash
func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.Hash, error) {
// Use default branch if ref is empty
if ref == "" {
ref = r.gitConfig.Branch
}
// Try to parse ref as a hash first
refHash, err := hash.FromHex(ref)
if err == nil && refHash != hash.Zero {
// Valid hash, return it
return refHash, nil
}
// Prefix ref with refs/heads/
ref = fmt.Sprintf("refs/heads/%s", ref)
// Not a valid hash, try to resolve as a branch reference
branchRef, err := r.client.GetRef(ctx, ref)
if err != nil {
if errors.Is(err, nanogit.ErrObjectNotFound) {
return hash.Zero, fmt.Errorf("ref not found: %s: %w", ref, repository.ErrRefNotFound)
}
return hash.Zero, fmt.Errorf("get ref %s: %w", ref, err)
}
return branchRef.Hash, nil
}
// ensureBranchExists checks if a branch exists and creates it if it doesn't,
// returning the branch reference to avoid duplicate GetRef calls
func (r *gitRepository) ensureBranchExists(ctx context.Context, branchName string) (nanogit.Ref, error) {
if !IsValidGitBranchName(branchName) {
return nanogit.Ref{}, &apierrors.StatusError{
ErrStatus: metav1.Status{
Code: http.StatusBadRequest,
Message: "invalid branch name",
},
}
}
// Check if branch exists by trying to get the branch reference
branchRef, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", branchName))
if err == nil {
// Branch exists, return it
logging.FromContext(ctx).Info("branch already exists", "branch", branchName)
return branchRef, nil
}
// If error is not "ref not found", return the error
if !errors.Is(err, nanogit.ErrObjectNotFound) {
return nanogit.Ref{}, fmt.Errorf("check branch exists: %w", err)
}
// Branch doesn't exist, create it based on the configured branch
srcBranch := r.gitConfig.Branch
srcRef, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", srcBranch))
if err != nil {
return nanogit.Ref{}, fmt.Errorf("get source branch ref: %w", err)
}
// Create the new branch reference
newRef := nanogit.Ref{
Name: fmt.Sprintf("refs/heads/%s", branchName),
Hash: srcRef.Hash,
}
if err := r.client.CreateRef(ctx, newRef); err != nil {
return nanogit.Ref{}, fmt.Errorf("create branch: %w", err)
}
return newRef, nil
}
// createSignature creates author and committer signatures using the context signature if available,
// falling back to default Grafana signature
func (r *gitRepository) createSignature(ctx context.Context) (nanogit.Author, nanogit.Committer) {
author := nanogit.Author{
Name: "Grafana",
Email: "noreply@grafana.com",
Time: time.Now(),
}
committer := nanogit.Committer{
Name: "Grafana",
Email: "noreply@grafana.com",
Time: time.Now(),
}
// Use signature from context if available
if sig := repository.GetAuthorSignature(ctx); sig != nil && sig.Name != "" {
author.Name = sig.Name
author.Email = sig.Email
author.Time = sig.When
committer.Name = sig.Name
committer.Email = sig.Email
committer.Time = sig.When
}
if author.Time.IsZero() {
author.Time = time.Now()
committer.Time = time.Now()
}
return author, committer
}
func (r *gitRepository) commit(ctx context.Context, writer nanogit.StagedWriter, comment string) error {
author, committer := r.createSignature(ctx)
if _, err := writer.Commit(ctx, comment, author, committer); err != nil {
return fmt.Errorf("commit changes: %w", err)
}
return nil
}
func (r *gitRepository) commitAndPush(ctx context.Context, writer nanogit.StagedWriter, comment string) error {
if err := r.commit(ctx, writer, comment); err != nil {
return err
}
if err := writer.Push(ctx); err != nil {
return fmt.Errorf("push changes: %w", err)
}
return nil
}
func (r *gitRepository) logger(ctx context.Context, ref string) (context.Context, logging.Logger) {
logger := logging.FromContext(ctx)
type containsGit int
var containsGitKey containsGit
if ctx.Value(containsGitKey) != nil {
return ctx, logging.FromContext(ctx)
}
if ref == "" {
ref = r.gitConfig.Branch
}
logger = logger.With(slog.Group("git_repository", "url", r.gitConfig.URL, "ref", ref, "nanogit", true))
ctx = logging.Context(ctx, logger)
// We want to ensure we don't add multiple git_repository keys. With doesn't deduplicate the keys...
ctx = context.WithValue(ctx, containsGitKey, true)
ctx = log.ToContext(ctx, logger)
return ctx, logger
}
func (r *gitRepository) OnCreate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *gitRepository) OnUpdate(_ context.Context) ([]map[string]interface{}, error) {
return nil, nil
}
func (r *gitRepository) OnDelete(ctx context.Context) error {
logger := logging.FromContext(ctx)
secretName := r.config.Name + gitTokenSecretSuffix
if err := r.secrets.Delete(ctx, r.config, secretName); err != nil {
return fmt.Errorf("delete git token secret: %w", err)
}
logger.Info("Deleted git token secret", "secretName", secretName)
return nil
}