Provisioning: Move repository package to provisioning app (#110228)
* Move repository package to apps
* Move operators to grafana/grafana
* Go mod tidy
* Own package by git sync team for now
* Merged
* Do not use settings in local extra
* Remove dependency on webhook extra
* Hack to work around issue with secure contracts
* Sync Go modules
* Revert "Move operators to grafana/grafana"
This reverts commit 9f19b30a2e.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// basicGitBranchNameRegex is a regular expression to validate a git branch name
|
||||
// it does not cover all cases as positive lookaheads are not supported in Go's regexp
|
||||
var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`)
|
||||
|
||||
// IsValidGitBranchName checks if a branch name is valid.
|
||||
// It uses the following regexp `^[a-zA-Z0-9\-\_\/\.]+$` to validate the branch name with some additional checks that must satisfy the following rules:
|
||||
// 1. The branch name must have at least one character and must not be empty.
|
||||
// 2. The branch name cannot start with `/` or end with `/`, `.`, or whitespace.
|
||||
// 3. The branch name cannot contain consecutive slashes (`//`).
|
||||
// 4. The branch name cannot contain consecutive dots (`..`).
|
||||
// 5. The branch name cannot contain `@{`.
|
||||
// 6. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`.
|
||||
func IsValidGitBranchName(branch string) bool {
|
||||
if !basicGitBranchNameRegex.MatchString(branch) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Additional checks for invalid patterns
|
||||
if strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") ||
|
||||
strings.HasSuffix(branch, ".") || strings.Contains(branch, "..") ||
|
||||
strings.Contains(branch, "//") || strings.HasSuffix(branch, ".lock") {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsValidGitBranchName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
branch string
|
||||
expected bool
|
||||
}{
|
||||
{"Valid branch name", "feature/add-tests", true},
|
||||
{"Valid branch name with numbers", "feature/123-add-tests", true},
|
||||
{"Valid branch name with dots", "feature.add.tests", true},
|
||||
{"Valid branch name with hyphens", "feature-add-tests", true},
|
||||
{"Valid branch name with underscores", "feature_add_tests", true},
|
||||
{"Valid branch name with mixed characters", "feature/add_tests-123", true},
|
||||
{"Starts with /", "/feature", false},
|
||||
{"Ends with /", "feature/", false},
|
||||
{"Ends with .", "feature.", false},
|
||||
{"Ends with space", "feature ", false},
|
||||
{"Contains consecutive slashes", "feature//branch", false},
|
||||
{"Contains consecutive dots", "feature..branch", false},
|
||||
{"Contains @{", "feature@{branch", false},
|
||||
{"Contains invalid character ~", "feature~branch", false},
|
||||
{"Contains invalid character ^", "feature^branch", false},
|
||||
{"Contains invalid character :", "feature:branch", false},
|
||||
{"Contains invalid character ?", "feature?branch", false},
|
||||
{"Contains invalid character *", "feature*branch", false},
|
||||
{"Contains invalid character [", "feature[branch", false},
|
||||
{"Contains invalid character ]", "feature]branch", false},
|
||||
{"Contains invalid character \\", "feature\\branch", false},
|
||||
{"Empty branch name", "", false},
|
||||
{"Only whitespace", " ", false},
|
||||
{"Single valid character", "a", true},
|
||||
{"Ends with .lock", "feature.lock", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, IsValidGitBranchName(tt.branch))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
type extra struct {
|
||||
decrypter repository.Decrypter
|
||||
}
|
||||
|
||||
func Extra(decrypter repository.Decrypter) repository.Extra {
|
||||
return &extra{
|
||||
decrypter: decrypter,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *extra) Type() provisioning.RepositoryType {
|
||||
return provisioning.GitRepositoryType
|
||||
}
|
||||
|
||||
func (e *extra) Build(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
|
||||
secure := e.decrypter(r)
|
||||
cfg := r.Spec.Git
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("git configuration is required")
|
||||
}
|
||||
|
||||
token, err := secure.Token(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to decrypt token: %w", err)
|
||||
}
|
||||
|
||||
return NewRepository(ctx, r, RepositoryConfig{
|
||||
URL: cfg.URL,
|
||||
Branch: cfg.Branch,
|
||||
Path: cfg.Path,
|
||||
TokenUser: cfg.TokenUser,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *extra) Mutate(ctx context.Context, obj runtime.Object) error {
|
||||
return Mutate(ctx, obj)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package git
|
||||
|
||||
import "github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
|
||||
// GitRepository is an interface that combines all repository capabilities
|
||||
// needed for Git repositories.
|
||||
//
|
||||
//go:generate mockery --name GitRepository --structname MockGitRepository --inpackage --filename git_repository_mock.go --with-expecter
|
||||
type GitRepository interface {
|
||||
repository.Repository
|
||||
repository.Versioned
|
||||
repository.Writer
|
||||
repository.Reader
|
||||
repository.StageableRepository
|
||||
URL() string
|
||||
Branch() string
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
func Mutate(ctx context.Context, obj runtime.Object) error {
|
||||
repo, ok := obj.(*provisioning.Repository)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if repo.Spec.Type != provisioning.GitRepositoryType {
|
||||
return nil
|
||||
}
|
||||
|
||||
if repo.Spec.Git == nil {
|
||||
return fmt.Errorf("git configuration is required for git repository type")
|
||||
}
|
||||
|
||||
if repo.Spec.Git.URL != "" {
|
||||
url := strings.TrimSpace(repo.Spec.Git.URL)
|
||||
if url != "" {
|
||||
// Remove any trailing slashes
|
||||
url = strings.TrimRight(url, "/")
|
||||
// Only add .git if it's not already present
|
||||
if !strings.HasSuffix(url, ".git") {
|
||||
url = url + ".git"
|
||||
}
|
||||
repo.Spec.Git.URL = url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
)
|
||||
|
||||
func TestMutate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
obj runtime.Object
|
||||
token string
|
||||
expectedError string
|
||||
expectedURL string
|
||||
}{
|
||||
{
|
||||
name: "no git spec",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.LocalRepositoryType,
|
||||
Git: nil,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no git spec for git repository type",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitRepositoryType,
|
||||
Git: nil,
|
||||
},
|
||||
},
|
||||
expectedError: "git configuration is required for git repository type",
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitRepositoryType,
|
||||
Git: &provisioning.GitRepositoryConfig{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-repository object",
|
||||
obj: &runtime.Unknown{},
|
||||
},
|
||||
{
|
||||
name: "URL normalization - add .git suffix",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitRepositoryType,
|
||||
Git: &provisioning.GitRepositoryConfig{
|
||||
URL: "https://github.com/grafana/grafana",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedURL: "https://github.com/grafana/grafana.git",
|
||||
},
|
||||
{
|
||||
name: "URL normalization - keep existing .git suffix",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitRepositoryType,
|
||||
Git: &provisioning.GitRepositoryConfig{
|
||||
URL: "https://github.com/grafana/grafana.git",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedURL: "https://github.com/grafana/grafana.git",
|
||||
},
|
||||
{
|
||||
name: "URL normalization - remove trailing slash and add .git",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitRepositoryType,
|
||||
Git: &provisioning.GitRepositoryConfig{
|
||||
URL: "https://github.com/grafana/grafana/",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedURL: "https://github.com/grafana/grafana.git",
|
||||
},
|
||||
{
|
||||
name: "URL normalization - trim whitespace and add .git",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitRepositoryType,
|
||||
Git: &provisioning.GitRepositoryConfig{
|
||||
URL: " https://github.com/grafana/grafana ",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedURL: "https://github.com/grafana/grafana.git",
|
||||
},
|
||||
{
|
||||
name: "URL normalization - empty URL after trim",
|
||||
obj: &provisioning.Repository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: provisioning.RepositorySpec{
|
||||
Type: provisioning.GitRepositoryType,
|
||||
Git: &provisioning.GitRepositoryConfig{
|
||||
URL: " ",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedURL: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Mutate(context.Background(), tt.obj)
|
||||
if tt.expectedError != "" {
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check that token was cleared and encrypted token was set
|
||||
if repo, ok := tt.obj.(*provisioning.Repository); ok && repo.Spec.Git != nil {
|
||||
// Check URL normalization
|
||||
if tt.expectedURL != "" {
|
||||
assert.Equal(t, tt.expectedURL, repo.Spec.Git.URL, "URL should be normalized correctly")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
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/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"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"
|
||||
)
|
||||
|
||||
type RepositoryConfig struct {
|
||||
URL string
|
||||
Branch string
|
||||
TokenUser string
|
||||
Token common.RawSecureValue
|
||||
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
|
||||
}
|
||||
|
||||
func NewRepository(
|
||||
ctx context.Context,
|
||||
config *provisioning.Repository,
|
||||
gitConfig RepositoryConfig,
|
||||
) (GitRepository, error) {
|
||||
var opts []options.Option
|
||||
if !gitConfig.Token.IsZero() {
|
||||
tokenUser := gitConfig.TokenUser
|
||||
if tokenUser == "" {
|
||||
tokenUser = "git"
|
||||
}
|
||||
|
||||
opts = append(opts, options.WithBasicAuth(tokenUser, string(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,
|
||||
}, 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"))
|
||||
}
|
||||
|
||||
// Readonly repositories may not need a token (if public)
|
||||
if len(r.config.Spec.Workflows) > 0 {
|
||||
if cfg.Token == "" && r.config.Secure.Token.IsZero() {
|
||||
list = append(list, field.Required(field.NewPath("secure", "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("secure", "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) Move(ctx context.Context, oldPath, newPath, 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.move(ctx, oldPath, newPath, 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) move(ctx context.Context, oldPath, newPath string, writer nanogit.StagedWriter) error {
|
||||
oldFinalPath := safepath.Join(r.gitConfig.Path, oldPath)
|
||||
newFinalPath := safepath.Join(r.gitConfig.Path, newPath)
|
||||
|
||||
// Check if moving directories
|
||||
if safepath.IsDir(oldPath) && safepath.IsDir(newPath) {
|
||||
// For directories, trim trailing slashes and use MoveTree
|
||||
oldTrimmed := strings.TrimSuffix(oldFinalPath, "/")
|
||||
newTrimmed := strings.TrimSuffix(newFinalPath, "/")
|
||||
|
||||
if _, err := writer.MoveTree(ctx, oldTrimmed, newTrimmed); err != nil {
|
||||
if errors.Is(err, nanogit.ErrObjectNotFound) {
|
||||
return repository.ErrFileNotFound
|
||||
}
|
||||
if errors.Is(err, nanogit.ErrObjectAlreadyExists) {
|
||||
return repository.ErrFileAlreadyExists
|
||||
}
|
||||
return fmt.Errorf("move tree: %w", err)
|
||||
}
|
||||
} else if !safepath.IsDir(oldPath) && !safepath.IsDir(newPath) {
|
||||
// For files, use MoveBlob operation
|
||||
if _, err := writer.MoveBlob(ctx, oldFinalPath, newFinalPath); err != nil {
|
||||
if errors.Is(err, nanogit.ErrObjectNotFound) {
|
||||
return repository.ErrFileNotFound
|
||||
}
|
||||
if errors.Is(err, nanogit.ErrObjectAlreadyExists) {
|
||||
return repository.ErrFileAlreadyExists
|
||||
}
|
||||
return fmt.Errorf("move blob: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Mismatched types (file to directory or vice versa)
|
||||
return apierrors.NewBadRequest("cannot move between file and directory types")
|
||||
}
|
||||
|
||||
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 {
|
||||
if errors.Is(err, nanogit.ErrNothingToCommit) {
|
||||
return repository.ErrNothingToCommit
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,240 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
"github.com/grafana/nanogit"
|
||||
)
|
||||
|
||||
// stagedGitRepository implements repository.ClonedRepository by wrapping a gitRepository
|
||||
// FIXME: this is a hack until we can delete the go-git cloned implementation
|
||||
// once that happens we could do more magic here.
|
||||
type stagedGitRepository struct {
|
||||
*gitRepository
|
||||
opts repository.StageOptions
|
||||
writer nanogit.StagedWriter
|
||||
}
|
||||
|
||||
func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repository.StageOptions) (repository.StagedRepository, error) {
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
branch := opts.Ref
|
||||
if branch == "" {
|
||||
branch = repo.gitConfig.Branch
|
||||
}
|
||||
|
||||
ref, err := repo.ensureBranchExists(ctx, branch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure branch exists: %w", err)
|
||||
}
|
||||
|
||||
writer, err := repo.client.NewStagedWriter(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build staged writer: %w", err)
|
||||
}
|
||||
|
||||
return &stagedGitRepository{
|
||||
gitRepository: repo,
|
||||
opts: opts,
|
||||
writer: writer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isRefSupported checks if the given ref is supported for staged operations.
|
||||
// It returns true if ref is empty, equals the git config branch, or equals the staged options ref.
|
||||
func (r *stagedGitRepository) isRefSupported(ref string) bool {
|
||||
if ref == "" {
|
||||
return true
|
||||
}
|
||||
if ref == r.gitConfig.Branch {
|
||||
return true
|
||||
}
|
||||
// Allow ref if it matches the staged options ref (the branch we're staging to)
|
||||
stagingBranch := r.opts.Ref
|
||||
if stagingBranch == "" {
|
||||
stagingBranch = r.gitConfig.Branch
|
||||
}
|
||||
return ref == stagingBranch
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) Read(ctx context.Context, path, ref string) (*repository.FileInfo, error) {
|
||||
if !r.isRefSupported(ref) {
|
||||
return nil, errors.New("ref is not supported for staged repository")
|
||||
}
|
||||
|
||||
// TODO: the read in the cloned is simplied used to check if a folder exists,
|
||||
// We should fix the usage and the interface so that it's not needed to load the entire blob
|
||||
return r.gitRepository.Read(ctx, path, ref)
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
|
||||
if !r.isRefSupported(ref) {
|
||||
return nil, errors.New("ref is not supported for staged repository")
|
||||
}
|
||||
|
||||
ref = ""
|
||||
// TODO: I think we don't need this for cloned repository currently.
|
||||
// we should probably remove it from the interface or construct this tree from the writer itself
|
||||
|
||||
return r.gitRepository.ReadTree(ctx, ref)
|
||||
}
|
||||
|
||||
// handleCommitAndPush handles the commit and push logic based on the StageMode and PushOnWrites flag
|
||||
func (r *stagedGitRepository) handleCommitAndPush(ctx context.Context, message string) error {
|
||||
switch r.opts.Mode {
|
||||
case repository.StageModeCommitOnEach:
|
||||
if err := r.commit(ctx, r.writer, message); err != nil {
|
||||
return err
|
||||
}
|
||||
// Only push if PushOnWrites is enabled
|
||||
if r.opts.PushOnWrites {
|
||||
return r.Push(ctx)
|
||||
}
|
||||
return nil
|
||||
case repository.StageModeCommitAndPushOnEach:
|
||||
if err := r.commit(ctx, r.writer, message); err != nil {
|
||||
return err
|
||||
}
|
||||
// Always push for this mode (explicit push-on-each mode)
|
||||
return r.Push(ctx)
|
||||
case repository.StageModeCommitOnlyOnce:
|
||||
// No immediate commit, will commit on Push
|
||||
return nil
|
||||
default:
|
||||
// Default to StageModeCommitOnEach for backward compatibility
|
||||
if err := r.commit(ctx, r.writer, message); err != nil {
|
||||
return err
|
||||
}
|
||||
// Only push if PushOnWrites is enabled
|
||||
if r.opts.PushOnWrites {
|
||||
return r.Push(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) Create(ctx context.Context, path, ref string, data []byte, message string) error {
|
||||
if !r.isRefSupported(ref) {
|
||||
return errors.New("ref is not supported for staged repository")
|
||||
}
|
||||
|
||||
if err := r.create(ctx, path, data, r.writer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.handleCommitAndPush(ctx, message)
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) blobExists(ctx context.Context, path string) (bool, error) {
|
||||
if r.gitConfig.Path != "" {
|
||||
path = safepath.Join(r.gitConfig.Path, path)
|
||||
}
|
||||
return r.writer.BlobExists(ctx, path)
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) Write(ctx context.Context, path, ref string, data []byte, message string) error {
|
||||
if !r.isRefSupported(ref) {
|
||||
return errors.New("ref is not supported for staged repository")
|
||||
}
|
||||
|
||||
exists, err := r.blobExists(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check if file exists: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
if err := r.update(ctx, path, data, r.writer); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := r.create(ctx, path, data, r.writer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return r.handleCommitAndPush(ctx, message)
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) Update(ctx context.Context, path, ref string, data []byte, message string) error {
|
||||
if !r.isRefSupported(ref) {
|
||||
return errors.New("ref is not supported for staged repository")
|
||||
}
|
||||
|
||||
if safepath.IsDir(path) {
|
||||
return errors.New("cannot update a directory in a staged repository")
|
||||
}
|
||||
|
||||
if err := r.update(ctx, path, data, r.writer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.handleCommitAndPush(ctx, message)
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) Delete(ctx context.Context, path, ref, message string) error {
|
||||
if !r.isRefSupported(ref) {
|
||||
return errors.New("ref is not supported for staged repository")
|
||||
}
|
||||
|
||||
if err := r.delete(ctx, path, r.writer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.handleCommitAndPush(ctx, message)
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) Move(ctx context.Context, oldPath, newPath, ref, message string) error {
|
||||
if !r.isRefSupported(ref) {
|
||||
return errors.New("ref is not supported for staged repository")
|
||||
}
|
||||
|
||||
if err := r.move(ctx, oldPath, newPath, r.writer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.handleCommitAndPush(ctx, message)
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) Push(ctx context.Context) error {
|
||||
if r.opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, r.opts.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if r.opts.Mode == repository.StageModeCommitOnlyOnce {
|
||||
message := r.opts.CommitOnlyOnceMessage
|
||||
if message == "" {
|
||||
message = "Staged changes"
|
||||
}
|
||||
|
||||
if err := r.commit(ctx, r.writer, message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := r.writer.Push(ctx)
|
||||
if err != nil {
|
||||
// Convert nanogit-specific errors to repository-level errors to avoid leaky abstraction
|
||||
if errors.Is(err, nanogit.ErrNothingToPush) {
|
||||
return repository.ErrNothingToPush
|
||||
}
|
||||
if errors.Is(err, nanogit.ErrNothingToCommit) {
|
||||
return repository.ErrNothingToCommit
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stagedGitRepository) Remove(ctx context.Context) error {
|
||||
return r.writer.Cleanup(ctx)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user