POC/Provisioning: Support test method for repository configuration (#97485)

* add test method

* support new

* show test failure in the UI

* update betterer

* fix lint/test

* merge

* merge

* frontend more cleanup

* move common validation functions

* cleanup

* Add github repository tests

---------

Co-authored-by: Roberto Jimenez Sanchez <roberto.jimenez@grafana.com>
This commit is contained in:
Ryan McKinleyandRoberto Jimenez Sanchez authored and GitHub committed 2024-12-10 16:03:20 +01:00
1 parent 5c3f9ad1cc
commit d33424e265
21 files changed
+564 -71

No files matched your search

+3 -3
View File
@@ -3724,15 +3724,15 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"]
],
"public/app/features/provisioning/api/index.ts:5381": [
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
],
"public/app/features/provisioning/api/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/query/components/QueryEditorRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
@@ -111,6 +111,7 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error {
&ResourceWrapper{},
&FileList{},
&HistoryList{},
&TestResults{},
&Job{},
&JobList{},
)
+18
View File
@@ -256,6 +256,24 @@ type FileItem struct {
Author string `json:"author,omitempty"`
}
// HistoryList is a list of versions of a resource
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type TestResults struct {
metav1.TypeMeta `json:",inline"`
// HTTP status code
Code int `json:"code"`
// Is the connection healthy
Success bool `json:"success"`
// Error descriptions
Errors []string `json:"errors,omitempty"`
// Optional details
Details *common.Unstructured `json:"details,omitempty"`
}
// HistoryList is a list of versions of a resource
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type HistoryList struct {
@@ -570,6 +570,40 @@ func (in *S3RepositoryConfig) DeepCopy() *S3RepositoryConfig {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TestResults) DeepCopyInto(out *TestResults) {
*out = *in
out.TypeMeta = in.TypeMeta
if in.Errors != nil {
in, out := &in.Errors, &out.Errors
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Details != nil {
in, out := &in.Details, &out.Details
*out = (*in).DeepCopy()
}
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResults.
func (in *TestResults) DeepCopy() *TestResults {
if in == nil {
return nil
}
out := new(TestResults)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TestResults) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WebhookResponse) DeepCopyInto(out *WebhookResponse) {
*out = *in
@@ -38,6 +38,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceType": schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceWrapper": schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_S3RepositoryConfig(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.TestResults": schema_pkg_apis_provisioning_v0alpha1_TestResults(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.WebhookResponse": schema_pkg_apis_provisioning_v0alpha1_WebhookResponse(ref),
}
}
@@ -1169,6 +1170,73 @@ func schema_pkg_apis_provisioning_v0alpha1_S3RepositoryConfig(ref common.Referen
}
}
func schema_pkg_apis_provisioning_v0alpha1_TestResults(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "HistoryList is a list of versions of a resource",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
"code": {
SchemaProps: spec.SchemaProps{
Description: "HTTP status code",
Default: 0,
Type: []string{"integer"},
Format: "int32",
},
},
"success": {
SchemaProps: spec.SchemaProps{
Description: "Is the connection healthy",
Default: false,
Type: []string{"boolean"},
Format: "",
},
},
"errors": {
SchemaProps: spec.SchemaProps{
Description: "Error descriptions",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"details": {
SchemaProps: spec.SchemaProps{
Description: "Optional details",
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
},
},
},
Required: []string{"code", "success"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_WebhookResponse(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -4,10 +4,6 @@ API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provis
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,HistoryItem,Authors
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,JobSpec,Commits
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,JobStatus,Errors
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,ResourceWrapper,Errors
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,ResourceWrapper,Lint
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,CommitInfo,SHA1
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,FileList,Items
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositorySpec,GitHub
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositorySpec,PreferYAML
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,WebhookResponse,Message
+31 -34
View File
@@ -9,7 +9,6 @@ import (
"log/slog"
"net/url"
"path/filepath"
"slices"
"strings"
"github.com/prometheus/client_golang/prometheus"
@@ -196,6 +195,10 @@ func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserv
jobs: b.jobs,
logger: b.logger.With("connector", "webhook"),
}
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = &testConnector{
getter: b,
logger: b.logger.With("connector", "test"),
}
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = &filesConnector{
getter: b,
client: b.client,
@@ -236,7 +239,10 @@ func (b *ProvisioningAPIBuilder) asRepository(ctx context.Context, obj runtime.O
if !ok {
return nil, fmt.Errorf("expected repository configuration")
}
return b.AsRepository(ctx, r)
}
func (b *ProvisioningAPIBuilder) AsRepository(ctx context.Context, r *provisioning.Repository) (repository.Repository, error) {
switch r.Spec.Type {
case provisioning.LocalRepositoryType:
return repository.NewLocal(r, b.localFileResolver), nil
@@ -444,11 +450,10 @@ func (b *ProvisioningAPIBuilder) Validate(ctx context.Context, a admission.Attri
return err
}
// Typed validation
list := repo.Validate()
cfg := repo.Config()
list := ValidateRepository(repo)
if a.GetOperation() == admission.Update {
cfg := repo.Config()
oldRepo, err := b.asRepository(ctx, a.GetOldObject())
if err != nil {
return fmt.Errorf("get old repository for update: %w", err)
@@ -460,36 +465,10 @@ func (b *ProvisioningAPIBuilder) Validate(ctx context.Context, a admission.Attri
}
}
if cfg.Spec.Title == "" {
list = append(list, field.Required(field.NewPath("spec", "title"), "a repository title must be given"))
}
// Reserved names (for now)
reserved := []string{"classic", "sql", "SQL", "plugins", "legacy", "new", "job", "github", "s3", "gcs", "file"}
if slices.Contains(reserved, cfg.Name) {
list = append(list, field.Invalid(field.NewPath("metadata", "name"), cfg.Name, "Name is reserved, choose a different identifier"))
}
if cfg.Spec.Type != provisioning.LocalRepositoryType && cfg.Spec.Local != nil {
list = append(list, field.Invalid(field.NewPath("spec", "local"),
cfg.Spec.GitHub, "Local config only valid when type is local"))
}
if cfg.Spec.Type != provisioning.GitHubRepositoryType && cfg.Spec.GitHub != nil {
list = append(list, field.Invalid(field.NewPath("spec", "github"),
cfg.Spec.GitHub, "Github config only valid when type is github"))
}
if cfg.Spec.Type != provisioning.S3RepositoryType && cfg.Spec.S3 != nil {
list = append(list, field.Invalid(field.NewPath("spec", "s3"),
cfg.Spec.GitHub, "S3 config only valid when type is s3"))
}
if len(list) > 0 {
return apierrors.NewInvalid(schema.GroupKind{
Group: provisioning.GROUP,
Kind: "Repository", //??
}, a.GetName(), list)
return apierrors.NewInvalid(
provisioning.RepositoryResourceInfo.GroupVersionKind().GroupKind(),
a.GetName(), list)
}
return nil
}
@@ -510,6 +489,7 @@ func (b *ProvisioningAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.
repoprefix := root + "namespaces/{namespace}/repositories/{name}"
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
defsBase := "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1."
// TODO: we might want to register some extras for subresources here.
sub := oas.Paths.Paths[repoprefix+"/hello"]
@@ -529,6 +509,23 @@ func (b *ProvisioningAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.
}
}
sub = oas.Paths.Paths[repoprefix+"/test"]
if sub != nil {
repoSchema := defs[defsBase+"Repository"].Schema
sub.Post.Description = "Check if the configuration is valid"
sub.Post.RequestBody = &spec3.RequestBody{
RequestBodyProps: spec3.RequestBodyProps{
Content: map[string]*spec3.MediaType{
"application/json": &spec3.MediaType{
MediaTypeProps: spec3.MediaTypeProps{
Schema: &repoSchema,
},
},
},
},
}
}
sub = oas.Paths.Paths[repoprefix+"/webhook"]
if sub != nil && sub.Get != nil {
sub.Post.Description = "Currently only supports github webhooks"
@@ -595,7 +592,7 @@ func (b *ProvisioningAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.
// Replace the content type for this response
mt := sub.Get.Responses.StatusCodeResponses[200].Content
s := defs["github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.FileList"].Schema
s := defs[defsBase+"FileList"].Schema
mt["*/*"].Schema = &s
}
@@ -99,13 +99,56 @@ func (r *githubRepository) Validate() (list field.ErrorList) {
}
// Test implements provisioning.Repository.
func (r *githubRepository) Test(ctx context.Context, logger *slog.Logger) error {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: "test is not yet implemented",
Code: http.StatusNotImplemented,
},
func (r *githubRepository) Test(ctx context.Context, logger *slog.Logger) (*provisioning.TestResults, error) {
if err := r.gh.IsAuthenticated(ctx); err != nil {
// TODO: should we return a more specific error or error code?
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{err.Error()},
}, nil
}
// FIXME: check token permissions
ok, err := r.gh.RepoExists(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository)
if err != nil {
return &provisioning.TestResults{
Code: http.StatusInternalServerError,
Success: false,
Errors: []string{err.Error()},
}, nil
}
if !ok {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{"repository does not exist"},
}, nil
}
ok, err = r.gh.BranchExists(ctx, r.config.Spec.GitHub.Owner, r.config.Spec.GitHub.Repository, r.config.Spec.GitHub.Branch)
if err != nil {
return &provisioning.TestResults{
Code: http.StatusInternalServerError,
Success: false,
Errors: []string{err.Error()},
}, nil
}
if !ok {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{"branch does not exist"},
}, nil
}
return &provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil
}
// ReadResource implements provisioning.Repository.
@@ -26,6 +26,9 @@ type ErrRateLimited = github.RateLimitError
//go:generate mockery --name Client --structname MockClient --inpackage --filename client_mock.go
type Client interface {
// IsAuthenticated checks if the client is authenticated.
IsAuthenticated(ctx context.Context) error
// GetContents returns the metadata and content of a file or directory.
// When a file is checked, the first returned value will have a value. For a directory, the second will. The other value is always nil.
// If an error occurs, the returned values may or may not be nil.
@@ -70,6 +73,9 @@ type Client interface {
// Commits returns the commits for the given path
Commits(ctx context.Context, owner, repository, path, branch string) ([]Commit, error)
// RepoExists checks if a repository exists.
RepoExists(ctx context.Context, owner, repository string) (bool, error)
// CreateBranch creates a new branch in the repository.
CreateBranch(ctx context.Context, owner, repository, sourceBranch, branchName string) error
// BranchExists checks if a branch exists in the repository.
@@ -309,6 +309,24 @@ func (_m *MockClient) GetTree(ctx context.Context, owner string, repository stri
return r0, r1, r2
}
// IsAuthenticated provides a mock function with given fields: ctx
func (_m *MockClient) IsAuthenticated(ctx context.Context) error {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for IsAuthenticated")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context) error); ok {
r0 = rf(ctx)
} else {
r0 = ret.Error(0)
}
return r0
}
// ListPullRequestFiles provides a mock function with given fields: ctx, owner, repository, number
func (_m *MockClient) ListPullRequestFiles(ctx context.Context, owner string, repository string, number int) ([]CommitFile, error) {
ret := _m.Called(ctx, owner, repository, number)
@@ -369,6 +387,34 @@ func (_m *MockClient) ListWebhooks(ctx context.Context, owner string, repository
return r0, r1
}
// RepoExists provides a mock function with given fields: ctx, owner, repository
func (_m *MockClient) RepoExists(ctx context.Context, owner string, repository string) (bool, error) {
ret := _m.Called(ctx, owner, repository)
if len(ret) == 0 {
panic("no return value specified for RepoExists")
}
var r0 bool
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) (bool, error)); ok {
return rf(ctx, owner, repository)
}
if rf, ok := ret.Get(0).(func(context.Context, string, string) bool); ok {
r0 = rf(ctx, owner, repository)
} else {
r0 = ret.Get(0).(bool)
}
if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = rf(ctx, owner, repository)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateFile provides a mock function with given fields: ctx, owner, repository, path, branch, message, hash, content
func (_m *MockClient) UpdateFile(ctx context.Context, owner string, repository string, path string, branch string, message string, hash string, content []byte) error {
ret := _m.Called(ctx, owner, repository, path, branch, message, hash, content)
@@ -9,6 +9,8 @@ import (
"time"
"github.com/google/go-github/v66/github"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type realImpl struct {
@@ -21,6 +23,45 @@ func NewRealClient(client *github.Client) *realImpl {
return &realImpl{client}
}
func (r *realImpl) IsAuthenticated(ctx context.Context) error {
if _, _, err := r.gh.Users.Get(ctx, ""); err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
switch ghErr.Response.StatusCode {
case http.StatusUnauthorized:
return apierrors.NewUnauthorized("token is invalid or expired")
case http.StatusForbidden:
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusUnauthorized,
Reason: metav1.StatusReasonUnauthorized,
Message: "token is revoked or has insufficient permissions",
},
}
case http.StatusServiceUnavailable:
return ErrServiceUnavailable
}
}
return err
}
return nil
}
func (r *realImpl) RepoExists(ctx context.Context, owner, repository string) (bool, error) {
_, resp, err := r.gh.Repositories.Get(ctx, owner, repository)
if err == nil {
return true, nil
}
if resp.StatusCode == http.StatusNotFound {
return false, nil
}
return false, err
}
func (r *realImpl) GetContents(ctx context.Context, owner, repository, path, ref string) (fileContents RepositoryContent, dirContents []RepositoryContent, err error) {
if strings.Contains(path, "..") {
return nil, nil, ErrPathTraversalDisallowed
@@ -112,13 +112,33 @@ func (r *localRepository) Validate() (fields field.ErrorList) {
}
// Test implements provisioning.Repository.
func (r *localRepository) Test(ctx context.Context, logger *slog.Logger) error {
return &apierrors.StatusError{
ErrStatus: metav1.Status{
Message: "test is not yet implemented",
Code: http.StatusNotImplemented,
},
// NOTE: Validate has been called (and passed) before this function should be called
func (r *localRepository) Test(ctx context.Context, logger *slog.Logger) (*provisioning.TestResults, error) {
if r.path == "" {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{
fmt.Sprintf("invalid path: %s", r.config.Spec.Local.Path),
},
}, nil
}
_, err := os.Stat(r.path)
if errors.Is(err, os.ErrNotExist) {
return &provisioning.TestResults{
Code: http.StatusBadRequest,
Success: false,
Errors: []string{
fmt.Sprintf("file not found: %s", r.config.Spec.Local.Path),
},
}, nil
}
return &provisioning.TestResults{
Code: http.StatusOK,
Success: true,
}, nil
}
// Test implements provisioning.Repository.
@@ -65,7 +65,7 @@ type Repository interface {
Validate() field.ErrorList
// Called to check if all connection information actually works
Test(ctx context.Context, logger *slog.Logger) error
Test(ctx context.Context, logger *slog.Logger) (*provisioning.TestResults, error)
// Read a file from the resource
// This data will be parsed and validated before it is shown to end users
@@ -43,8 +43,8 @@ func (r *s3Repository) Validate() (list field.ErrorList) {
}
// Test implements provisioning.Repository.
func (r *s3Repository) Test(ctx context.Context, logger *slog.Logger) error {
return &errors.StatusError{
func (r *s3Repository) Test(ctx context.Context, logger *slog.Logger) (*provisioning.TestResults, error) {
return nil, &errors.StatusError{
ErrStatus: metav1.Status{
Message: "test is not yet implemented",
Code: http.StatusNotImplemented,
@@ -39,8 +39,8 @@ func (r *unknownRepository) Validate() (fields field.ErrorList) {
}
// Test implements provisioning.Repository.
func (r *unknownRepository) Test(ctx context.Context, logger *slog.Logger) error {
return &errors.StatusError{
func (r *unknownRepository) Test(ctx context.Context, logger *slog.Logger) (*provisioning.TestResults, error) {
return nil, &errors.StatusError{
ErrStatus: metav1.Status{
Message: "test is not yet implemented",
Code: http.StatusNotImplemented,
+161
View File
@@ -0,0 +1,161 @@
package provisioning
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"slices"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/registry/rest"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
type testConnector struct {
getter RepoGetter
logger *slog.Logger
}
func (*testConnector) New() runtime.Object {
return &provisioning.TestResults{}
}
func (*testConnector) Destroy() {}
func (*testConnector) ProducesMIMETypes(verb string) []string {
return []string{"application/json"}
}
func (*testConnector) ProducesObject(verb string) any {
return &provisioning.TestResults{}
}
func (*testConnector) ConnectMethods() []string {
return []string{http.MethodPost}
}
func (*testConnector) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
func (s *testConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
logger := s.logger.With("repository_name", name)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
responder.Error(err)
return
}
var repo repository.Repository
if len(body) > 0 {
cfg := &provisioning.Repository{}
err = json.Unmarshal(body, cfg)
if err != nil {
responder.Error(err)
return
}
// Create a temporary repository
tmp, err := s.getter.AsRepository(ctx, cfg)
if err != nil {
responder.Error(err)
return
}
if name != "new" {
repo, err = s.getter.AsRepository(ctx, cfg)
if err != nil {
responder.Error(err)
return
}
// Make sure we are OK with the changes
if cfg.Spec.Type != repo.Config().Spec.Type {
responder.Error(apierrors.NewBadRequest("test config must be the same type"))
return
}
}
repo = tmp
}
if repo == nil {
repo, err = s.getter.GetRepository(ctx, name)
if err != nil {
responder.Error(err)
return
}
}
// Only call test if field validation passes
rsp, err := TestRepository(ctx, repo, logger)
if err != nil {
responder.Error(err)
return
}
responder.Object(rsp.Code, rsp)
}), nil
}
// This function will validate and test a configuration
// TODO?: this should periodically be run and the update the status if anything changes
func TestRepository(ctx context.Context, repo repository.Repository, logger *slog.Logger) (*provisioning.TestResults, error) {
errors := ValidateRepository(repo)
if len(errors) > 0 {
rsp := &provisioning.TestResults{
Code: http.StatusUnprocessableEntity, // Invalid
Success: false,
Errors: make([]string, len(errors)),
}
for i, v := range errors {
rsp.Errors[i] = v.Error()
}
return rsp, nil
}
return repo.Test(ctx, logger)
}
// Validate a repository
func ValidateRepository(repo repository.Repository) field.ErrorList {
list := repo.Validate()
cfg := repo.Config()
if cfg.Spec.Title == "" {
list = append(list, field.Required(field.NewPath("spec", "title"), "a repository title must be given"))
}
// Reserved names (for now)
reserved := []string{"classic", "sql", "SQL", "plugins", "legacy", "new", "job", "github", "s3", "gcs", "file", "new", "create", "update", "delete"}
if slices.Contains(reserved, cfg.Name) {
list = append(list, field.Invalid(field.NewPath("metadata", "name"), cfg.Name, "Name is reserved, choose a different identifier"))
}
if cfg.Spec.Type != provisioning.LocalRepositoryType && cfg.Spec.Local != nil {
list = append(list, field.Invalid(field.NewPath("spec", "local"),
cfg.Spec.GitHub, "Local config only valid when type is local"))
}
if cfg.Spec.Type != provisioning.GitHubRepositoryType && cfg.Spec.GitHub != nil {
list = append(list, field.Invalid(field.NewPath("spec", "github"),
cfg.Spec.GitHub, "Github config only valid when type is github"))
}
if cfg.Spec.Type != provisioning.S3RepositoryType && cfg.Spec.S3 != nil {
list = append(list, field.Invalid(field.NewPath("spec", "s3"),
cfg.Spec.GitHub, "S3 config only valid when type is s3"))
}
return list
}
var (
_ rest.Storage = (*testConnector)(nil)
_ rest.Connecter = (*testConnector)(nil)
_ rest.StorageMetadata = (*testConnector)(nil)
)
+5
View File
@@ -3,9 +3,14 @@ package provisioning
import (
"context"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository"
)
type RepoGetter interface {
// This gets a repository with the provided name in the namespace from ctx
GetRepository(ctx context.Context, name string) (repository.Repository, error)
// Given a repository configuration, return it as a repository instance
AsRepository(ctx context.Context, cfg *provisioning.Repository) (repository.Repository, error)
}
@@ -151,6 +151,15 @@ func TestIntegrationProvisioning(t *testing.T) {
"update"
]
},
{
"name": "repositories/test",
"singularName": "",
"namespaced": true,
"kind": "TestResults",
"verbs": [
"create"
]
},
{
"name": "repositories/webhook",
"singularName": "",
@@ -5,6 +5,7 @@ import { useAsync } from 'react-use';
import { SelectableValue, urlUtil } from '@grafana/data';
import {
List,
Alert,
Card,
CellProps,
@@ -26,8 +27,8 @@ import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { ScopedResourceClient } from '../apiserver/client';
import { useGetRepositoryStatusQuery, useListRepositoryFilesQuery } from './api';
import { JobSpec, JobStatus, RepositoryResource } from './api/types';
import { useGetRepositoryStatusQuery, useListRepositoryFilesQuery, useTestRepositoryQuery } from './api';
import { JobSpec, JobStatus, RepositoryResource, FileDetails, TestResponse } from './api/types';
import { PROVISIONING_URL } from './constants';
enum TabSelection {
@@ -71,6 +72,7 @@ export default function RepositoryStatusPage() {
<>
{query.data ? (
<>
<ErrorView repo={query.data} />
<TabsBar>
{tabInfo.map((t: SelectableValue) => (
<Tab
@@ -97,17 +99,35 @@ export default function RepositoryStatusPage() {
</Page>
);
}
type FileDetails = {
path: string;
size: string;
hash: string;
};
interface RepoProps {
repo: RepositoryResource;
}
function ErrorView({ repo }: RepoProps) {
const name = repo.metadata.name;
const status = useTestRepositoryQuery({ name });
if (status.isLoading) {
return (
<div>
<Spinner /> Testing configuration...
</div>
);
}
if (status.isError) {
let response = (status.error as any)?.data as TestResponse;
if (!response || !response.errors) {
return <Alert title="Error testing configuration" severity="error" />;
}
return (
<Alert title="Error testing configuration" severity="error">
<List items={response.errors} renderItem={(error) => <div>{error}</div>} />
</Alert>
);
}
return null; // don't show anything when it is OK?
}
type Cell<T extends keyof FileDetails = keyof FileDetails> = CellProps<FileDetails, FileDetails[T]>;
function FilesView({ repo }: RepoProps) {
@@ -1,3 +1,5 @@
import { Resource } from 'app/features/apiserver/types';
import { baseAPI as api } from './baseAPI';
import {
RepositoryList,
@@ -10,6 +12,8 @@ import {
GetFileArg,
ListFilesApiResponse,
HistoryListResponse,
TestResponse,
RepositorySpec,
JobList,
} from './types';
@@ -135,6 +139,18 @@ const injectedRtkApi = api.injectEndpoints({
body,
}),
}),
testRepository: build.query<TestResponse, { name: string }>({
query: ({ name }) => ({
url: `${BASE_PATH}/${name}/test`,
method: 'POST', // tests the existing configuration
}),
}),
testRepositoryConfig: build.mutation<TestResponse, Resource<RepositorySpec>>({
query: ({ metadata }) => ({
url: `${BASE_PATH}/${metadata.name ?? 'new'}/test`,
method: 'POST',
}),
}),
}),
overrideExisting: false,
});
@@ -160,4 +176,6 @@ export const {
useGetRepositoryStatusQuery,
useUpdateRepositoryStatusMutation,
usePatchRepositoryStatusMutation,
useTestRepositoryQuery,
useTestRepositoryConfigMutation,
} = injectedRtkApi;
+14 -4
View File
@@ -44,9 +44,13 @@ export type EditingOptions = {
update: boolean;
};
export type WatchEvent = {
object: unknown;
type: string;
export type TestResponse = {
apiVersion?: string;
kind?: string;
code: number;
success: boolean;
errors?: string[];
details?: object;
};
export interface RequestArg {
@@ -126,9 +130,15 @@ export type GetFileArg = {
ref?: string;
};
export type FileDetails = {
path: string;
size: string;
hash: string;
};
export type ListFilesApiResponse = {
apiVersion?: string;
files?: any[];
files?: FileDetails[];
kind?: string;
metadata?: any;
};