Files
grafana/apps/provisioning/pkg/repository/local/local.go
T
Roberto Jiménez Sánchez 4eadc823a9 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.
2025-09-02 09:45:44 +02:00

436 lines
12 KiB
Go

package local
import (
"context"
"path"
// Git still uses sha1 for the most part: https://git-scm.com/docs/hash-function-transition
//nolint:gosec
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation/field"
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"
)
type LocalFolderResolver struct {
PermittedPrefixes []string
HomePath string
}
type InvalidLocalFolderError struct {
Path string
AdditionalInfo string
}
var (
_ error = (*InvalidLocalFolderError)(nil)
_ apierrors.APIStatus = (*InvalidLocalFolderError)(nil)
)
func (e *InvalidLocalFolderError) Error() string {
return fmt.Sprintf("the path given ('%s') is invalid for a local repository (%s)", e.Path, e.AdditionalInfo)
}
func (e *InvalidLocalFolderError) Status() metav1.Status {
return metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusBadRequest,
Reason: metav1.StatusReasonBadRequest,
Message: e.Error(),
}
}
func (r *LocalFolderResolver) LocalPath(p string) (string, error) {
if len(r.PermittedPrefixes) == 0 {
return "", &InvalidLocalFolderError{p, "no permitted prefixes were configured"}
}
originalPath := p
if !path.IsAbs(p) {
p = safepath.Join(r.HomePath, p)
} else {
p = safepath.Clean(p)
}
for _, permitted := range r.PermittedPrefixes {
if safepath.InDir(p, permitted) {
return p, nil
}
}
return "", &InvalidLocalFolderError{originalPath, "the path matches no permitted prefix"}
}
var (
_ repository.Repository = (*localRepository)(nil)
_ repository.Writer = (*localRepository)(nil)
_ repository.Reader = (*localRepository)(nil)
)
type localRepository struct {
config *provisioning.Repository
resolver *LocalFolderResolver
// validated path that can be read if not empty
path string
}
func NewRepository(config *provisioning.Repository, resolver *LocalFolderResolver) *localRepository {
r := &localRepository{
config: config,
resolver: resolver,
}
if config.Spec.Local != nil {
r.path, _ = resolver.LocalPath(config.Spec.Local.Path)
if r.path != "" && !safepath.IsDir(r.path) {
r.path += "/"
}
for i, permitted := range r.resolver.PermittedPrefixes {
r.resolver.PermittedPrefixes[i] = safepath.Clean(permitted)
}
}
return r
}
func (r *localRepository) Config() *provisioning.Repository {
return r.config
}
// Validate implements provisioning.Repository.
func (r *localRepository) Validate() field.ErrorList {
cfg := r.config.Spec.Local
if cfg == nil {
return field.ErrorList{&field.Error{
Type: field.ErrorTypeRequired,
Field: "spec.local",
}}
}
// The path value must be set for local provisioning
if cfg.Path == "" {
return field.ErrorList{field.Required(field.NewPath("spec", "local", "path"),
"must enter a path to local file")}
}
if err := safepath.IsSafe(cfg.Path); err != nil {
return field.ErrorList{field.Invalid(field.NewPath("spec", "local", "path"),
cfg.Path, err.Error())}
}
// Check if it is valid
_, err := r.resolver.LocalPath(cfg.Path)
if err != nil {
return field.ErrorList{field.Invalid(field.NewPath("spec", "local", "path"),
cfg.Path, err.Error())}
}
return nil
}
// Test implements provisioning.Repository.
// NOTE: Validate has been called (and passed) before this function should be called
func (r *localRepository) Test(ctx context.Context) (*provisioning.TestResults, error) {
path := field.NewPath("spec", "local", "path")
if r.config.Spec.Local.Path == "" {
return repository.FromFieldError(field.Required(path, "no path is configured")), nil
}
_, err := r.resolver.LocalPath(r.config.Spec.Local.Path)
if err != nil {
return repository.FromFieldError(field.Invalid(path, r.config.Spec.Local.Path, err.Error())), nil
}
_, err = os.Stat(r.path)
if errors.Is(err, os.ErrNotExist) {
return repository.FromFieldError(field.NotFound(path, r.config.Spec.Local.Path)), nil
}
return &provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil
}
// Test implements provisioning.Repository.
func (r *localRepository) validateRequest(ref string) error {
if ref != "" {
return apierrors.NewBadRequest("local repository does not support ref")
}
return nil
}
// ReadResource implements provisioning.Repository.
func (r *localRepository) Read(ctx context.Context, filePath string, ref string) (*repository.FileInfo, error) {
if err := r.validateRequest(ref); err != nil {
return nil, err
}
actualPath := safepath.Join(r.path, filePath)
info, err := os.Stat(actualPath)
if errors.Is(err, os.ErrNotExist) {
return nil, repository.ErrFileNotFound
} else if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
}
if info.IsDir() {
return &repository.FileInfo{
Path: filePath,
Modified: &metav1.Time{
Time: info.ModTime(),
},
}, nil
}
//nolint:gosec
data, err := os.ReadFile(actualPath)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
hash, _, err := r.calculateFileHash(actualPath)
if err != nil {
return nil, fmt.Errorf("calculate hash of file: %w", err)
}
return &repository.FileInfo{
Path: filePath,
Data: data,
Hash: hash,
Modified: &metav1.Time{
Time: info.ModTime(),
},
}, nil
}
// ReadResource implements provisioning.Repository.
func (r *localRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) {
if err := r.validateRequest(ref); err != nil {
return nil, err
}
// Return an empty list when folder does not exist
_, err := os.Stat(r.path)
if errors.Is(err, fs.ErrNotExist) {
return []repository.FileTreeEntry{}, nil
}
rootlen := len(r.path)
entries := make([]repository.FileTreeEntry, 0, 100)
err = filepath.Walk(r.path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
entry := repository.FileTreeEntry{
Path: strings.TrimLeft(path[rootlen:], "/"),
Size: info.Size(),
}
if entry.Path == "" {
return nil // skip the root file
}
if !info.IsDir() {
entry.Blob = true
entry.Hash, _, err = r.calculateFileHash(path)
if err != nil {
return fmt.Errorf("read and calculate hash of path %s: %w", path, err)
}
} else if !strings.HasSuffix(entry.Path, "/") {
// ensure trailing slash for directories
entry.Path = entry.Path + "/"
}
entries = append(entries, entry)
return err
})
return entries, err
}
func (r *localRepository) calculateFileHash(path string) (string, int64, error) {
// Treats https://securego.io/docs/rules/g304.html
if !safepath.InDir(path, r.path) {
return "", 0, repository.ErrFileNotFound
}
// We've already made sure the path is safe, so we'll ignore the gosec lint.
//nolint:gosec
file, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
return "", 0, fmt.Errorf("open file: %w", err)
}
// TODO: Define what hashing algorithm we want to use for the entire repository. Maybe a config option?
hasher := sha1.New()
// TODO: context-aware io.Copy? Is that even possible with a reasonable impl?
size, err := io.Copy(hasher, file)
if err != nil {
return "", 0, fmt.Errorf("copy file: %w", err)
}
// NOTE: EncodeToString (& hex.Encode for that matter) return lower-case hex.
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
func (r *localRepository) Create(ctx context.Context, filepath string, ref string, data []byte, comment string) error {
if err := r.validateRequest(ref); err != nil {
return err
}
fpath := safepath.Join(r.path, filepath)
_, err := os.Stat(fpath)
if !errors.Is(err, os.ErrNotExist) {
if err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to check if file exists: %w", err))
}
return apierrors.NewAlreadyExists(schema.GroupResource{}, filepath)
}
if safepath.IsDir(fpath) {
if data != nil {
return apierrors.NewBadRequest("data cannot be provided for a directory")
}
if err := os.MkdirAll(fpath, 0700); err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
}
return nil
}
if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
}
return os.WriteFile(fpath, data, 0600)
}
func (r *localRepository) Update(ctx context.Context, path string, ref string, data []byte, comment string) error {
if err := r.validateRequest(ref); err != nil {
return err
}
path = safepath.Join(r.path, path)
if safepath.IsDir(path) {
return apierrors.NewBadRequest("cannot update a directory")
}
f, err := os.Stat(path)
if err != nil && errors.Is(err, os.ErrNotExist) {
return repository.ErrFileNotFound
}
if f.IsDir() {
return apierrors.NewBadRequest("path exists but it is a directory")
}
return os.WriteFile(path, data, 0600)
}
func (r *localRepository) Write(ctx context.Context, fpath, ref string, data []byte, comment string) error {
if err := r.validateRequest(ref); err != nil {
return err
}
fpath = safepath.Join(r.path, fpath)
if safepath.IsDir(fpath) {
return os.MkdirAll(fpath, 0700)
}
if err := os.MkdirAll(path.Dir(fpath), 0700); err != nil {
return apierrors.NewInternalError(fmt.Errorf("failed to create path: %w", err))
}
return os.WriteFile(fpath, data, 0600)
}
func (r *localRepository) Delete(ctx context.Context, path string, ref string, comment string) error {
if err := r.validateRequest(ref); err != nil {
return err
}
fullPath := safepath.Join(r.path, path)
if safepath.IsDir(path) {
// if it is a folder, delete all of its contents
return os.RemoveAll(fullPath)
}
return os.Remove(fullPath)
}
func (r *localRepository) Move(ctx context.Context, oldPath, newPath, ref, comment string) error {
if err := r.validateRequest(ref); err != nil {
return err
}
oldFullPath := safepath.Join(r.path, oldPath)
newFullPath := safepath.Join(r.path, newPath)
// Check if source exists
sourceInfo, err := os.Stat(oldFullPath)
if errors.Is(err, os.ErrNotExist) {
return repository.ErrFileNotFound
} else if err != nil {
return fmt.Errorf("check source: %w", err)
}
// Check if destination already exists
if _, err := os.Stat(newFullPath); !errors.Is(err, os.ErrNotExist) {
if err != nil {
return fmt.Errorf("check destination: %w", err)
}
return repository.ErrFileAlreadyExists
}
// Validate move types
sourceIsDir := sourceInfo.IsDir()
targetIsDir := safepath.IsDir(newPath)
if sourceIsDir != targetIsDir {
return apierrors.NewBadRequest("cannot move between file and directory types")
}
// Create destination directory if needed
if !sourceIsDir {
// For file moves, create the directory containing the file
destParent := path.Dir(newFullPath)
if err := os.MkdirAll(destParent, 0700); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
} else {
// For directory moves, create the parent directory of the destination
// but not the destination directory itself (os.Rename will create it)
// We need to be careful with trailing slashes in directory paths
cleanNewPath := strings.TrimSuffix(newFullPath, "/")
destParent := path.Dir(cleanNewPath)
if destParent != "." && destParent != "/" && destParent != r.path {
if err := os.MkdirAll(destParent, 0700); err != nil {
return fmt.Errorf("create destination parent directory: %w", err)
}
}
}
// Move the file or directory
if err := os.Rename(oldFullPath, newFullPath); err != nil {
return fmt.Errorf("move: %w", err)
}
return nil
}