-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathmanifest.go
More file actions
1407 lines (1299 loc) · 53 KB
/
manifest.go
File metadata and controls
1407 lines (1299 loc) · 53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package app
import (
"encoding/json"
"errors"
"fmt"
"maps"
"math"
"slices"
"strings"
"github.com/getkin/kin-openapi/openapi3"
"github.com/hashicorp/go-multierror"
"gopkg.in/yaml.v3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
)
const (
OpenAPIExtensionPrefix = "x-grafana-app"
OpenAPIExtensionUsesKubernetesObjectMeta = OpenAPIExtensionPrefix + "-uses-kubernetes-object-metadata"
OpenAPIExtensionUsesKubernetesListMeta = OpenAPIExtensionPrefix + "-uses-kubernetes-list-metadata"
ManifestRolePermissionSetViewer = "viewer"
ManifestRolePermissionSetEditor = "editor"
ManifestRolePermissionSetAdmin = "admin"
)
// NewEmbeddedManifest returns a Manifest which has the ManifestData embedded in it
func NewEmbeddedManifest(manifestData ManifestData) Manifest {
return Manifest{
Location: ManifestLocation{
Type: ManifestLocationEmbedded,
},
ManifestData: &manifestData,
}
}
// NewOnDiskManifest returns a Manifest which points to a path on-disk to load ManifestData from
func NewOnDiskManifest(path string) Manifest {
return Manifest{
Location: ManifestLocation{
Type: ManifestLocationFilePath,
Path: path,
},
}
}
// NewAPIServerManifest returns a Manifest which points to a resource in an API server to load the ManifestData from
func NewAPIServerManifest(resourceName string) Manifest {
return Manifest{
Location: ManifestLocation{
Type: ManifestLocationAPIServerResource,
Path: resourceName,
},
}
}
// Manifest is a type which represents the Location and Data in an App Manifest.
type Manifest struct {
// ManifestData must be present if Location.Type == "embedded"
ManifestData *ManifestData
// Location indicates the place where the ManifestData should be loaded from
Location ManifestLocation
}
// ManifestLocation contains information of where a Manifest's ManifestData can be found.
type ManifestLocation struct {
Type ManifestLocationType
// Path is the path to the manifest, based on location.
// For "filepath", it is the path on disk. For "apiserver", it is the NamespacedName. For "embedded", it is empty.
Path string
}
type ManifestLocationType string
const (
ManifestLocationFilePath = ManifestLocationType("filepath")
ManifestLocationAPIServerResource = ManifestLocationType("apiserver")
ManifestLocationEmbedded = ManifestLocationType("embedded")
)
// ManifestData is the data in a Manifest, representing the Kinds and Capabilities of an App.
// NOTE: ManifestData is still experimental and subject to change
type ManifestData struct {
// AppName is the unique identifier for the App
AppName string `json:"appName" yaml:"appName"`
// AppDisplayName is the human-readable display name of the app. Unlike the AppName, any printable characters are allowed in this field
AppDisplayName string `json:"appDisplayName" yaml:"appDisplayName"`
// Group is the group used for all kinds maintained by this app.
// This is usually "<AppName>.ext.grafana.com"
Group string `json:"group" yaml:"group"`
// Versions is a list of versions supported by this App
Versions []ManifestVersion `json:"versions" yaml:"versions"`
// PreferredVersion is the preferred version for API use. If empty, it will use the latest from versions.
// For CRDs, this also dictates which version is used for storage.
PreferredVersion string `json:"preferredVersion" yaml:"preferredVersion"`
// Permissions is the extra permissions for non-owned kinds this app needs to operate its backend.
// It may be nil if no extra permissions are required.
ExtraPermissions *Permissions `json:"extraPermissions,omitempty" yaml:"extraPermissions,omitempty"`
// Operator has information about the operator being run for the app, if there is one.
// When present, it can indicate to the API server the URL and paths for webhooks, if applicable.
// This is only required if you run your app as an operator and any of your kinds support webhooks for validation,
// mutation, or conversion.
Operator *ManifestOperatorInfo `json:"operator,omitempty" yaml:"operator,omitempty"`
// Roles contains information for new user roles associated with this app.
// It is a map of the role name (e.g. "dashboard:reader") to the set of permissions on resources managed by this app.
Roles map[string]ManifestRole `json:"roles,omitempty" yaml:"roles,omitempty"`
// RoleBindings binds the roles specified in Roles to groups.
// Basic groups are "anonymous", "viewer", "editor", and "admin".
// At this time, only these are supported.
RoleBindings *ManifestRoleBindings `json:"roleBindings,omitempty" yaml:"roleBindings,omitempty"`
}
func (m *ManifestData) IsEmpty() bool {
return m.AppName == "" && m.Group == "" && len(m.Versions) == 0 && m.PreferredVersion == "" && m.ExtraPermissions == nil && m.Operator == nil
}
// Validate validates the ManifestData to ensure that the kind data across all Versions is consistent
//
//nolint:gocognit
func (m *ManifestData) Validate() error {
type kindData struct {
kind string
plural string
scope string
conversion bool
version string
}
var errs error
kinds := make(map[string]kindData)
for _, version := range m.Versions {
namespacedRoutes := make(map[string]struct{})
clusterRoutes := make(map[string]struct{})
for _, kind := range version.Kinds {
if kind.Scope == "Cluster" {
clusterRoutes[strings.ToLower(kind.Plural)] = struct{}{}
} else {
namespacedRoutes[strings.ToLower(kind.Plural)] = struct{}{}
}
if k, ok := kinds[kind.Kind]; !ok {
k = kindData{
kind: kind.Kind,
plural: kind.Plural,
scope: kind.Scope,
conversion: kind.Conversion,
version: version.Name,
}
kinds[kind.Kind] = k
} else {
if k.plural != kind.Plural {
errs = multierror.Append(errs, fmt.Errorf("kind '%s' has a different plural in versions '%s' and '%s'", kind.Kind, k.version, version.Name))
}
if k.scope != kind.Scope {
errs = multierror.Append(errs, fmt.Errorf("kind '%s' has a different scope in versions '%s' and '%s'", kind.Kind, k.version, version.Name))
}
if k.conversion != kind.Conversion {
errs = multierror.Append(errs, fmt.Errorf("kind '%s' conversion does not match in versions '%s' and '%s'", kind.Kind, k.version, version.Name))
}
}
}
for rpath := range version.Routes.Namespaced {
key := strings.Trim(strings.ToLower(rpath), "/")
if _, ok := namespacedRoutes[key]; ok {
errs = multierror.Append(errs, fmt.Errorf("namespaced custom route '%s' conflicts with already-registered kind '%s'", rpath, key))
}
}
for rpath := range version.Routes.Cluster {
key := strings.Trim(strings.ToLower(rpath), "/")
if _, ok := clusterRoutes[key]; ok {
errs = multierror.Append(errs, fmt.Errorf("cluster-scoped custom route '%s' conflicts with already-registered kind '%s'", rpath, key))
}
}
}
// checkRoleBinding adds to `errs` if there are issues with a rolebinding
// it exists to avoid duplicating this code for each rolebinding
checkRoleBinding := func(rb string, boundRoles []string) {
for _, role := range boundRoles {
role = strings.Trim(role, " ")
if role == "" {
continue
}
if m.Roles == nil {
errs = multierror.Append(errs, fmt.Errorf("%s: cannot bind role '%s' as no roles are present in the manifest", rb, role))
continue
}
if _, ok := m.Roles[role]; !ok {
errs = multierror.Append(errs, fmt.Errorf("%s: cannot bind role '%s' as it is not present in manifest roles", rb, role))
}
}
}
if m.RoleBindings != nil {
if len(m.RoleBindings.Viewer) > 0 {
checkRoleBinding("viewer", m.RoleBindings.Viewer)
}
if len(m.RoleBindings.Editor) > 0 {
checkRoleBinding("editor", m.RoleBindings.Editor)
}
if len(m.RoleBindings.Admin) > 0 {
checkRoleBinding("admin", m.RoleBindings.Admin)
}
for k, v := range m.RoleBindings.Additional {
if len(v) > 0 {
checkRoleBinding(k, v)
}
}
}
return errs
}
// Kinds returns a list of ManifestKinds parsed from Versions, for compatibility with kind-centric usage
//
// Deprecated: this exists to support current workflows, and should not be used for new ones.
func (m *ManifestData) Kinds() []ManifestKind {
kinds := make(map[string]ManifestKind)
for _, version := range m.Versions {
for _, kind := range version.Kinds {
k, ok := kinds[kind.Kind]
if !ok {
k = ManifestKind{
Kind: kind.Kind,
Plural: kind.Plural,
Scope: kind.Scope,
Conversion: kind.Conversion,
Versions: make([]ManifestKindVersion, 0),
}
}
k.Versions = append(k.Versions, ManifestKindVersion{
ManifestVersionKind: kind,
VersionName: version.Name,
})
kinds[kind.Kind] = k
}
}
k := make([]ManifestKind, 0, len(kinds))
for _, kind := range kinds {
k = append(k, kind)
}
return k
}
// ManifestKind is the manifest for a particular kind, including its Kind, Scope, and Versions.
// The values for Kind, Plural, Scope, and Conversion are hoisted up from their namesakes in Versions entries
//
// Deprecated: this is used only for the deprecated method ManifestData.Kinds()
type ManifestKind struct {
// Kind is the name of the kind
Kind string `json:"kind" yaml:"kind"`
// Scope if the scope of the kind, typically restricted to "Namespaced" or "Cluster"
Scope string `json:"scope" yaml:"scope"`
// Plural is the plural of the kind
Plural string `json:"plural" yaml:"plural"`
// Versions is the set of versions for the kind. This list should be ordered as a series of progressively later versions.
Versions []ManifestKindVersion `json:"versions" yaml:"versions"`
// Conversion is true if the app has a conversion capability for this kind
Conversion bool `json:"conversion" yaml:"conversion"`
}
// ManifestKindVersion is an extension on ManifestVersionKind that adds the version name
//
// Deprecated: this type if used only as part of the deprecated method ManifestData.Kinds()
type ManifestKindVersion struct {
ManifestVersionKind `json:",inline" yaml:",inline"`
VersionName string `json:"versionName" yaml:"versionName"`
}
type ManifestVersion struct {
// Name is the version name string, such as "v1" or "v1alpha1"
Name string `json:"name" yaml:"name"`
// Served dictates whether this version is served by the API server.
// A version cannot be removed from a manifest until it is no longer served.
Served bool `json:"served" yaml:"served"`
// Kinds is a list of all the kinds served in this version.
// Generally, kinds should exist in each version unless they have been deprecated (and no longer exist in a newer version)
// or newly added (and didn't exist for older versions).
Kinds []ManifestVersionKind `json:"kinds" yaml:"kinds"`
// Routes is a map of path patterns to custom routes for this version.
// Routes should not conflict with the plural name of any kinds for this version.
Routes ManifestVersionRoutes `json:"routes,omitempty" yaml:"routes,omitempty"`
}
type ManifestVersionRoutes struct {
Namespaced map[string]spec3.PathProps `json:"namespaced,omitempty" yaml:"namespaced,omitempty"`
Cluster map[string]spec3.PathProps `json:"cluster,omitempty" yaml:"cluster,omitempty"`
// Schemas is the map of #/components/schemas references used by requests and responses in Namespaced and Cluster.
Schemas map[string]spec.Schema `json:"schemas,omitempty" yaml:"schemas,omitempty"`
}
// ManifestVersionKind contains details for a version of a kind in a Manifest
type ManifestVersionKind struct {
// Kind is the name of the kind. This should begin with a capital letter and be CamelCased
Kind string `json:"kind" yaml:"kind"`
// Plural is the plural version of `kind`. This is optional and defaults to the kind + "s" if not present.
Plural string `json:"plural,omitempty" yaml:"plural,omitempty"`
// Scope dictates the scope of the kind. This field must be the same for all versions of the kind.
// Different values will result in an error or undefined behavior.
Scope string `json:"scope" yaml:"scope"`
// Admission is the collection of admission capabilities for this version.
// If nil, no admission capabilities exist for the version.
Admission *AdmissionCapabilities `json:"admission,omitempty" yaml:"admission,omitempty"`
// Schema is the schema of this version, as an OpenAPI document.
// This is currently an `any` type as implementation is incomplete.
Schema *VersionSchema `json:"schema,omitempty" yaml:"schema,omitempty"`
// SelectableFields are the set of JSON paths in the schema which can be used as field selectors
SelectableFields []string `json:"selectableFields,omitempty" yaml:"selectableFields,omitempty"`
// Routes is a map of path patterns to custom routes for this kind to be used as custom subresource routes.
Routes map[string]spec3.PathProps `json:"routes,omitempty" yaml:"routes,omitempty"`
// Conversion indicates whether this kind supports custom conversion behavior exposed by the Convert method in the App.
// It may not prevent automatic conversion behavior between versions of the kind when set to false
// (for example, CRDs will always support simple conversion, and this flag enables webhook conversion).
// This field should be the same for all versions of the kind. Different values will result in an error or undefined behavior.
Conversion bool `json:"conversion" yaml:"conversion"`
AdditionalPrinterColumns []ManifestVersionKindAdditionalPrinterColumn `json:"additionalPrinterColumns,omitempty" yaml:"additionalPrinterColumns,omitempty"`
}
// Subresources returns a list of all (stored) subresources for the kind.
// The list of subresources will not include "spec" or "metadata" as they are not subresources.
// Routes for the kind (subresource routes) will also not be included, as they are not stored.
//
//nolint:goconst
func (m *ManifestVersionKind) Subresources() []string {
if m.Schema == nil {
return []string{}
}
mp := m.Schema.AsOpenAPI3SchemasMap()
k, ok := mp[m.Kind]
if !ok {
return []string{}
}
cast, ok := k.(map[string]any)
if !ok {
return []string{}
}
props, ok := cast["properties"]
if !ok {
return []string{}
}
cast, ok = props.(map[string]any)
if !ok {
return []string{}
}
subresources := make([]string, 0)
for k := range cast {
if k == "spec" || k == "metadata" || k == "apiVersion" || k == "kind" {
continue
}
subresources = append(subresources, k)
}
return subresources
}
type ManifestVersionKindAdditionalPrinterColumn struct {
// name is a human readable name for the column.
Name string `json:"name"`
// type is an OpenAPI type definition for this column.
// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for details.
Type string `json:"type"`
// format is an optional OpenAPI type definition for this column. The 'name' format is applied
// to the primary identifier column to assist in clients identifying column is the resource name.
// See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types for details.
Format string `json:"format,omitempty"`
// description is a human readable description of this column.
Description string `json:"description,omitempty"`
// priority is an integer defining the relative importance of this column compared to others. Lower
// numbers are considered higher priority. Columns that may be omitted in limited space scenarios
// should be given a priority greater than 0.
Priority *int32 `json:"priority,omitempty"`
// jsonPath is a simple JSON path (i.e. with array notation) which is evaluated against
// each custom resource to produce the value for this column.
JSONPath string `json:"jsonPath"`
}
const parsedCRDSchemaKindName = "__KIND__"
// AdmissionCapabilities is the collection of admission capabilities of a kind
type AdmissionCapabilities struct {
// Validation contains the validation capability details. If nil, the kind does not have a validation capability.
Validation *ValidationCapability `json:"validation,omitempty" yaml:"validation,omitempty"`
// Mutation contains the mutation capability details. If nil, the kind does not have a mutation capability.
Mutation *MutationCapability `json:"mutation,omitempty" yaml:"mutation,omitempty"`
}
// SupportsAnyValidation returns true if the list of operations for validation is not empty.
// This is a convenience method to avoid having to make several nil and length checks.
func (c AdmissionCapabilities) SupportsAnyValidation() bool {
if c.Validation == nil {
return false
}
return len(c.Validation.Operations) > 0
}
// SupportsAnyMutation returns true if the list of operations for mutation is not empty.
// This is a convenience method to avoid having to make several nil and length checks.
func (c AdmissionCapabilities) SupportsAnyMutation() bool {
if c.Mutation == nil {
return false
}
return len(c.Mutation.Operations) > 0
}
// ValidationCapability is the details of a validation capability for a kind's admission control
type ValidationCapability struct {
// Operations is the list of operations that the validation capability is used for.
// If this list if empty or nil, this is equivalent to the app having no validation capability.
Operations []AdmissionOperation `json:"operations,omitempty" yaml:"operations,omitempty"`
}
// MutationCapability is the details of a mutation capability for a kind's admission control
type MutationCapability struct {
// Operations is the list of operations that the mutation capability is used for.
// If this list if empty or nil, this is equivalent to the app having no mutation capability.
Operations []AdmissionOperation `json:"operations,omitempty" yaml:"operations,omitempty"`
}
type AdmissionOperation string
const (
AdmissionOperationAny AdmissionOperation = "*"
AdmissionOperationCreate AdmissionOperation = "CREATE"
AdmissionOperationUpdate AdmissionOperation = "UPDATE"
AdmissionOperationDelete AdmissionOperation = "DELETE"
AdmissionOperationConnect AdmissionOperation = "CONNECT"
)
type Permissions struct {
AccessKinds []KindPermission `json:"accessKinds,omitempty" yaml:"accessKinds,omitempty"`
}
type KindPermissionAction string
type KindPermission struct {
Group string `json:"group" yaml:"group"`
Resource string `json:"resource" yaml:"resource"`
Actions []KindPermissionAction `json:"actions,omitempty" yaml:"actions,omitempty"`
}
// ManifestOperatorInfo contains information on the app's Operator deployment (if a deployment exists).
// This is primarily used to specify the location of webhook endpoints for the app.
type ManifestOperatorInfo struct {
URL string `json:"url" yaml:"url"`
Webhooks *ManifestOperatorWebhookProperties `json:"webhooks,omitempty" yaml:"webhooks,omitempty"`
}
// ManifestOperatorWebhookProperties contains information on webhook paths for an app's operator deployment.
type ManifestOperatorWebhookProperties struct {
ConversionPath string `json:"conversionPath" yaml:"conversionPath"`
ValidationPath string `json:"validationPath" yaml:"validationPath"`
MutationPath string `json:"mutationPath" yaml:"mutationPath"`
}
// ManifestRole describes a role used in the ManifestData Roles map.
// A ManifestRole consists of a PermissionSet (such as "editor") and a set of versions with kinds to apply that permission set to.
type ManifestRole struct {
Title string `json:"title" yaml:"title"`
Description string `json:"description" yaml:"description"`
Kinds []ManifestRoleKind `json:"kinds" yaml:"kinds"`
// Routes is a list of route names to match.
// To match the same route in multiple versions, it should share the same name.
Routes []string `json:"routes" yaml:"routes"`
}
// ManifestRoleKind is an association between a kind and a set of permissions
type ManifestRoleKind struct {
// Kind is the kind name
Kind string `json:"kind" yaml:"kind"`
// Verbs is a list of kubernetes verbs (get, list, watch, create, update, patch, delete, deletecollection).
// It is mutually exclusive with PermissionSet
Verbs []string `json:"verbs,omitempty" yaml:"verbs,omitempty"`
// PermissionSet is a permission set (viewer, editor, admin) to associate with the kind.
// It is mutually exclusive with Verbs
PermissionSet *string `json:"permissionSet,omitempty" yaml:"permissionSet,omitempty"`
}
// ManifestRoleBindings is the set of RoleBindings for ManifestData.RoleBindings.
// It binds a grafana group to one or more roles as described by ManifestData.Roles (or other role strings defined by other apps).
type ManifestRoleBindings struct {
// Viewer sets the role(s) granted to users in the "viewer" group
Viewer []string `json:"viewer" yaml:"viewer"`
// Editor sets the role(s) granted to users in the "editor" group
Editor []string `json:"editor" yaml:"editor"`
// Admin sets the role(s) granted to users in the "admin" group
Admin []string `json:"admin" yaml:"admin"`
// Additional is a map of additional group strings to their associated roles
Additional map[string][]string `json:"additional,omitempty" yaml:"additional,omitempty"`
}
// VersionSchemaFromMap accepts an OpenAPI-shaped map[string]any, where the contents of the map are either a full openAPI document
// with a components.schemas section, or just the components.schemas section of an openAPI document.
// The schemas must contain a schema with a name which matches the kindName provided.
func VersionSchemaFromMap(openAPISchema map[string]any, kindName string) (*VersionSchema, error) {
vs := &VersionSchema{
raw: openAPISchema,
}
err := vs.fixRaw()
// replace parsedCRDSchemaKindName with the kindName
if _, ok := vs.raw[parsedCRDSchemaKindName]; ok {
vs.raw[kindName] = vs.raw[parsedCRDSchemaKindName]
delete(vs.raw, parsedCRDSchemaKindName)
}
if _, ok := vs.raw[kindName]; !ok && len(vs.raw) > 0 {
return nil, fmt.Errorf("kind %q not found in map openAPI components", kindName)
}
return vs, err
}
// VersionSchema represents the schema of a KindVersion in a Manifest.
// It allows retrieval of the schema in a variety of ways, and can be unmarshaled from a CRD's version schema,
// an OpenAPI document for a kind, or from just the schemas component of an openAPI document.
// It marshals to the schemas component of an openAPI document.
// A Manifest VersionSchema does not contain a metadata object, as that is consistent between every app platform kind.
// This is modeled after kubernetes' behavior for describing a CRD schema.
type VersionSchema struct {
// raw is the openAPI components.schemas section of an openAPI document, represented as a map[string]any
raw map[string]any
}
func (v *VersionSchema) UnmarshalJSON(data []byte) error {
v.raw = make(map[string]any)
err := json.Unmarshal(data, &v.raw)
if err != nil {
return err
}
return v.fixRaw()
}
func (v *VersionSchema) MarshalJSON() ([]byte, error) {
return json.Marshal(v.raw)
}
func (v *VersionSchema) UnmarshalYAML(unmarshal func(any) error) error {
v.raw = make(map[string]any)
err := unmarshal(&v.raw)
if err != nil {
return err
}
return v.fixRaw()
}
func (v *VersionSchema) MarshalYAML() (any, error) {
// MarshalYAML needs to return an object to the marshaler, not bytes like MarshalJSON
return v.raw, nil
}
// fixRaw turns a full OpenAPI document map[string]any in raw into a set of schemas (if required)
// nolint:gocognit,funlen
func (v *VersionSchema) fixRaw() error {
if components, ok := v.raw["components"]; ok {
cast, ok := components.(map[string]any)
if !ok {
return errors.New("'components' in an OpenAPI document must be an object")
}
s, ok := cast["schemas"]
if !ok {
v.raw = make(map[string]any)
return nil
}
schemas, ok := s.(map[string]any)
if !ok {
return errors.New("'components.schemas' in an OpenAPI document must be an object")
}
v.raw = schemas
return nil
}
// TODO: should we even accept CRD-shaped data when unmarshaling?
// The process for working with the manifest should go through one of the versioned types first, which
// will always use OpenAPI, and not call unmarshal directly.
if _, ok := v.raw["openAPIV3Schema"]; ok {
// CRD-like schema, we have to convert this into a set of "components",
// but we don't know the object name. In this case, we use "KIND" as the name,
// which can be corrected later if necessary.
oapi, ok := v.raw["openAPIV3Schema"].(map[string]any)
if !ok {
return errors.New("'openAPIV3Schema' must be an object")
}
props, ok := oapi["properties"]
if !ok {
return errors.New("'openAPIV3Schema' must contain properties")
}
castProps, ok := props.(map[string]any)
if !ok {
return errors.New("'openAPIV3Schema' properties must be an object")
}
m := make(map[string]any)
maps.Copy(m, castProps)
v.raw = map[string]any{
parsedCRDSchemaKindName: map[string]any{
"properties": m,
"type": "object",
},
}
return nil
}
// There's another way something might be CRD-shaped, and that's the contents of openAPIV3Schema.properties
// If the map contains `spec`, and none of the root objects have a `spec` property that references it,
// we can make the assumption that this is actually a CRD-shaped-object
if _, ok := v.raw["spec"]; ok {
for k, v := range v.raw {
if k == "spec" {
continue
}
cast, ok := v.(map[string]any)
if !ok {
continue
}
props, ok := cast["properties"]
if !ok {
continue
}
cast, ok = props.(map[string]any)
if !ok {
continue
}
spc, ok := cast["spec"]
if !ok {
continue
}
cast, ok = spc.(map[string]any)
if !ok {
continue
}
if ref, ok := cast["$ref"]; ok {
if cr, ok := ref.(string); ok && len(cr) > 5 && cr[len(cr)-5:] == "/spec" {
return nil // spec is referenced by another object's `spec`, this isn't a CRD
}
}
}
// This seems to be CRD-shaped, handle it like a CRD
kind := make(map[string]any)
props := make(map[string]any)
root := make(map[string]any)
kind["properties"] = props
kind["type"] = "object"
for k, v := range v.raw {
// If the field starts with #, it's a definition, lift it out of the object
if len(k) > 1 && k[0] == '#' {
root[k] = v
continue
}
props[k] = v
}
v.raw = map[string]any{
parsedCRDSchemaKindName: kind,
}
maps.Copy(v.raw, root)
}
return nil
}
// AsOpenAPI3SchemasMap returns the schema as a map[string]any version of an openAPI components.schemas section
func (v *VersionSchema) AsOpenAPI3SchemasMap() map[string]any {
return v.raw
}
// AsCRDMap returns the schema as a map[string]any where each key is a top-level resource (ex. 'spec', 'status')
// if the kindObjectName provided doesn't exist in the underlying raw openAPI schemas,
// or the schema's references cannot be resolved into a single object, an error will be returned.
func (v *VersionSchema) AsCRDMap(kindObjectName string) (map[string]any, error) {
sch, err := v.AsCRDOpenAPI3(kindObjectName)
if err != nil {
return nil, err
}
dest := make(map[string]any)
b, err := json.Marshal(sch.Properties)
if err != nil {
return nil, fmt.Errorf("error marshaling CRD OpenAPI Schema: %w", err)
}
err = json.Unmarshal(b, &dest)
if err != nil {
return nil, fmt.Errorf("error unmarshaling CRD OpenAPI bytes to map[string]any: %w", err)
}
return dest, nil
}
// AsOpenAPI3 returns an openapi3.Components instance which contains the schema elements
func (v *VersionSchema) AsOpenAPI3() (*openapi3.Components, error) {
full := map[string]any{
"openapi": "3.0.0",
"components": map[string]any{
"schemas": v.AsOpenAPI3SchemasMap(),
},
}
yml, err := yaml.Marshal(full)
if err != nil {
return nil, err
}
loader := openapi3.NewLoader()
oT, err := loader.LoadFromData(yml)
if err != nil {
return nil, err
}
return oT.Components, nil
}
// AsCRDOpenAPI3 returns an openapi3.Schema instances for a CRD for the kind.
// References in the schema will be resolved and embedded, and recursive references
// will be converted into empty objects with `x-kubernetes-preserve-unknown-fields: true`.
// The root object for the CRD in the version components is specified by kindObjectName.
// If kindObjectName does not exist in the list of schemas, an error will be returned.
func (v *VersionSchema) AsCRDOpenAPI3(kindObjectName string) (*openapi3.Schema, error) {
components, err := v.AsOpenAPI3()
if err != nil {
return nil, err
}
if _, ok := components.Schemas[kindObjectName]; !ok {
// Unfixed CRD schema parsed, the only way to get here is from parsing CRD schema directly with UnmarshalJSON
if _, ok := components.Schemas[parsedCRDSchemaKindName]; !ok {
return GetCRDOpenAPISchema(components, parsedCRDSchemaKindName)
}
}
return GetCRDOpenAPISchema(components, kindObjectName)
}
// KubeOpenAPIReferenceReplacerFunc is used to prefix references in a schema, so that they won't conflict
// with other references in the set of all schemas for a version. It will update a reference key to be
// <pkgPrefix>.<kind><ref>
func KubeOpenAPIReferenceReplacerFunc(pkgPrefix string, gvk schema.GroupVersionKind) func(string) string {
return func(k string) string {
ucK := strings.ToUpper(k)
if len(k) > 1 {
ucK = strings.ToUpper(k[:1]) + k[1:]
}
return fmt.Sprintf("%s.%s%s", pkgPrefix, gvk.Kind, ucK)
}
}
// AsKubeOpenAPI converts the schema into a map of reference string to common.OpenAPIDefinition objects, suitable for use with kubernetes API server code.
// It uses the provided schema.GroupVersionKind and pkgPrefix for naming of the kind and for reference naming. The map output will look something like:
//
// "<pkgPrefix>.<kind>": {...},
// "<pkgPrefix>.<kind>List": {...},
// "<pkgPrefix>.<kind>Spec": {...}, // ...etc. for all other resources
//
// If you wish to exclude a field from your kind's object, ensure that the field name begins with a `#`, which will be treated as a definition.
// Definitions are included in the returned map as types, but are not included as fields (alongside "spec","status", etc.) in the kind object.
//
// It will error if the underlying schema cannot be parsed as valid openAPI.
//
//nolint:funlen
func (v *VersionSchema) AsKubeOpenAPI(gvk schema.GroupVersionKind, ref common.ReferenceCallback, pkgPrefix string) (map[string]common.OpenAPIDefinition, error) {
// Convert the kin-openapi to kube-openapi
oapi, err := v.AsOpenAPI3()
if err != nil {
return nil, fmt.Errorf("error converting OpenAPI Schema: %w", err)
}
// Check if we have underlying CRD data that hasn't been appropriately labeled
if crd, ok := oapi.Schemas[parsedCRDSchemaKindName]; ok {
// If so, assume that the CRD data is the kind we're looking for
oapi.Schemas[gvk.Kind] = crd
delete(oapi.Schemas, parsedCRDSchemaKindName)
}
result := make(map[string]common.OpenAPIDefinition)
// Get the kind object, strip out the metadata field if present, and format it correctly for k8s
// The key for the kind could be the Kind, "<anystring>.<Kind>", or `parsedCRDSchemaKindName` ("__KIND__") (in edge cases)
var kindSchema *openapi3.SchemaRef
kindSchemaKey := ""
for k := range oapi.Schemas {
if strings.EqualFold(k, gvk.Kind) || k == parsedCRDSchemaKindName {
kindSchema = oapi.Schemas[k]
kindSchemaKey = k
break
}
parts := strings.Split(k, ".")
if len(parts) > 1 && strings.EqualFold(parts[len(parts)-1], gvk.Kind) {
kindSchema = oapi.Schemas[k]
kindSchemaKey = k
break
}
}
if kindSchema == nil {
return nil, fmt.Errorf("unable to locate openAPI definition for kind %s", gvk.Kind)
}
if len(kindSchema.Value.AllOf) > 0 || len(kindSchema.Value.AnyOf) > 0 || len(kindSchema.Value.OneOf) > 0 || kindSchema.Value.Not != nil {
return nil, fmt.Errorf("anyOf, allOf, oneOf, and not are unsupported for the kind's root schema (kind %s)", gvk.Kind)
}
// Prefix all the refs the same way
// Name the schema as <pkgPrefix>.<Kind><schema>
// This ensures no conflicts when merging with other OpenAPI defs later
refKey := KubeOpenAPIReferenceReplacerFunc(pkgPrefix, gvk)
// Construct the new kind based on this entry
kindProp := spec.Schema{
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: "",
},
}
apiVersionProp := spec.Schema{
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: "",
},
}
// kind must always be present, partially declare it here so we can add subresources and dependencies as we iterate through them
kind := common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": kindProp,
"apiVersion": apiVersionProp,
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]any{},
Ref: ref(metav1.ObjectMeta{}.OpenAPIModelName()),
},
},
},
Required: []string{"kind", "apiVersion", "metadata"},
},
},
Dependencies: make([]string, 0),
}
kind.Dependencies = append(kind.Dependencies, metav1.ObjectMeta{}.OpenAPIModelName())
// Add the non-metadata properties
for k, v := range kindSchema.Value.Properties {
if k == "metadata" || k == "apiVersion" || k == "kind" {
continue
}
sch, deps := oapi3SchemaToKubeSchema(v, ref, gvk, refKey)
kind.Schema.Properties[k] = sch
kind.Dependencies = append(kind.Dependencies, deps...)
if k == "spec" {
kind.Schema.Required = append(kind.Schema.Required, k)
}
}
// For each schema, create an entry in the result
for k, s := range oapi.Schemas {
if k == kindSchemaKey {
continue
}
key := refKey(k)
sch, deps := oapi3SchemaToKubeSchema(s, ref, gvk, refKey)
// sort dependencies for consistent output
slices.Sort(deps)
result[key] = common.OpenAPIDefinition{
Schema: sch,
Dependencies: deps,
}
}
// sort dependencies for consistent output
slices.Sort(kind.Dependencies)
// add the kind object to our result map
result[fmt.Sprintf("%s.%s", pkgPrefix, gvk.Kind)] = kind
// add the kind list object to our result map (static object type based on the kind object)
result[fmt.Sprintf("%s.%sList", pkgPrefix, gvk.Kind)] = common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": kindProp,
"apiVersion": apiVersionProp,
"metadata": {
SchemaProps: spec.SchemaProps{
Default: map[string]any{},
Ref: ref(metav1.ListMeta{}.OpenAPIModelName()),
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
Items: &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: map[string]any{},
Ref: ref(fmt.Sprintf("%s.%s", pkgPrefix, gvk.Kind)),
},
},
},
},
},
},
Required: []string{"metadata", "items"},
},
},
Dependencies: []string{
metav1.ListMeta{}.OpenAPIModelName(), fmt.Sprintf("%s.%s", pkgPrefix, gvk.Kind)},
}
return result, nil
}
// oapi3SchemaToKubeSchema converts a SchemaRef into a spec.Schema and its dependencies.
// It requires a ReferenceCallback for creating any references, and uses the gvk to rename references as "<group>/<version>.<reference>"
//
//nolint:funlen,unparam
func oapi3SchemaToKubeSchema(sch *openapi3.SchemaRef, ref common.ReferenceCallback, gvk schema.GroupVersionKind, refReplacer func(string) string) (resSchema spec.Schema, dependencies []string) {
if sch.Ref != "" {
// Reformat the ref to use the path derived from the GVK
schRef := refReplacer(strings.TrimPrefix(sch.Ref, "#/components/schemas/"))
return spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: ref(schRef),
},
}, []string{schRef}
}
if sch.Value == nil {
// Not valid
return spec.Schema{}, []string{}
}
dependencies = make([]string, 0)
resSchema = spec.Schema{
SchemaProps: spec.SchemaProps{
Type: sch.Value.Type.Slice(),
Format: sch.Value.Format,
Description: sch.Value.Description,
Default: sch.Value.Default,
Minimum: sch.Value.Min,
Maximum: sch.Value.Max,
MultipleOf: sch.Value.MultipleOf,
Pattern: sch.Value.Pattern,
UniqueItems: sch.Value.UniqueItems,
Required: sch.Value.Required,
Enum: sch.Value.Enum,
Title: sch.Value.Title,
Nullable: sch.Value.Nullable,
},
}
// Differing types between k8s and openapi3
if sch.Value.MinLength != 0 {
ml := convertUint64(sch.Value.MinLength)
resSchema.MinLength = &ml
}
if sch.Value.MaxLength != nil {
ml := convertUint64(*sch.Value.MaxLength)
resSchema.MaxLength = &ml
}
if sch.Value.MinItems != 0 {
mi := convertUint64(sch.Value.MinItems)
resSchema.MinItems = &mi
}
if sch.Value.MaxItems != nil {
mi := convertUint64(*sch.Value.MaxItems)
resSchema.MaxItems = &mi
}
if sch.Value.MinProps != 0 {
mp := convertUint64(sch.Value.MinProps)
resSchema.MinProperties = &mp
}
if sch.Value.MaxProps != nil {
mp := convertUint64(*sch.Value.MaxProps)
resSchema.MaxProperties = &mp
}
// AdditionalProperties
if sch.Value.AdditionalProperties.Has != nil {
resSchema.AdditionalProperties = &spec.SchemaOrBool{
Allows: *sch.Value.AdditionalProperties.Has,
}
}
if sch.Value.AdditionalProperties.Schema != nil {
s, deps := oapi3SchemaToKubeSchema(sch.Value.AdditionalProperties.Schema, ref, gvk, refReplacer)
resSchema.AdditionalProperties = &spec.SchemaOrBool{
Schema: &s,
}
dependencies = updateDependencies(dependencies, deps)
}
// Handle special case of `x-kubernetes-preserve-unknown-fields: true` to make AdditionalProperties an empty object
if sch.Value.Extensions != nil {
if val, ok := sch.Value.Extensions["x-kubernetes-preserve-unknown-fields"]; ok {
if conv, ok := val.(bool); ok && conv {
resSchema.AdditionalProperties = &spec.SchemaOrBool{
Allows: true,
}
}
}
}
// AllOf, AnyOf, OneOf, Not
if sch.Value.AllOf != nil {
resSchema.AllOf = make([]spec.Schema, 0)
for _, v := range sch.Value.AllOf {
s, deps := oapi3SchemaToKubeSchema(v, ref, gvk, refReplacer)
resSchema.AllOf = append(resSchema.AllOf, s)
dependencies = updateDependencies(dependencies, deps)
}
}
if sch.Value.AnyOf != nil {
resSchema.AnyOf = make([]spec.Schema, 0)
for _, v := range sch.Value.AnyOf {
s, deps := oapi3SchemaToKubeSchema(v, ref, gvk, refReplacer)
resSchema.AnyOf = append(resSchema.AnyOf, s)
dependencies = updateDependencies(dependencies, deps)
}
}
if sch.Value.OneOf != nil {
resSchema.OneOf = make([]spec.Schema, 0)
for _, v := range sch.Value.OneOf {
s, deps := oapi3SchemaToKubeSchema(v, ref, gvk, refReplacer)