From 8fda03d3d5ca77356515ba328b424298ca1becb0 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 19 Nov 2024 17:25:45 +0300 Subject: [PATCH] POC/Provisioning: Add write hooks (#96670) --- pkg/apis/provisioning/v0alpha1/types.go | 12 + .../v0alpha1/zz_generated.deepcopy.go | 17 ++ .../v0alpha1/zz_generated.openapi.go | 49 +++- pkg/registry/apis/provisioning/file.go | 223 ++++++++++++++++++ pkg/registry/apis/provisioning/github.go | 27 +++ pkg/registry/apis/provisioning/local.go | 57 ++++- pkg/registry/apis/provisioning/read.go | 102 -------- pkg/registry/apis/provisioning/register.go | 92 +++++++- pkg/registry/apis/provisioning/s3.go | 33 ++- pkg/registry/apis/provisioning/types.go | 45 +++- .../apis/provisioning/provisioning_test.go | 47 +++- .../provisioning/testdata/github-example.yaml | 6 +- .../local-conf-provisioning-sample.yaml | 4 + .../provisioning/testdata/local-devenv.yaml | 4 + 14 files changed, 587 insertions(+), 131 deletions(-) create mode 100644 pkg/registry/apis/provisioning/file.go delete mode 100644 pkg/registry/apis/provisioning/read.go diff --git a/pkg/apis/provisioning/v0alpha1/types.go b/pkg/apis/provisioning/v0alpha1/types.go index c2941d6b725..9b4f80704bb 100644 --- a/pkg/apis/provisioning/v0alpha1/types.go +++ b/pkg/apis/provisioning/v0alpha1/types.go @@ -87,6 +87,9 @@ type RepositorySpec struct { // The value is a reference to the Kubernetes metadata name of the folder in the same namespace. Folder string `json:"folder,omitempty"` + // Edit options within the repository + Editing EditingOptions `json:"editing"` + // The repository type. When selected oneOf the values below should be non-nil Type RepositoryType `json:"type"` @@ -104,6 +107,15 @@ type RepositorySpec struct { GitHub *GitHubRepositoryConfig `json:"github,omitempty"` } +type EditingOptions struct { + // End users can create new files in the remote file system + Create bool `json:"create"` + // End users can update existing files in the remote file system + Update bool `json:"update"` + // End users can delete existing files in the remote file system + Delete bool `json:"delete"` +} + // The status of a Repository. // This is expected never to be created by a kubectl call or similar, and is expected to rarely (if ever) be edited manually. // As such, it is also a little less well structured than the spec, such as conditional-but-ever-present fields. diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go index c69a12089b7..8a7c2f584ec 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.deepcopy.go @@ -11,6 +11,22 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EditingOptions) DeepCopyInto(out *EditingOptions) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EditingOptions. +func (in *EditingOptions) DeepCopy() *EditingOptions { + if in == nil { + return nil + } + out := new(EditingOptions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitHubRepositoryConfig) DeepCopyInto(out *GitHubRepositoryConfig) { *out = *in @@ -132,6 +148,7 @@ func (in *RepositoryList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RepositorySpec) DeepCopyInto(out *RepositorySpec) { *out = *in + out.Editing = in.Editing if in.Local != nil { in, out := &in.Local, &out.Local *out = new(LocalRepositoryConfig) diff --git a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go index 2c53937a56e..2f9f20f5230 100644 --- a/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/provisioning/v0alpha1/zz_generated.openapi.go @@ -14,6 +14,7 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.EditingOptions": schema_pkg_apis_provisioning_v0alpha1_EditingOptions(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.HelloWorld": schema_pkg_apis_provisioning_v0alpha1_HelloWorld(ref), "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig": schema_pkg_apis_provisioning_v0alpha1_LocalRepositoryConfig(ref), @@ -27,6 +28,43 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA } } +func schema_pkg_apis_provisioning_v0alpha1_EditingOptions(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "create": { + SchemaProps: spec.SchemaProps{ + Description: "End users can create new files in the remote file system", + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "update": { + SchemaProps: spec.SchemaProps{ + Description: "End users can update existing files in the remote file system", + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + "delete": { + SchemaProps: spec.SchemaProps{ + Description: "End users can delete existing files in the remote file system", + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + Required: []string{"create", "update", "delete"}, + }, + }, + } +} + func schema_pkg_apis_provisioning_v0alpha1_GitHubRepositoryConfig(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -266,6 +304,13 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa Format: "", }, }, + "editing": { + SchemaProps: spec.SchemaProps{ + Description: "Edit options within the repository", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.EditingOptions"), + }, + }, "type": { SchemaProps: spec.SchemaProps{ Description: "The repository type. When selected oneOf the values below should be non-nil\n\nPossible enum values:\n - `\"github\"`\n - `\"local\"`\n - `\"s3\"`", @@ -294,11 +339,11 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref common.ReferenceCa }, }, }, - Required: []string{"title", "type"}, + Required: []string{"title", "editing", "type"}, }, }, Dependencies: []string{ - "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig"}, + "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.EditingOptions", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.GitHubRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.LocalRepositoryConfig", "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.S3RepositoryConfig"}, } } diff --git a/pkg/registry/apis/provisioning/file.go b/pkg/registry/apis/provisioning/file.go new file mode 100644 index 00000000000..64e41da770d --- /dev/null +++ b/pkg/registry/apis/provisioning/file.go @@ -0,0 +1,223 @@ +package provisioning + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer/yaml" + "k8s.io/apiserver/pkg/registry/rest" + + "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" +) + +type readConnector struct { + getter RepoGetter +} + +func (*readConnector) New() runtime.Object { + // This is added as the "ResponseType" regardless what ProducesObject() returns + return &provisioning.ResourceWrapper{} +} + +func (*readConnector) Destroy() {} + +func (*readConnector) NamespaceScoped() bool { + return true +} + +func (*readConnector) GetSingularName() string { + return "Resource" +} + +func (*readConnector) ProducesMIMETypes(verb string) []string { + return []string{"application/json"} +} + +func (*readConnector) ProducesObject(verb string) any { + return &provisioning.ResourceWrapper{} +} + +func (*readConnector) ConnectMethods() []string { + return []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete} +} + +func (*readConnector) NewConnectOptions() (runtime.Object, bool, string) { + return nil, true, "" // true adds the {path} component +} + +func (s *readConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { + repo, err := s.getter.GetRepository(ctx, name) + if err != nil { + return nil, err + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + idx := strings.Index(r.URL.Path, "/"+name+"/read") + filePath := strings.TrimLeft(r.URL.Path[idx+len(name+"/read")+1:], "/") + if filePath == "" { + responder.Error(errors.NewBadRequest("missing path")) + return + } + + if strings.Contains(filePath, "..") { + responder.Error(errors.NewBadRequest("invalid path navigation")) + return + } + + switch filepath.Ext(filePath) { + case ".json", ".yaml", ".yml": + // ok + default: + responder.Error(errors.NewBadRequest("only yaml and json files supported")) + return + } + + var obj runtime.Object + commit := r.URL.Query().Get("commit") + message := r.URL.Query().Get("message") + + switch r.Method { + case http.MethodGet: + obj, err = s.doRead(r.Context(), repo, filePath, commit) + case http.MethodPost: + obj, err = s.doWrite(r.Context(), true, repo, filePath, message, r) + case http.MethodPut: + obj, err = s.doWrite(r.Context(), false, repo, filePath, message, r) + case http.MethodDelete: + obj, err = s.doDelete(r.Context(), repo, filePath, message) + default: + err = errors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method) + } + + if err != nil { + responder.Error(err) + return + } + responder.Object(200, obj) + }), nil +} + +func (s *readConnector) getValidatedBody(_ context.Context, data []byte) (*unstructured.Unstructured, *schema.GroupVersionKind, error) { + obj, gvk, err := LoadYAMLOrJSON(bytes.NewBuffer(data)) + if err != nil { + obj, gvk, err = FallbackResourceLoader(data) + if err != nil { + return nil, nil, err + } + } + + // TODO? dry run??? and add validation errors/warnings? + fmt.Printf("TODO, get client and then dry run... %+v\n", gvk) + + return obj, gvk, err +} + +func (s *readConnector) doRead(ctx context.Context, repo Repository, path string, commit string) (*provisioning.ResourceWrapper, error) { + data, err := repo.Read(ctx, path, commit) + if err != nil { + return nil, err + } + + obj, _, err := s.getValidatedBody(ctx, data) + if err != nil { + return nil, err + } + + return &provisioning.ResourceWrapper{ + Path: path, + Commit: commit, + Resource: v0alpha1.Unstructured{ + Object: obj.Object, + }, + }, nil +} + +func (s *readConnector) doWrite(ctx context.Context, update bool, repo Repository, path string, message string, req *http.Request) (runtime.Object, error) { + settings := repo.Config().Spec.Editing + if update && !settings.Update { + return nil, errors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), "updating files not enabled", nil) + } else if !settings.Create { + return nil, errors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), "creating files not enabled", nil) + } + + defer req.Body.Close() + data, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + + obj, _, err := s.getValidatedBody(ctx, data) + if err != nil { + return nil, err + } + + switch filepath.Ext(path) { + // JSON pretty print + case ".json": + data, err = json.MarshalIndent(obj, "", " ") + if err != nil { + return nil, err + } + + // Write the value as yaml + case ".yaml", ".yml": + buff := bytes.NewBuffer(make([]byte, 0, 1024)) + err = yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme). + Encode(obj, buff) + if err != nil { + return nil, err + } + data = buff.Bytes() + + default: + return nil, fmt.Errorf("unexpected format") + } + + if update { + err = repo.Update(ctx, path, data, message) + } else { + err = repo.Create(ctx, path, data, message) + } + if err != nil { + return nil, err + } + + fmt.Printf("TODO! trigger sync for this file we just wrote... %s\n", path) + + return obj, err +} + +func (s *readConnector) doDelete(ctx context.Context, repo Repository, path string, message string) (runtime.Object, error) { + settings := repo.Config().Spec.Editing + if !settings.Delete { + return nil, errors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), "deleting is not supported", nil) + } + + err := repo.Delete(ctx, path, message) + if err != nil { + return nil, err + } + + fmt.Printf("TODO! trigger sync for this file we just deleted... %s\n", path) + + return nil, err +} + +var ( + _ rest.Storage = (*readConnector)(nil) + _ rest.Connecter = (*readConnector)(nil) + _ rest.Scoper = (*readConnector)(nil) + _ rest.SingularNameProvider = (*readConnector)(nil) + _ rest.StorageMetadata = (*readConnector)(nil) +) diff --git a/pkg/registry/apis/provisioning/github.go b/pkg/registry/apis/provisioning/github.go index 6392fd96489..16f0d0c778f 100644 --- a/pkg/registry/apis/provisioning/github.go +++ b/pkg/registry/apis/provisioning/github.go @@ -113,6 +113,33 @@ func (r *githubRepository) Read(ctx context.Context, filePath string, commit str return []byte(data), nil } +func (r *githubRepository) Create(ctx context.Context, path string, data []byte, comment string) error { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: "write file is not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + +func (r *githubRepository) Update(ctx context.Context, path string, data []byte, comment string) error { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: "write file is not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + +func (r *githubRepository) Delete(ctx context.Context, path string, comment string) error { + return &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Message: "delete file not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + // Webhook implements provisioning.Repository. func (r *githubRepository) Webhook(responder rest.Responder) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { diff --git a/pkg/registry/apis/provisioning/local.go b/pkg/registry/apis/provisioning/local.go index def2473e5f9..42b65fa796e 100644 --- a/pkg/registry/apis/provisioning/local.go +++ b/pkg/registry/apis/provisioning/local.go @@ -2,12 +2,14 @@ package provisioning import ( "context" + "errors" + "fmt" "net/http" "os" "path/filepath" "strings" - "k8s.io/apimachinery/pkg/api/errors" + apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/registry/rest" @@ -105,7 +107,7 @@ func (r *localRepository) Validate() (fields field.ErrorList) { // Test implements provisioning.Repository. func (r *localRepository) Test(ctx context.Context) error { - return &errors.StatusError{ + return &apierrors.StatusError{ ErrStatus: v1.Status{ Message: "test is not yet implemented", Code: http.StatusNotImplemented, @@ -116,10 +118,10 @@ func (r *localRepository) Test(ctx context.Context) error { // ReadResource implements provisioning.Repository. func (r *localRepository) Read(ctx context.Context, path string, commit string) ([]byte, error) { if commit != "" { - return nil, errors.NewBadRequest("local repository does not support commits") + return nil, apierrors.NewBadRequest("local repository does not support commits") } if r.path == "" { - return nil, &errors.StatusError{ + return nil, &apierrors.StatusError{ ErrStatus: v1.Status{ Message: "the service is missing a root path", Code: http.StatusFailedDependency, @@ -131,6 +133,53 @@ func (r *localRepository) Read(ctx context.Context, path string, commit string) return os.ReadFile(filepath.Join(r.path, path)) } +func (r *localRepository) Create(ctx context.Context, path string, data []byte, comment string) error { + if r.path == "" { + return &apierrors.StatusError{ + ErrStatus: v1.Status{ + Message: "the service is missing a root path", + Code: http.StatusFailedDependency, + }, + } + } + + path = filepath.Join(r.path, path) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return os.WriteFile(filepath.Join(r.path, path), data, 0600) + } + return fmt.Errorf("file already exists") +} + +func (r *localRepository) Update(ctx context.Context, path string, data []byte, comment string) error { + if r.path == "" { + return &apierrors.StatusError{ + ErrStatus: v1.Status{ + Message: "the service is missing a root path", + Code: http.StatusFailedDependency, + }, + } + } + + path = filepath.Join(r.path, path) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("file does not exist") + } + return os.WriteFile(path, data, 0600) +} + +func (r *localRepository) Delete(ctx context.Context, path string, comment string) error { + if r.path == "" { + return &apierrors.StatusError{ + ErrStatus: v1.Status{ + Message: "the service is missing a root path", + Code: http.StatusFailedDependency, + }, + } + } + + return os.Remove(filepath.Join(r.path, path)) +} + // Webhook implements provisioning.Repository. func (r *localRepository) Webhook(responder rest.Responder) http.HandlerFunc { // webhooks are not supported with local diff --git a/pkg/registry/apis/provisioning/read.go b/pkg/registry/apis/provisioning/read.go deleted file mode 100644 index 0ccb849f783..00000000000 --- a/pkg/registry/apis/provisioning/read.go +++ /dev/null @@ -1,102 +0,0 @@ -package provisioning - -import ( - "bytes" - "context" - "fmt" - "net/http" - "strings" - - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/rest" - - "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" - provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" -) - -type readConnector struct { - getter RepoGetter -} - -func (*readConnector) New() runtime.Object { - // This is added as the "ResponseType" regardless what ProducesObject() returns - return &provisioning.ResourceWrapper{} -} - -func (*readConnector) Destroy() {} - -func (*readConnector) NamespaceScoped() bool { - return true -} - -func (*readConnector) GetSingularName() string { - return "Resource" -} - -func (*readConnector) ProducesMIMETypes(verb string) []string { - return []string{"application/json"} -} - -func (*readConnector) ProducesObject(verb string) any { - return &provisioning.ResourceWrapper{} -} - -func (*readConnector) ConnectMethods() []string { - return []string{http.MethodGet} -} - -func (*readConnector) NewConnectOptions() (runtime.Object, bool, string) { - return nil, true, "" // true adds the {path} component -} - -func (s *readConnector) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - repo, err := s.getter.GetRepository(ctx, name) - if err != nil { - return nil, err - } - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - idx := strings.Index(r.URL.Path, "/"+name+"/read") - filePath := strings.TrimLeft(r.URL.Path[idx+len(name+"/read")+1:], "/") - if filePath == "" { - responder.Error(errors.NewBadRequest("missing path")) - return - } - - commit := r.URL.Query().Get("commit") - data, err := repo.Read(ctx, filePath, commit) - if err != nil { - responder.Error(err) - return - } - - obj, gvk, err := LoadYAMLOrJSON(bytes.NewBuffer(data)) - if err != nil { - obj, gvk, err = FallbackResourceLoader(data) - if err != nil { - responder.Error(err) - return - } - } - - // TODO? dry run??? and add validation errors/warnings? - fmt.Printf("TODO, dryrun... %+v\n", gvk) - - responder.Object(200, &provisioning.ResourceWrapper{ - Path: filePath, - Commit: commit, - Resource: v0alpha1.Unstructured{ - Object: obj.Object, - }, - }) - }), nil -} - -var ( - _ rest.Storage = (*readConnector)(nil) - _ rest.Connecter = (*readConnector)(nil) - _ rest.Scoper = (*readConnector)(nil) - _ rest.SingularNameProvider = (*readConnector)(nil) - _ rest.StorageMetadata = (*readConnector)(nil) -) diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index d89e1e32c1a..c5ada2b3617 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -10,6 +10,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" @@ -127,7 +128,7 @@ func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserv storage[provisioning.RepositoryResourceInfo.StoragePath("webhook")] = &webhookConnector{ getter: b, } - storage[provisioning.RepositoryResourceInfo.StoragePath("read")] = &readConnector{ + storage[provisioning.RepositoryResourceInfo.StoragePath("file")] = &readConnector{ getter: b, } apiGroupInfo.VersionedResourcesStorageMap[provisioning.VERSION] = storage @@ -337,11 +338,11 @@ func (b *ProvisioningAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3. } // hide the version with no path - delete(oas.Paths.Paths, repoprefix+"/read") + delete(oas.Paths.Paths, repoprefix+"/file") // update the version with a path - sub = oas.Paths.Paths[repoprefix+"/read/{path}"] - if sub != nil && sub.Get != nil { + sub = oas.Paths.Paths[repoprefix+"/file/{path}"] + if sub != nil { sub.Get.Description = "Read value from upstream repository" sub.Get.Parameters = []*spec3.Parameter{ { @@ -355,6 +356,89 @@ func (b *ProvisioningAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3. }, }, } + + // Add message to the OpenAPI spec + comment := []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "message", + In: "query", + Example: "My commit message", + Description: "for git properties this will be in the commit message", + Schema: spec.StringProperty(), + Required: false, + }, + }, + } + sub.Delete.Parameters = comment + sub.Post.Parameters = comment + sub.Put.Parameters = comment + sub.Post.RequestBody = &spec3.RequestBody{ + RequestBodyProps: spec3.RequestBodyProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: spec.MapProperty(nil), + Example: &unstructured.Unstructured{}, + Examples: map[string]*spec3.Example{ + "dashboard": { + ExampleProps: spec3.ExampleProps{ + Value: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "hello": "dashboard", + }, + }, + }, + }, + }, + "playlist": { + ExampleProps: spec3.ExampleProps{ + Value: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "hello": "playlist", + }, + }, + }, + }, + }, + }, + }, + }, + "application/x-yaml": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: spec.MapProperty(nil), + Example: &unstructured.Unstructured{}, + Examples: map[string]*spec3.Example{ + "dashboard": { + ExampleProps: spec3.ExampleProps{ + Value: `apiVersion: dashboards.grafana.app/v0alpha1 +kind: Dashboard +spec: + title: Sample dashboard +`}, + }, + "playlist": { + ExampleProps: spec3.ExampleProps{ + Value: `apiVersion: playlist.grafana.app/v0alpha1 +kind: Playlist +spec: + title: Playlist from provisioning + interval: 5m + items: + - type: dashboard_by_tag + value: panel-tests +`}, + }, + }, + }, + }, + }, + }, + } + // POST and put have the same request + sub.Put.RequestBody = sub.Post.RequestBody } // The root API discovery list diff --git a/pkg/registry/apis/provisioning/s3.go b/pkg/registry/apis/provisioning/s3.go index 10b700d81e8..50a582937e4 100644 --- a/pkg/registry/apis/provisioning/s3.go +++ b/pkg/registry/apis/provisioning/s3.go @@ -5,7 +5,7 @@ import ( "net/http" "k8s.io/apimachinery/pkg/api/errors" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/registry/rest" @@ -43,7 +43,7 @@ func (r *s3Repository) Validate() (list field.ErrorList) { // Test implements provisioning.Repository. func (r *s3Repository) Test(ctx context.Context) error { return &errors.StatusError{ - ErrStatus: v1.Status{ + ErrStatus: metav1.Status{ Message: "test is not yet implemented", Code: http.StatusNotImplemented, }, @@ -53,13 +53,40 @@ func (r *s3Repository) Test(ctx context.Context) error { // ReadResource implements provisioning.Repository. func (r *s3Repository) Read(ctx context.Context, path string, commit string) ([]byte, error) { return nil, &errors.StatusError{ - ErrStatus: v1.Status{ + ErrStatus: metav1.Status{ Message: "read resource is not yet implemented", Code: http.StatusNotImplemented, }, } } +func (r *s3Repository) Create(ctx context.Context, path string, data []byte, comment string) error { + return &errors.StatusError{ + ErrStatus: metav1.Status{ + Message: "write file is not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + +func (r *s3Repository) Update(ctx context.Context, path string, data []byte, comment string) error { + return &errors.StatusError{ + ErrStatus: metav1.Status{ + Message: "write file is not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + +func (r *s3Repository) Delete(ctx context.Context, path string, comment string) error { + return &errors.StatusError{ + ErrStatus: metav1.Status{ + Message: "delete file not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + // Webhook implements provisioning.Repository. func (r *s3Repository) Webhook(responder rest.Responder) http.HandlerFunc { // webhooks are not supported with local diff --git a/pkg/registry/apis/provisioning/types.go b/pkg/registry/apis/provisioning/types.go index 93b0648a485..21c94cb5481 100644 --- a/pkg/registry/apis/provisioning/types.go +++ b/pkg/registry/apis/provisioning/types.go @@ -5,7 +5,7 @@ import ( "net/http" "k8s.io/apimachinery/pkg/api/errors" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/registry/rest" @@ -42,8 +42,20 @@ type Repository interface { Test(ctx context.Context) error // Read a file from the resource + // This data will be parsed and validated before it is shown to end users Read(ctx context.Context, path string, commit string) ([]byte, error) + // Write a file to the repository. + // The data has already been validated and is ready for save + Create(ctx context.Context, path string, data []byte, comment string) error + + // Update a file in the remote repository + // The data has already been validated and is ready for save + Update(ctx context.Context, path string, data []byte, comment string) error + + // Delete a file in the remote repository + Delete(ctx context.Context, path string, comment string) error + // For repositories that support webhooks Webhook(responder rest.Responder) http.HandlerFunc @@ -78,7 +90,7 @@ func (r *unknownRepository) Validate() (fields field.ErrorList) { // Test implements provisioning.Repository. func (r *unknownRepository) Test(ctx context.Context) error { return &errors.StatusError{ - ErrStatus: v1.Status{ + ErrStatus: metav1.Status{ Message: "test is not yet implemented", Code: http.StatusNotImplemented, }, @@ -88,13 +100,40 @@ func (r *unknownRepository) Test(ctx context.Context) error { // ReadResource implements provisioning.Repository. func (r *unknownRepository) Read(ctx context.Context, path string, commit string) ([]byte, error) { return nil, &errors.StatusError{ - ErrStatus: v1.Status{ + ErrStatus: metav1.Status{ Message: "read resource is not yet implemented", Code: http.StatusNotImplemented, }, } } +func (r *unknownRepository) Create(ctx context.Context, path string, data []byte, comment string) error { + return &errors.StatusError{ + ErrStatus: metav1.Status{ + Message: "write file is not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + +func (r *unknownRepository) Update(ctx context.Context, path string, data []byte, comment string) error { + return &errors.StatusError{ + ErrStatus: metav1.Status{ + Message: "write file is not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + +func (r *unknownRepository) Delete(ctx context.Context, path string, comment string) error { + return &errors.StatusError{ + ErrStatus: metav1.Status{ + Message: "delete file not yet implemented", + Code: http.StatusNotImplemented, + }, + } +} + // Webhook implements provisioning.Repository. func (r *unknownRepository) Webhook(responder rest.Responder) http.HandlerFunc { // webhooks are not supported with local diff --git a/pkg/tests/apis/provisioning/provisioning_test.go b/pkg/tests/apis/provisioning/provisioning_test.go index 21ed6ecb3ae..93c70968213 100644 --- a/pkg/tests/apis/provisioning/provisioning_test.go +++ b/pkg/tests/apis/provisioning/provisioning_test.go @@ -49,7 +49,7 @@ func TestIntegrationProvisioning(t *testing.T) { v1Disco, err := json.MarshalIndent(resources, "", " ") require.NoError(t, err) - //fmt.Printf("%s", string(v1Disco)) + // fmt.Printf("%s", string(v1Disco)) require.JSONEq(t, `{ "kind": "APIResourceList", "apiVersion": "v1", @@ -71,6 +71,18 @@ func TestIntegrationProvisioning(t *testing.T) { "watch" ] }, + { + "name": "repositories/file", + "singularName": "", + "namespaced": true, + "kind": "ResourceWrapper", + "verbs": [ + "create", + "delete", + "get", + "update" + ] + }, { "name": "repositories/hello", "singularName": "", @@ -80,15 +92,6 @@ func TestIntegrationProvisioning(t *testing.T) { "get" ] }, - { - "name": "repositories/read", - "singularName": "", - "namespaced": true, - "kind": "ResourceWrapper", - "verbs": [ - "get" - ] - }, { "name": "repositories/status", "singularName": "", @@ -154,6 +157,11 @@ func TestIntegrationProvisioning(t *testing.T) { require.JSONEq(t, `{ "github-example": { "description": "load resources from github", + "editing": { + "create": true, + "delete": true, + "update": true + }, "github": { "branch": "dummy-branch", "branchWorkflow": true, @@ -161,14 +169,19 @@ func TestIntegrationProvisioning(t *testing.T) { "owner": "grafana", "repository": "git-ui-sync-demo", "token": "github_pat_dummy", - "webhookURL": "https://dummyWebhookUrl/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/github-example/webhook", - "webhookSecret": "dummyWebhookSecret" + "webhookSecret": "dummyWebhookSecret", + "webhookURL": "https://dummyWebhookUrl/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/github-example/webhook" }, "title": "Github Example", "type": "github" }, "local-conf-provisioning-sample": { "description": "load resources from https://github.com/grafana/grafana/tree/main/conf/provisioning/sample", + "editing": { + "create": true, + "delete": true, + "update": true + }, "local": { "path": "provisioning/sample" }, @@ -177,6 +190,11 @@ func TestIntegrationProvisioning(t *testing.T) { }, "local-devenv": { "description": "load https://github.com/grafana/grafana/tree/main/devenv/dev-dashboards", + "editing": { + "create": true, + "delete": true, + "update": true + }, "local": { "path": "devenv/dev-dashboards" }, @@ -185,6 +203,11 @@ func TestIntegrationProvisioning(t *testing.T) { }, "s3-example": { "description": "load resources from an S3 bucket", + "editing": { + "create": false, + "delete": false, + "update": false + }, "s3": { "bucket": "my-bucket", "region": "us-west-1" diff --git a/pkg/tests/apis/provisioning/testdata/github-example.yaml b/pkg/tests/apis/provisioning/testdata/github-example.yaml index 3004ceaac8a..b5401bc38bc 100644 --- a/pkg/tests/apis/provisioning/testdata/github-example.yaml +++ b/pkg/tests/apis/provisioning/testdata/github-example.yaml @@ -6,6 +6,10 @@ spec: title: Github Example description: load resources from github type: github + editing: + create: true + update: true + delete: true github: owner: grafana repository: git-ui-sync-demo @@ -13,5 +17,5 @@ spec: branchWorkflow: true generateDashboardPreviews: true token: "github_pat_dummy" - webhookUrl: "https://dummyWebhookUrl/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/github-example/webhook" + webhookURL: "https://dummyWebhookUrl/apis/provisioning.grafana.app/v0alpha1/namespaces/default/repositories/github-example/webhook" webhookSecret: "dummyWebhookSecret" \ No newline at end of file diff --git a/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.yaml b/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.yaml index 5ad8917c9ac..3fab6889fad 100644 --- a/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.yaml +++ b/pkg/tests/apis/provisioning/testdata/local-conf-provisioning-sample.yaml @@ -6,5 +6,9 @@ spec: title: Config provisioning files description: load resources from https://github.com/grafana/grafana/tree/main/conf/provisioning/sample type: local + editing: + create: true + update: true + delete: true local: path: provisioning/sample diff --git a/pkg/tests/apis/provisioning/testdata/local-devenv.yaml b/pkg/tests/apis/provisioning/testdata/local-devenv.yaml index 60416bf309b..55d3b76cc01 100644 --- a/pkg/tests/apis/provisioning/testdata/local-devenv.yaml +++ b/pkg/tests/apis/provisioning/testdata/local-devenv.yaml @@ -5,6 +5,10 @@ metadata: spec: title: Load devenv dashboards description: load https://github.com/grafana/grafana/tree/main/devenv/dev-dashboards + editing: + create: true + update: true + delete: true type: local local: path: devenv/dev-dashboards