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:
1 parent
5c3f9ad1cc
commit
d33424e265
21 files changed
+564
-71
No files matched your search
+3
-3
@@ -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{},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user