POC/Provisioning: Create an import endpoint (#97085)

* wip

* fix: don't panic on dirs

* fix: workaround dashboard Get returning 500 on 404
This commit is contained in:
Mariell Hoversholm authored and GitHub committed 2024-11-27 10:10:29 +01:00
1 parent ab2f168b71
commit ee91485b32
14 files changed
+489 -37

No files matched your search

@@ -124,3 +124,7 @@ func (u *Unstructured) GetNestedInt64(fields ...string) int64 {
}
return val
}
func (u *Unstructured) ToKubernetesObject() *unstructured.Unstructured {
return &unstructured.Unstructured{Object: u.Object}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func (c *exportConnector) ProducesObject(verb string) any {
}
func (*exportConnector) ConnectMethods() []string {
return []string{http.MethodGet, http.MethodPost}
return []string{http.MethodPost}
}
func (*exportConnector) NewConnectOptions() (runtime.Object, bool, string) {
+1 -6
View File
@@ -134,12 +134,7 @@ func (s *filesConnector) getParser(repo repository.Repository) (*fileParser, err
if err != nil {
return nil, err
}
return &fileParser{
namespace: ns,
repo: repo,
client: client,
kinds: newKindsLookup(client),
}, nil
return newFileParser(ns, repo, client, newKindsLookup(client)), nil
}
func (s *filesConnector) doRead(ctx context.Context, logger *slog.Logger, repo repository.Repository, path string, ref string) (int, *provisioning.ResourceWrapper, error) {
+174
View File
@@ -0,0 +1,174 @@
package provisioning
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"strings"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
)
type importConnector struct {
repoGetter RepoGetter
client *resourceClient
logger *slog.Logger
}
func (*importConnector) New() runtime.Object {
// This is added as the "ResponseType" regardless what ProducesObject() returns
return &provisioning.ResourceWrapper{}
}
func (*importConnector) Destroy() {}
func (*importConnector) NamespaceScoped() bool {
return true
}
func (*importConnector) GetSingularName() string {
return "Import"
}
func (*importConnector) ProducesMIMETypes(verb string) []string {
return []string{"application/json"}
}
func (c *importConnector) ProducesObject(verb string) any {
return c.New()
}
func (*importConnector) ConnectMethods() []string {
return []string{http.MethodPost}
}
func (*importConnector) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
func (c *importConnector) Connect(
ctx context.Context,
name string,
opts runtime.Object,
responder rest.Responder,
) (http.Handler, error) {
logger := c.logger.With("repository_name", name)
repo, err := c.repoGetter.GetRepository(ctx, name)
if err != nil {
return nil, err
}
ns := repo.Config().GetNamespace()
// TODO: We need some way to filter what we import.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
client, err := c.client.Client(ns)
if err != nil {
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to create a dynamic client: %w", err)))
return
}
kinds := newKindsLookup(client)
fileParser := newFileParser(ns, repo, client, kinds)
ref := r.URL.Query().Get("ref")
if ref == "" {
responder.Error(apierrors.NewBadRequest("no ref value was given"))
return
}
tree, err := repo.ReadTree(r.Context(), logger, ref)
if err != nil {
responder.Error(err) // TODO: do we want to do anything more for this?
return
}
for _, entry := range tree {
logger := logger.With("file", entry.Path)
if !entry.Blob {
logger.DebugContext(ctx, "ignoring non-blob entry")
continue
}
name := filepath.Base(entry.Path)
if strings.ContainsRune(name, '.') {
name = name[:strings.LastIndex(name, ".")]
}
logger = logger.With("dashboard_name", name)
info, err := repo.Read(r.Context(), logger, entry.Path, ref)
if err != nil {
logger.DebugContext(ctx, "error on reading the entry", "error", err)
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to read %s: %w", entry.Path, err)))
return
}
// NOTE: We don't need validation because we're about to apply the data for real.
// If the data is invalid, we'll know then!
file, err := fileParser.parse(r.Context(), logger, info, false)
if err != nil {
logger.DebugContext(ctx, "error on parsing the entry's data", "error", err)
if errors.Is(err, ErrUnableToReadResourceBytes) {
// Non-resource data is not relevant to us.
logger.DebugContext(ctx, "ignoring file due to being a non-resource", "error", err)
continue
}
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to read %s: %w", entry.Path, err)))
return
}
wrapper := file.AsResourceWrapper()
resource := wrapper.Resource.File
gv, err := schema.ParseGroupVersion(resource.GetNestedString("apiVersion"))
if err != nil {
logger.DebugContext(ctx, "invalid GroupVersion was given as the apiVersion of the parsed object?", "error", err)
responder.Error(apierrors.NewInternalError(fmt.Errorf("got an invalid groupversion from a parsed file %s: %w", entry.Path, err)))
return
}
gvk := gv.WithKind(resource.GetNestedString("kind"))
logger = logger.With("gvk", gvk)
gvr, ok := kinds.Resource(gvk)
if !ok {
logger.DebugContext(ctx, "got GVK of a resource we don't know how to control; ignoring")
continue
}
iface := client.Resource(gvr).Namespace(ns)
_, err = iface.Get(r.Context(), name, metav1.GetOptions{})
// FIXME: Remove the 'false &&' when .Get returns 404 on 404 instead of 500. Until then, this is a really ugly workaround.
if false && err != nil && !apierrors.IsNotFound(err) {
logger.DebugContext(ctx, "failed to check if the object already exists", "error", err)
responder.Error(apierrors.NewInternalError(fmt.Errorf("failed to check if object already exists: %w", err)))
return
}
if err != nil { // IsNotFound
_, err = iface.Create(r.Context(), resource.ToKubernetesObject(), metav1.CreateOptions{})
} else { // already exists
_, err = iface.Update(ctx, resource.ToKubernetesObject(), metav1.UpdateOptions{})
}
if err != nil {
logger.DebugContext(ctx, "failed to upsert object", "error", err)
responder.Error(err) // FIXME
return
}
// TODO: Folders
logger.DebugContext(ctx, "successfully upserted the object")
}
responder.Object(http.StatusOK, &provisioning.ResourceWrapper{})
}), nil
}
var (
_ rest.Connecter = (*importConnector)(nil)
_ rest.Storage = (*importConnector)(nil)
_ rest.Scoper = (*importConnector)(nil)
_ rest.SingularNameProvider = (*importConnector)(nil)
_ rest.StorageMetadata = (*importConnector)(nil)
)
+9
View File
@@ -33,6 +33,15 @@ type fileParser struct {
kinds *kindsLookup
}
func newFileParser(ns string, repo repository.Repository, client *dynamic.DynamicClient, kinds *kindsLookup) *fileParser {
return &fileParser{
namespace: ns,
repo: repo,
client: client,
kinds: kinds,
}
}
type parsedFile struct {
// Original file info
info *repository.FileInfo
+42 -29
View File
@@ -158,6 +158,11 @@ func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserv
client: b.client,
logger: b.logger.With("connector", "export"),
}
importConnector := &importConnector{
repoGetter: b,
client: b.client,
logger: b.logger.With("connector", "export"),
}
storage := map[string]rest.Storage{}
storage[provisioning.RepositoryResourceInfo.StoragePath()] = repositoryStorage
@@ -175,6 +180,7 @@ func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserv
logger: b.logger.With("connector", "files"),
}
storage[provisioning.RepositoryResourceInfo.StoragePath("export")] = exportConnector
storage[provisioning.RepositoryResourceInfo.StoragePath("import")] = importConnector
apiGroupInfo.VersionedResourcesStorageMap[provisioning.VERSION] = storage
return nil
}
@@ -481,41 +487,48 @@ func (b *ProvisioningAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.
sub.Post.Description = "Currently only supports github webhooks"
}
ref := &spec3.Parameter{
ParameterProps: spec3.ParameterProps{
Name: "ref",
In: "query",
Example: "",
Examples: map[string]*spec3.Example{
"": {
ExampleProps: spec3.ExampleProps{
Summary: "The default",
},
},
"branch": {
ExampleProps: spec3.ExampleProps{
Value: "my-branch",
Summary: "Select branch",
},
},
"commit": {
ExampleProps: spec3.ExampleProps{
Value: "7f7cc2153",
Summary: "Commit hash (or prefix)",
},
},
},
Description: "branch or commit hash",
Schema: spec.StringProperty(),
Required: false,
}}
sub = oas.Paths.Paths[repoprefix+"/import"]
if sub != nil && sub.Post != nil {
ref := *ref
ref.ParameterProps.Required = true
sub.Post.Parameters = []*spec3.Parameter{&ref}
}
// hide the version with no path
delete(oas.Paths.Paths, repoprefix+"/files")
// update the version with a path
sub = oas.Paths.Paths[repoprefix+"/files/{path}"]
if sub != nil {
ref := &spec3.Parameter{
ParameterProps: spec3.ParameterProps{
Name: "ref",
In: "query",
Example: "",
Examples: map[string]*spec3.Example{
"": {
ExampleProps: spec3.ExampleProps{
Summary: "The default",
},
},
"branch": {
ExampleProps: spec3.ExampleProps{
Value: "my-branch",
Summary: "Select branch",
},
},
"commit": {
ExampleProps: spec3.ExampleProps{
Value: "7f7cc2153",
Summary: "Commit hash (or prefix)",
},
},
},
Description: "optional branch or commit hash",
Schema: spec.StringProperty(),
Required: false,
}}
sub.Get.Description = "Read value from upstream repository"
sub.Get.Parameters = []*spec3.Parameter{ref}
@@ -98,7 +98,7 @@ func (r *githubRepository) Read(ctx context.Context, logger *slog.Logger, filePa
owner := r.config.Spec.GitHub.Owner
repo := r.config.Spec.GitHub.Repository
content, _, err := r.gh.GetContents(ctx, owner, repo, filePath, ref)
content, dirContent, err := r.gh.GetContents(ctx, owner, repo, filePath, ref)
if err != nil {
if errors.Is(err, pgh.ErrResourceNotFound) {
return nil, &apierrors.StatusError{
@@ -111,6 +111,9 @@ func (r *githubRepository) Read(ctx context.Context, logger *slog.Logger, filePa
return nil, fmt.Errorf("get contents: %w", err)
}
if dirContent != nil {
return nil, fmt.Errorf("input path was a directory")
}
data, err := content.GetFileContent()
if err != nil {
@@ -124,6 +127,42 @@ func (r *githubRepository) Read(ctx context.Context, logger *slog.Logger, filePa
}, nil
}
func (r *githubRepository) ReadTree(ctx context.Context, logger *slog.Logger, ref string) ([]FileTreeEntry, error) {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
}
owner := r.config.Spec.GitHub.Owner
repo := r.config.Spec.GitHub.Repository
logger = logger.With("owner", owner, "repo", repo, "ref", ref)
tree, truncated, err := r.gh.GetTree(ctx, owner, repo, ref, true)
if err != nil {
if errors.Is(err, pgh.ErrResourceNotFound) {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: fmt.Sprintf("tree not found; ref=%s", ref),
Code: http.StatusNotFound,
},
}
}
}
if truncated {
logger.WarnContext(ctx, "tree from github was truncated")
}
entries := make([]FileTreeEntry, 0, len(tree))
for _, entry := range tree {
converted := FileTreeEntry{
Path: entry.GetPath(),
Size: entry.GetSize(),
Hash: entry.GetSHA(),
Blob: !entry.IsDirectory(),
}
entries = append(entries, converted)
}
return entries, nil
}
func (r *githubRepository) Create(ctx context.Context, logger *slog.Logger, path, ref string, data []byte, comment string) error {
if ref == "" {
ref = r.config.Spec.GitHub.Branch
@@ -32,6 +32,13 @@ type Client interface {
// If ".." appears in the "path", this method will return an error.
GetContents(ctx context.Context, owner, repository, path, ref string) (fileContents RepositoryContent, dirContents []RepositoryContent, err error)
// GetTree returns the Git tree in the repository.
// When recursive is given, subtrees are mapped into the returned array.
//
// The truncated bool will be set to true if the tree is larger than 7 MB or 100 000 entries.
// When truncated is true, you may wish to read each subtree manually instead.
GetTree(ctx context.Context, owner, repository, ref string, recursive bool) (entries []RepositoryContent, truncated bool, err error)
// CreateFile creates a new file in the repository under the given path.
// The file is created on the branch given.
// The message given is the commit message. If none is given, an appropriate default is used.
@@ -85,6 +92,8 @@ type RepositoryContent interface {
// Get the SHA hash. This is usually a SHA-256, but may also be SHA-512.
// Directories have SHA hashes, too (TODO: how is this calculated?).
GetSHA() string
// The size of the file. Not necessarily non-zero, even if the file is supposed to be non-zero.
GetSize() int64
}
type CreateFileOptions struct {
@@ -180,6 +180,43 @@ func (_m *MockClient) GetContents(ctx context.Context, owner string, repository
return r0, r1, r2
}
// GetTree provides a mock function with given fields: ctx, owner, repository, ref, recursive
func (_m *MockClient) GetTree(ctx context.Context, owner string, repository string, ref string, recursive bool) ([]RepositoryContent, bool, error) {
ret := _m.Called(ctx, owner, repository, ref, recursive)
if len(ret) == 0 {
panic("no return value specified for GetTree")
}
var r0 []RepositoryContent
var r1 bool
var r2 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, bool) ([]RepositoryContent, bool, error)); ok {
return rf(ctx, owner, repository, ref, recursive)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, bool) []RepositoryContent); ok {
r0 = rf(ctx, owner, repository, ref, recursive)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]RepositoryContent)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string, string, string, bool) bool); ok {
r1 = rf(ctx, owner, repository, ref, recursive)
} else {
r1 = ret.Get(1).(bool)
}
if rf, ok := ret.Get(2).(func(context.Context, string, string, string, bool) error); ok {
r2 = rf(ctx, owner, repository, ref, recursive)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
// ListWebhooks provides a mock function with given fields: ctx, owner, repository
func (_m *MockClient) ListWebhooks(ctx context.Context, owner string, repository string) ([]WebhookConfig, error) {
ret := _m.Called(ctx, owner, repository)
@@ -51,6 +51,41 @@ func (r *realImpl) GetContents(ctx context.Context, owner, repository, path, ref
}
}
func (r *realImpl) GetTree(ctx context.Context, owner, repository, ref string, recursive bool) ([]RepositoryContent, bool, error) {
tree, _, err := r.gh.Git.GetTree(ctx, owner, repository, ref, recursive)
if err != nil {
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return nil, false, err
}
if ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return nil, false, ErrServiceUnavailable
}
if ghErr.Response.StatusCode == http.StatusNotFound {
return nil, false, ErrResourceNotFound
}
return nil, false, err
}
var entries []RepositoryContent
for _, te := range tree.Entries {
rrc := &realRepositoryContent{
real: &github.RepositoryContent{
Path: te.Path,
Size: te.Size,
SHA: te.SHA,
},
}
if te.GetType() == "tree" {
rrc.real.Type = github.String("dir")
} else {
rrc.real.Type = te.Type
}
entries = append(entries, rrc)
}
return entries, tree.GetTruncated(), nil
}
func (r *realImpl) CreateFile(ctx context.Context, owner, repository, path, branch, message string, content []byte) error {
if strings.Contains(path, "..") {
return ErrPathTraversalDisallowed
@@ -300,3 +335,15 @@ func (c realRepositoryContent) GetPath() string {
func (c realRepositoryContent) GetSHA() string {
return c.real.GetSHA()
}
func (c realRepositoryContent) GetSize() int64 {
if c.real.Size != nil {
return int64(*c.real.Size)
}
if c.real.Content != nil {
if c, err := c.real.GetContent(); err == nil {
return int64(len(c))
}
}
return 0
}
@@ -2,8 +2,11 @@ package repository
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
@@ -134,6 +137,8 @@ func (r *localRepository) Read(ctx context.Context, logger *slog.Logger, path st
info, err := os.Stat(fullpath)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrFileNotFound
} else if err != nil {
return nil, err
}
//nolint:gosec
@@ -142,15 +147,95 @@ func (r *localRepository) Read(ctx context.Context, logger *slog.Logger, path st
return nil, err
}
hash, _, err := r.calculateFileHash(ctx, fullpath)
if err != nil {
return nil, err
}
return &FileInfo{
Path: path,
Data: data,
Hash: hash,
Modified: &metav1.Time{
Time: info.ModTime(),
},
}, nil
}
// ReadResource implements provisioning.Repository.
func (r *localRepository) ReadTree(ctx context.Context, logger *slog.Logger, ref string) ([]FileTreeEntry, error) {
if ref != "" {
return nil, apierrors.NewBadRequest("local repository does not support ref")
}
if r.path == "" {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: "the service is missing a root path",
Code: http.StatusFailedDependency,
},
}
}
return r.recursivelyReadTree(ctx, r.path)
}
func (r *localRepository) recursivelyReadTree(ctx context.Context, path string) ([]FileTreeEntry, error) {
osEntries, err := os.ReadDir(path)
if err != nil {
return nil, err
}
entries := make([]FileTreeEntry, 0, len(osEntries))
for _, oe := range osEntries {
if err := ctx.Err(); err != nil {
return nil, err
}
oePath := filepath.Join(path, oe.Name())
if oe.IsDir() {
entries = append(entries, FileTreeEntry{
Path: oePath,
Hash: "",
Size: 0,
Blob: false,
})
subEntries, err := r.recursivelyReadTree(ctx, oePath)
if err != nil {
return nil, err
}
entries = append(entries, subEntries...)
} else {
hash, size, err := r.calculateFileHash(ctx, oePath)
if err != nil {
return nil, fmt.Errorf("failed to read and calculate hash of path %s: %w", oePath, err)
}
entries = append(entries, FileTreeEntry{
Path: oePath,
Blob: true,
Hash: hash,
Size: size,
})
}
}
return entries, nil
}
func (r *localRepository) calculateFileHash(ctx context.Context, path string) (string, int64, error) {
file, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
return "", 0, 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, 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, logger *slog.Logger, path string, ref string, data []byte, comment string) error {
if ref != "" {
return apierrors.NewBadRequest("local repository does not support ref")
@@ -41,6 +41,21 @@ type FileInfo struct {
Modified *metav1.Time
}
// An entry in the file tree, as returned by 'ReadFileTree'. Like FileInfo, but contains less information.
type FileTreeEntry struct {
// The path to the file from the base path given (if any).
// No leading or trailing slashes will be contained within.
Path string
// The hash for the file. Lower-case hex.
// Empty string if Blob is false.
Hash string
// The size of the file.
// 0 if Blob is false.
Size int64
// Whether this entry is a blob or a subtree.
Blob bool
}
type Repository interface {
// The saved Kubernetes object.
Config() *provisioning.Repository
@@ -57,6 +72,13 @@ type Repository interface {
// This data will be parsed and validated before it is shown to end users
Read(ctx context.Context, logger *slog.Logger, path, ref string) (*FileInfo, error)
// Read all file names from the tree.
// This data will be parsed and validated before it is shown.
//
// TODO: Make some API contract that lets us ignore files that aren't relevant to us (e.g. CI/CD, CODEOWNERS, other configs or source code).
// TODO: Test scale: do we want to stream entries instead somehow?
ReadTree(ctx context.Context, logger *slog.Logger, ref string) ([]FileTreeEntry, error)
// Write a file to the repository.
// The data has already been validated and is ready for save
Create(ctx context.Context, logger *slog.Logger, path, ref string, data []byte, message string) error
@@ -63,6 +63,15 @@ func (r *s3Repository) Read(ctx context.Context, logger *slog.Logger, path strin
}
}
func (r *s3Repository) ReadTree(ctx context.Context, logger *slog.Logger, ref string) ([]FileTreeEntry, error) {
return nil, &errors.StatusError{
ErrStatus: metav1.Status{
Message: "read file tree resource is not yet implemented",
Code: http.StatusNotImplemented,
},
}
}
func (r *s3Repository) Create(ctx context.Context, logger *slog.Logger, path string, ref string, data []byte, comment string) error {
return &errors.StatusError{
ErrStatus: metav1.Status{
@@ -58,6 +58,15 @@ func (r *unknownRepository) Read(ctx context.Context, logger *slog.Logger, path,
}
}
func (r *unknownRepository) ReadTree(ctx context.Context, logger *slog.Logger, ref string) ([]FileTreeEntry, error) {
return nil, &errors.StatusError{
ErrStatus: metav1.Status{
Message: "read file tree resource is not yet implemented",
Code: http.StatusNotImplemented,
},
}
}
func (r *unknownRepository) Create(ctx context.Context, logger *slog.Logger, path, ref string, data []byte, comment string) error {
return &errors.StatusError{
ErrStatus: metav1.Status{