POC/Provisioning: Parse and validate objects from repo (#96822)

This commit is contained in:
Ryan McKinley authored and GitHub committed 2024-11-21 15:54:38 +03:00
1 parent 81b56561c0
commit 079f47221d
13 files changed
+347 -117

No files matched your search

+11 -2
View File
@@ -158,8 +158,17 @@ type ResourceWrapper struct {
// The modified time in the remote file system
Timestamp *metav1.Time `json:"timestamp,omitempty"`
// The raw resource body
Value common.Unstructured `json:"value"`
// If errors exist, show them here
Errors []string `json:"errors,omitempty"`
// Different flavors of the same object
Resource ResourceObjects `json:"resource"`
}
type ResourceObjects struct {
File common.Unstructured `json:"file,omitempty"`
Store common.Unstructured `json:"store,omitempty"`
DryRun common.Unstructured `json:"dryRun,omitempty"`
}
// Dummy object to return for webhooks
@@ -193,6 +193,25 @@ func (in *RepositoryStatus) DeepCopy() *RepositoryStatus {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResourceObjects) DeepCopyInto(out *ResourceObjects) {
*out = *in
in.File.DeepCopyInto(&out.File)
in.Store.DeepCopyInto(&out.Store)
in.DryRun.DeepCopyInto(&out.DryRun)
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceObjects.
func (in *ResourceObjects) DeepCopy() *ResourceObjects {
if in == nil {
return nil
}
out := new(ResourceObjects)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ResourceWrapper) DeepCopyInto(out *ResourceWrapper) {
*out = *in
@@ -201,7 +220,12 @@ func (in *ResourceWrapper) DeepCopyInto(out *ResourceWrapper) {
in, out := &in.Timestamp, &out.Timestamp
*out = (*in).DeepCopy()
}
in.Value.DeepCopyInto(&out.Value)
if in.Errors != nil {
in, out := &in.Errors, &out.Errors
*out = make([]string, len(*in))
copy(*out, *in)
}
in.Resource.DeepCopyInto(&out.Resource)
return
}
@@ -22,6 +22,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.RepositoryList": schema_pkg_apis_provisioning_v0alpha1_RepositoryList(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.RepositorySpec": schema_pkg_apis_provisioning_v0alpha1_RepositorySpec(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.RepositoryStatus": schema_pkg_apis_provisioning_v0alpha1_RepositoryStatus(ref),
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceObjects": schema_pkg_apis_provisioning_v0alpha1_ResourceObjects(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.WebhookResponse": schema_pkg_apis_provisioning_v0alpha1_WebhookResponse(ref),
@@ -367,6 +368,35 @@ func schema_pkg_apis_provisioning_v0alpha1_RepositoryStatus(ref common.Reference
}
}
func schema_pkg_apis_provisioning_v0alpha1_ResourceObjects(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"file": {
SchemaProps: spec.SchemaProps{
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
},
},
"store": {
SchemaProps: spec.SchemaProps{
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
},
},
"dryRun": {
SchemaProps: spec.SchemaProps{
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -415,18 +445,34 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceC
Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"),
},
},
"value": {
"errors": {
SchemaProps: spec.SchemaProps{
Description: "The raw resource body",
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured"),
Description: "If errors exist, show them here",
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
"resource": {
SchemaProps: spec.SchemaProps{
Description: "Different flavors of the same object",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceObjects"),
},
},
},
Required: []string{"value"},
Required: []string{"resource"},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.Unstructured", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"},
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1.ResourceObjects", "k8s.io/apimachinery/pkg/apis/meta/v1.Time"},
}
}
@@ -1 +1,2 @@
API rule violation: list_type_missing,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,ResourceWrapper,Errors
API rule violation: names_match,github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1,RepositorySpec,GitHub
+31 -16
View File
@@ -15,9 +15,6 @@ type resourceClient struct {
identities auth.BackgroundIdentityService
client map[string]*dynamic.DynamicClient
clientMu sync.Mutex
kinds map[schema.GroupVersionKind]schema.GroupVersionResource
kindMu sync.Mutex
}
func newResourceClient(identities auth.BackgroundIdentityService) *resourceClient {
@@ -49,22 +46,40 @@ func (c *resourceClient) Client(ns string) (*dynamic.DynamicClient, error) {
return client, nil
}
func (c *resourceClient) GVR(ns string, gvk schema.GroupVersionKind) (schema.GroupVersionResource, bool) {
type kindsLookup struct {
client *dynamic.DynamicClient
kinds map[schema.GroupVersionKind]schema.GroupVersionResource
kindMu sync.Mutex
}
func newKindsLookup(client *dynamic.DynamicClient) *kindsLookup {
return &kindsLookup{
client: client,
kinds: make(map[schema.GroupVersionKind]schema.GroupVersionResource),
}
}
func (c *kindsLookup) Resource(gvk schema.GroupVersionKind) (schema.GroupVersionResource, bool) {
c.kindMu.Lock()
defer c.kindMu.Unlock()
// TODO... this needs to find the right GVR for a GVK
ok := true
var gvr schema.GroupVersionResource
switch gvk.Kind {
case "Dashboard":
gvr = gvk.GroupVersion().WithResource("dashboards")
case "Playlist":
gvr = gvk.GroupVersion().WithResource("playlists")
case "Folder":
gvr = gvk.GroupVersion().WithResource(folder.RESOURCE)
default:
ok = false
gvr, ok := c.kinds[gvk]
if !ok {
ok = true
switch gvk.Kind {
case "Dashboard":
gvr = gvk.GroupVersion().WithResource("dashboards")
case "Playlist":
gvr = gvk.GroupVersion().WithResource("playlists")
case "Folder":
gvr = gvk.GroupVersion().WithResource(folder.RESOURCE)
default:
ok = false
}
// TODO... use the client to get the resource!
if ok {
c.kinds[gvk] = gvr
}
}
return gvr, ok
+50 -56
View File
@@ -1,9 +1,7 @@
package provisioning
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -11,18 +9,15 @@ import (
"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 filesConnector struct {
getter RepoGetter
client *resourceClient
}
func (*filesConnector) New() runtime.Object {
@@ -93,9 +88,10 @@ func (s *filesConnector) Connect(ctx context.Context, name string, opts runtime.
ref := r.URL.Query().Get("ref")
message := r.URL.Query().Get("message")
code := http.StatusOK
switch r.Method {
case http.MethodGet:
obj, err = s.doRead(r.Context(), repo, filePath, ref)
obj, err, code = s.doRead(r.Context(), repo, filePath, ref)
case http.MethodPost:
obj, err = s.doWrite(r.Context(), false, repo, filePath, ref, message, r)
case http.MethodPut:
@@ -110,44 +106,48 @@ func (s *filesConnector) Connect(ctx context.Context, name string, opts runtime.
responder.Error(err)
return
}
responder.Object(200, obj)
responder.Object(code, obj)
}), nil
}
func (s *filesConnector) 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
}
func (s *filesConnector) getParser(repo Repository) (*fileParser, error) {
ns := repo.Config().Namespace
if ns == "" {
return nil, fmt.Errorf("missing namespace")
}
// TODO? dry run??? and add validation errors/warnings?
fmt.Printf("TODO, get client and then dry run... %+v\n", gvk)
return obj, gvk, err
client, err := s.client.Client(ns) // As system user
if err != nil {
return nil, err
}
return &fileParser{
namespace: ns,
repo: repo,
client: client,
kinds: newKindsLookup(client),
}, nil
}
func (s *filesConnector) doRead(ctx context.Context, repo Repository, path string, ref string) (*provisioning.ResourceWrapper, error) {
func (s *filesConnector) doRead(ctx context.Context, repo Repository, path string, ref string) (*provisioning.ResourceWrapper, error, int) {
info, err := repo.Read(ctx, path, ref)
if err != nil {
return nil, err
return nil, err, 0
}
obj, _, err := s.getValidatedBody(ctx, info.Data)
parser, err := s.getParser(repo)
if err != nil {
return nil, err
return nil, err, 0
}
return &provisioning.ResourceWrapper{
Path: info.Path,
Ref: info.Ref,
Hash: info.Hash,
Timestamp: info.Modified,
Value: v0alpha1.Unstructured{
Object: obj.Object,
},
}, nil
parsed, err := parser.parse(ctx, info, true)
if err != nil {
return nil, err, 0
}
code := http.StatusOK
if len(parsed.errors) > 0 {
code = http.StatusNotAcceptable
}
return parsed.AsResourceWrapper(), nil, code
}
func (s *filesConnector) doWrite(ctx context.Context, update bool, repo Repository, path string, ref string, message string, req *http.Request) (runtime.Object, error) {
@@ -158,37 +158,31 @@ func (s *filesConnector) doWrite(ctx context.Context, update bool, repo Reposito
return nil, errors.NewForbidden(provisioning.RepositoryResourceInfo.GroupResource(), "creating files not enabled", nil)
}
defer req.Body.Close()
defer func() { _ = req.Body.Close() }()
data, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
obj, _, err := s.getValidatedBody(ctx, data)
info := &FileInfo{
Data: data,
Path: path,
Ref: ref,
}
parser, err := s.getParser(repo)
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
}
parsed, err := parser.parse(ctx, info, true)
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")
data, err = parsed.ToSaveBytes()
if err != nil {
return nil, err
}
if update {
@@ -200,9 +194,9 @@ func (s *filesConnector) doWrite(ctx context.Context, update bool, repo Reposito
return nil, err
}
fmt.Printf("TODO! trigger sync for this file we just wrote... %s\n", path)
fmt.Printf("TODO! write the file into real storage %s\n", path)
return obj, err
return parsed.obj, err
}
func (s *filesConnector) doDelete(ctx context.Context, repo Repository, path string, ref string, message string) (runtime.Object, error) {
-2
View File
@@ -36,7 +36,6 @@ func newGithubRepository(ctx context.Context, config *provisioning.Repository) *
githubClient: githubClient,
logger: slog.Default().With("logger", "github-repository"),
}
}
func (r *githubRepository) Config() *provisioning.Repository {
@@ -374,7 +373,6 @@ func (r *githubRepository) updateWebhook(ctx context.Context, oldRepo *githubRep
r.logger.Info("webhook secret reverted", "url", oldCfg.WebhookURL)
return nil
}, nil
}
}
-33
View File
@@ -2,17 +2,14 @@ package provisioning
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
@@ -94,36 +91,6 @@ func (s *helloWorldSubresource) Connect(ctx context.Context, name string, opts r
slog.InfoContext(ctx, "the conspicuous boolean", "bool", b)
}
write := r.URL.Query().Get("list")
if write != "" {
gvr, ok := s.parent.client.GVR(repo.Namespace, schema.GroupVersionKind{
Group: v1alpha1.GROUP, // dashboard.grafana.app
Version: v1alpha1.VERSION, // "v0alpha1",
Kind: "Dashboard",
})
if !ok {
responder.Error(fmt.Errorf("no dashboard gvr"))
return
}
client, err := s.parent.client.Client(repo.Namespace)
if err != nil {
responder.Error(err)
return
}
ttt, err := client.Resource(gvr).Namespace(repo.Namespace).List(ctx, metav1.ListOptions{})
if err != nil {
responder.Error(err)
return
}
// responder not happy flipping types
out, _ := json.Marshal(ttt)
w.Write(out)
return
}
slog.InfoContext(ctx, "Got a repository",
"name", repo.Name,
"type", repo.Spec.Type,
+172
View File
@@ -0,0 +1,172 @@
package provisioning
import (
"bytes"
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer/yaml"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1"
)
type fileParser struct {
// Reader is always in the context of a single namespace
namespace string
// The target repository
repo Repository
// client helper (for this namespace?)
client *dynamic.DynamicClient
kinds *kindsLookup
}
type parsedFile struct {
// Original file info
info *FileInfo
// Parsed contents
obj *unstructured.Unstructured
// The Kind is defined in the file
gvk *schema.GroupVersionKind
// The Resource is found by mapping Kind to the right apiserver
gvr *schema.GroupVersionResource
// Client that can talk to this resource
client dynamic.ResourceInterface
// The existing object (same name)
// ?? do we need/want the whole thing??
existing *unstructured.Unstructured
// The results from dry run
dryRunResponse *unstructured.Unstructured
// If we got some errors
errors []error
}
func (r *fileParser) parse(ctx context.Context, info *FileInfo, validate bool) (*parsedFile, error) {
obj, gvk, err := LoadYAMLOrJSON(bytes.NewBuffer(info.Data))
if err != nil {
obj, gvk, err = FallbackResourceLoader(info.Data)
if err != nil {
return nil, err
}
}
parsed := &parsedFile{
info: info,
obj: obj,
gvk: gvk,
}
if r.namespace == "" {
return nil, fmt.Errorf("parser is not configured (missing namespace)")
}
// Validate the namespace
if obj.GetNamespace() != "" && obj.GetNamespace() != r.namespace {
parsed.errors = append(parsed.errors, ErrNamespaceMismatch)
}
obj.SetNamespace(r.namespace)
// When name is missing use the file path as the k8s name
if obj.GetName() == "" {
name := filepath.Base(info.Path)
suffix := filepath.Ext(name)
if suffix != "" {
name = strings.TrimSuffix(name, suffix)
}
obj.SetName(name)
}
// We can not do anything more if no kind is defined
if gvk == nil {
return parsed, nil
}
gvr, ok := r.kinds.Resource(*gvk)
if !ok {
return parsed, nil
}
client := r.client.Resource(gvr).Namespace(r.namespace)
parsed.gvr = &gvr
parsed.client = client
if !validate {
return parsed, nil
}
// Dry run CREATE or UPDATE
parsed.existing, _ = parsed.client.Get(ctx, obj.GetName(), metav1.GetOptions{})
if parsed.existing == nil {
parsed.dryRunResponse, err = parsed.client.Create(ctx, obj, metav1.CreateOptions{
DryRun: []string{"All"},
})
} else {
parsed.dryRunResponse, err = parsed.client.Update(ctx, obj, metav1.UpdateOptions{
DryRun: []string{"All"},
})
}
if err != nil {
parsed.errors = append(parsed.errors, err)
}
return parsed, nil
}
func (f *parsedFile) ToSaveBytes() ([]byte, error) {
// TODO... should use the validated one?
obj := f.obj
switch filepath.Ext(f.info.Path) {
// JSON pretty print
case ".json":
return json.MarshalIndent(obj, "", " ")
// 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
}
return buff.Bytes(), nil
default:
return nil, fmt.Errorf("unexpected format")
}
}
func (f *parsedFile) AsResourceWrapper() *provisioning.ResourceWrapper {
info := f.info
res := provisioning.ResourceObjects{}
if f.obj != nil {
res.File = v0alpha1.Unstructured{Object: f.obj.Object}
}
if f.existing != nil {
res.Store = v0alpha1.Unstructured{Object: f.existing.Object}
}
if f.dryRunResponse != nil {
res.DryRun = v0alpha1.Unstructured{Object: f.dryRunResponse.Object}
}
wrap := &provisioning.ResourceWrapper{
Path: info.Path,
Ref: info.Ref,
Hash: info.Hash,
Timestamp: info.Modified,
Resource: res,
}
for _, err := range f.errors {
wrap.Errors = append(wrap.Errors, err.Error())
}
return wrap
}
@@ -158,6 +158,7 @@ func (b *ProvisioningAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserv
}
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = &filesConnector{
getter: b,
client: b.client,
}
storage[provisioning.RepositoryResourceInfo.StoragePath("export")] = exportConnector
apiGroupInfo.VersionedResourcesStorageMap[provisioning.VERSION] = storage
+3 -1
View File
@@ -10,6 +10,8 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer/yaml"
yamlutil "k8s.io/apimachinery/pkg/util/yaml"
"github.com/grafana/grafana/pkg/apis/dashboard"
)
func FallbackResourceLoader(data []byte) (*unstructured.Unstructured, *schema.GroupVersionKind, error) {
@@ -36,7 +38,7 @@ func FallbackResourceLoader(data []byte) (*unstructured.Unstructured, *schema.Gr
value["schemaVersion"] != nil &&
value["tags"] != nil {
gvk := &schema.GroupVersionKind{
Group: "dashboards.grafana.app",
Group: dashboard.GROUP,
Version: "v0alpha1",
Kind: "Dashboard"}
return &unstructured.Unstructured{
+1 -1
View File
@@ -71,7 +71,7 @@ spec:
require.NoError(t, err)
require.Equal(t, &schema.GroupVersionKind{
Group: "dashboards.grafana.app",
Group: "dashboard.grafana.app",
Version: "v0alpha1",
Kind: "Dashboard",
}, gvk)
+1
View File
@@ -524,6 +524,7 @@ func (s *service) GetRestConfigForBackgroundWorker(requester func() identity.Req
if err := s.NamedService.AwaitRunning(req.Context()); err != nil {
return nil, err
}
fmt.Printf("WORKER: %s/%v\n", req.Method, req.URL)
ctx := identity.WithRequester(context.Background(), requester())
wrapped := grafanaresponsewriter.WrapHandler(s.handler)
return wrapped(req.WithContext(ctx))