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:
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}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in new issue
Block a user