diff --git a/.betterer.results b/.betterer.results index 60d3e226521..40d67c1f924 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3724,15 +3724,15 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "8"], [0, 0, 0, "No untranslated strings. Wrap text with ", "9"], [0, 0, 0, "No untranslated strings. Wrap text with ", "10"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "11"] + [0, 0, 0, "No untranslated strings. Wrap text with ", "11"], + [0, 0, 0, "No untranslated strings. Wrap text with ", "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"], diff --git a/pkg/apis/provisioning/v0alpha1/register.go b/pkg/apis/provisioning/v0alpha1/register.go index b2d64be15d3..add42f99381 100644 --- a/pkg/apis/provisioning/v0alpha1/register.go +++ b/pkg/apis/provisioning/v0alpha1/register.go @@ -111,6 +111,7 @@ func AddKnownTypes(gv schema.GroupVersion, scheme *runtime.Scheme) error { &ResourceWrapper{}, &FileList{}, &HistoryList{}, + &TestResults{}, &Job{}, &JobList{}, ) diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index 1a98dfe2902..a7aff2ca618 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -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 { diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index f26a4ab1307..34c56e4738d 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -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 diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 0cd6db94a90..9534bc8e908 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -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{ diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list index 5aa8c603c40..029fd76e475 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi_violation_exceptions.list @@ -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 diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index 14aa7e64417..ce7e56c6e0f 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -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 } diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go index 171e173ff0f..40be1aba122 100644 --- a/pkg/registry/apis/provisioning/repository/github.go +++ b/pkg/registry/apis/provisioning/repository/github.go @@ -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. diff --git a/pkg/registry/apis/provisioning/repository/github/client.go b/pkg/registry/apis/provisioning/repository/github/client.go index 086067c17bf..3f3e47f34ca 100644 --- a/pkg/registry/apis/provisioning/repository/github/client.go +++ b/pkg/registry/apis/provisioning/repository/github/client.go @@ -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. diff --git a/pkg/registry/apis/provisioning/repository/github/client_mock.go b/pkg/registry/apis/provisioning/repository/github/client_mock.go index 1ac250eb505..e64bae70e41 100644 --- a/pkg/registry/apis/provisioning/repository/github/client_mock.go +++ b/pkg/registry/apis/provisioning/repository/github/client_mock.go @@ -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) diff --git a/pkg/registry/apis/provisioning/repository/github/real.go b/pkg/registry/apis/provisioning/repository/github/real.go index 9d43bceea30..082475441b2 100644 --- a/pkg/registry/apis/provisioning/repository/github/real.go +++ b/pkg/registry/apis/provisioning/repository/github/real.go @@ -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 diff --git a/pkg/registry/apis/provisioning/repository/local.go b/pkg/registry/apis/provisioning/repository/local.go index cfc06b750b1..0a9110a6e03 100644 --- a/pkg/registry/apis/provisioning/repository/local.go +++ b/pkg/registry/apis/provisioning/repository/local.go @@ -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. diff --git a/pkg/registry/apis/provisioning/repository/repository.go b/pkg/registry/apis/provisioning/repository/repository.go index 01c8517e132..4b1c80e4019 100644 --- a/pkg/registry/apis/provisioning/repository/repository.go +++ b/pkg/registry/apis/provisioning/repository/repository.go @@ -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 diff --git a/pkg/registry/apis/provisioning/repository/s3.go b/pkg/registry/apis/provisioning/repository/s3.go index aeddb859c3e..c1c551b7ce3 100644 --- a/pkg/registry/apis/provisioning/repository/s3.go +++ b/pkg/registry/apis/provisioning/repository/s3.go @@ -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, diff --git a/pkg/registry/apis/provisioning/repository/unknown.go b/pkg/registry/apis/provisioning/repository/unknown.go index 7806ae4fc70..d2ff26f1a56 100644 --- a/pkg/registry/apis/provisioning/repository/unknown.go +++ b/pkg/registry/apis/provisioning/repository/unknown.go @@ -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, diff --git a/pkg/registry/apis/provisioning/test.go b/pkg/registry/apis/provisioning/test.go new file mode 100644 index 00000000000..fcb741cb2aa --- /dev/null +++ b/pkg/registry/apis/provisioning/test.go @@ -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) +) diff --git a/pkg/registry/apis/provisioning/types.go b/pkg/registry/apis/provisioning/types.go index 6a0fc9e2ed0..366b8393698 100644 --- a/pkg/registry/apis/provisioning/types.go +++ b/pkg/registry/apis/provisioning/types.go @@ -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) } diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index c304d212b49..ef27b937a7a 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -151,6 +151,15 @@ func TestIntegrationProvisioning(t *testing.T) { "update" ] }, + { + "name": "repositories/test", + "singularName": "", + "namespaced": true, + "kind": "TestResults", + "verbs": [ + "create" + ] + }, { "name": "repositories/webhook", "singularName": "", diff --git a/public/app/features/provisioning/RepositoryStatusPage.tsx b/public/app/features/provisioning/RepositoryStatusPage.tsx index 47b054f8054..ca0e3f3aed7 100644 --- a/public/app/features/provisioning/RepositoryStatusPage.tsx +++ b/public/app/features/provisioning/RepositoryStatusPage.tsx @@ -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 ? ( <> + {tabInfo.map((t: SelectableValue) => ( ); } - -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 ( +
+ Testing configuration... +
+ ); + } + if (status.isError) { + let response = (status.error as any)?.data as TestResponse; + if (!response || !response.errors) { + return ; + } + + return ( + +
{error}
} /> +
+ ); + } + return null; // don't show anything when it is OK? +} + type Cell = CellProps; function FilesView({ repo }: RepoProps) { diff --git a/public/app/features/provisioning/api/endpoints.ts b/public/app/features/provisioning/api/endpoints.ts index b98d9c5283e..44bd453b26a 100644 --- a/public/app/features/provisioning/api/endpoints.ts +++ b/public/app/features/provisioning/api/endpoints.ts @@ -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({ + query: ({ name }) => ({ + url: `${BASE_PATH}/${name}/test`, + method: 'POST', // tests the existing configuration + }), + }), + testRepositoryConfig: build.mutation>({ + 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; diff --git a/public/app/features/provisioning/api/types.ts b/public/app/features/provisioning/api/types.ts index 3a0ece7a093..eacf3203f22 100644 --- a/public/app/features/provisioning/api/types.ts +++ b/public/app/features/provisioning/api/types.ts @@ -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; };