From 39eb4b57afe1eb4c3ae329c78e23a8b2b016b2a8 Mon Sep 17 00:00:00 2001 From: Austin Pond Date: Thu, 12 Sep 2024 12:58:52 -0400 Subject: [PATCH] Initial App Manifest (#395) # What This PR Does / Why We Need It This PR introduces the first step of the **App Manifest**, a concept to help clarify and drive app development. The App Manifest is the listing of the app's managed kinds and capabilities (admission, conversion) for those kinds. This PR introduces admission and conversion capabilities for kinds in the manifest, be leaves schemas unimplemented. Added in this PR is the `app` package, which is where the App Manifest (as `app.Manifest`) lives, and where additional app-centric logic will reside for future features such as https://github.com/grafana/grafana-app-sdk/issues/385 `app.Manifest` is decoupled from the actual manifest data by having an `app.ManifestData` type which contains manifest data, and having the `app.Manifest` contain a pointer to said data (which can be nil), and a location for the data. This way, an `app.Manifest` can simply point to a file on-disk, or an API server location, without having to have the data loaded, and the consumer of the `app.Manifest` should understand how to fetch the `app.ManifestData`. This allows an app to not need to know the credentials to fetch the manifest data (such as kube config), but still be able to tell other components which may have such knowledge where to fetch the data (this is important in https://github.com/grafana/grafana-app-sdk/issues/385, as the App Provider will need to provide the manifest and a way of creating the App, but will not know how to talk to the API server, as the runner will be able to load those credentials). Included in this PR is the generation of this in-progress initial manifest as both an API server CR, and as in-code `app.ManifestData` to use if and when the manifest type is not available in the API server. An app author can now specify their app's capabilities for admission and conversion for kinds via `admission`, `mutations`, and `conversion` in the `apiResource` field in CUE, and can optionally override those kind-wide defaults on a per-version basis with `admission` and `mutation` fields in the version (`conversion` is always only kind-wide, as all versions must be inter-convertable for `conversion` to be allowed). The test data has been updated with these additional fields for manifest generation testing. Two new files will now be generated on each `grafana-app-sdk generate` call: `definitions/-manifest.(json|yaml)`, and `pkg/generated/manifest.go`. Relates to https://github.com/grafana/grafana-app-sdk/issues/353, which will be completed once the manifest contains the CRDs (schemas) as well. --------- Co-authored-by: Igor Suleymanov --- app/manifest.go | 141 ++++++++++++ cmd/grafana-app-sdk/generate.go | 36 ++- codegen/cuekind/def.cue | 16 ++ codegen/cuekind/generators.go | 19 ++ codegen/cuekind/generators_test.go | 47 ++++ codegen/cuekind/testing/testkind.cue | 6 +- codegen/jennies/manifest.go | 208 ++++++++++++++++++ codegen/kind.go | 31 ++- codegen/templates/manifest_go.tmpl | 43 ++++ codegen/templates/templates.go | 29 +++ .../manifest/go/testkinds/manifest.go.txt | 67 ++++++ .../test-app-custom-kind-2-manifest.yaml.txt | 7 + .../test-app-test-kind-manifest.yaml.txt | 33 +++ go.work.sum | 3 + 14 files changed, 678 insertions(+), 8 deletions(-) create mode 100644 app/manifest.go create mode 100644 codegen/jennies/manifest.go create mode 100644 codegen/templates/manifest_go.tmpl create mode 100644 codegen/testing/golden_generated/manifest/go/testkinds/manifest.go.txt create mode 100644 codegen/testing/golden_generated/manifest/test-app-custom-kind-2-manifest.yaml.txt create mode 100644 codegen/testing/golden_generated/manifest/test-app-test-kind-manifest.yaml.txt diff --git a/app/manifest.go b/app/manifest.go new file mode 100644 index 00000000..e55bf640 --- /dev/null +++ b/app/manifest.go @@ -0,0 +1,141 @@ +package app + +// 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"` + // Group is the group used for all kinds maintained by this app. + // This is usually ".ext.grafana.com" + Group string `json:"group" yaml:"group"` + // Kinds is a list of all Kinds maintained by this App + Kinds []ManifestKind `json:"kinds,omitempty" yaml:"kinds,omitempty"` +} + +// ManifestKind is the manifest for a particular kind, including its Kind, Scope, and Versions +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"` + // 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 contains details for a version of a kind in a Manifest +type ManifestKindVersion struct { + // Name is the version string name, such as "v1" + Name string `yaml:"name" json:"name"` + // 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 any `json:"schema,omitempty" yaml:"schema,omitempty"` // TODO: actual schema +} + +// 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" +) diff --git a/cmd/grafana-app-sdk/generate.go b/cmd/grafana-app-sdk/generate.go index bd7b9325..f2b1415a 100644 --- a/cmd/grafana-app-sdk/generate.go +++ b/cmd/grafana-app-sdk/generate.go @@ -177,6 +177,7 @@ type kindGenConfig struct { GroupKinds bool } +//nolint:goconst func generateKindsThema(modFS fs.FS, cfg kindGenConfig, selectors ...string) (codejen.Files, error) { parser, err := themagen.NewCustomKindParser(thema.NewRuntime(cuecontext.New()), modFS) if err != nil { @@ -269,6 +270,7 @@ func generateFrontendModelsThema(parser *themagen.CustomKindParser, genPath stri return files, nil } +//nolint:goconst func generateCRDsThema(parser *themagen.CustomKindParser, genPath string, encoding string, selectors []string) (codejen.Files, error) { var ms themagen.Generator if encoding == "yaml" { @@ -289,7 +291,7 @@ func generateCRDsThema(parser *themagen.CustomKindParser, genPath string, encodi return files, nil } -//nolint:funlen +//nolint:funlen,goconst func generateKindsCue(modFS fs.FS, cfg kindGenConfig, selectors ...string) (codejen.Files, error) { parser, err := cuekind.NewParser() if err != nil { @@ -360,11 +362,43 @@ func generateKindsCue(modFS fs.FS, cfg kindGenConfig, selectors ...string) (code } } + // Manifest + var manifestFiles codejen.Files + if cfg.CRDEncoding != "none" { + encFunc := func(v any) ([]byte, error) { + return json.MarshalIndent(v, "", " ") + } + if cfg.CRDEncoding == "yaml" { + encFunc = yaml.Marshal + } + manifestFiles, err = generator.FilteredGenerate(cuekind.ManifestGenerator(encFunc, cfg.CRDEncoding, ""), func(kind codegen.Kind) bool { + return kind.Properties().APIResource != nil + }, selectors...) + if err != nil { + return nil, err + } + for i, f := range manifestFiles { + manifestFiles[i].RelativePath = filepath.Join(cfg.CRDPath, f.RelativePath) + } + } + + goManifestFiles, err := generator.FilteredGenerate(cuekind.ManifestGoGenerator(filepath.Base(cfg.GoGenBasePath), ""), func(kind codegen.Kind) bool { + return kind.Properties().APIResource != nil + }) + if err != nil { + return nil, err + } + for i, f := range goManifestFiles { + goManifestFiles[i].RelativePath = filepath.Join(cfg.GoGenBasePath, f.RelativePath) + } + allFiles := append(make(codejen.Files, 0), resourceFiles...) allFiles = append(allFiles, modelFiles...) allFiles = append(allFiles, tsModelFiles...) allFiles = append(allFiles, tsResourceFiles...) allFiles = append(allFiles, crdFiles...) + allFiles = append(allFiles, manifestFiles...) + allFiles = append(allFiles, goManifestFiles...) return allFiles, nil } diff --git a/codegen/cuekind/def.cue b/codegen/cuekind/def.cue index 4010b101..53453b01 100644 --- a/codegen/cuekind/def.cue +++ b/codegen/cuekind/def.cue @@ -70,6 +70,10 @@ Schema: { _specIsNonEmpty: spec & struct.MinFields(0) } +#AdmissionCapability: { + operations: [...string] +} + // Kind represents an arbitrary kind which can be used for code generation Kind: S={ kind: =~"^([A-Z][a-zA-Z0-9-]{0,61}[a-zA-Z0-9])$" @@ -107,6 +111,16 @@ Kind: S={ // scope determines whether resources of this kind exist globally ("Cluster") or // within Kubernetes namespaces. scope: "Cluster" | *"Namespaced" + // validation determines whether there is code-based validation for this kind. Used for generating the manifest. + validation: #AdmissionCapability | *{ + operations: [] + } + // mutation determines whether there is code-based mutation for this kind. Used for generating the manifest. + mutation: #AdmissionCapability | *{ + operations: [] + } + // conversion determines whether there is code-based conversion for this kind. Used for generating the manifest. + conversion: bool | *false } // isCRD is true if the `crd` trait is present in the kind. isAPIResource: apiResource != _|_ @@ -128,6 +142,8 @@ Kind: S={ // Fields must be from the root of the schema, i.e. 'spec.foo', and have a string type. // Fields cannot include custom metadata (TODO: check if we can use annotations for field selectors) selectableFields: [...string] + validation: #AdmissionCapability | *S.apiResource.validation + mutation: #AdmissionCapability | *S.apiResource.mutation } } machineName: strings.ToLower(strings.Replace(S.kind, "-", "_", -1)) diff --git a/codegen/cuekind/generators.go b/codegen/cuekind/generators.go index f53bfad9..1ba5845a 100644 --- a/codegen/cuekind/generators.go +++ b/codegen/cuekind/generators.go @@ -141,6 +141,25 @@ func PostResourceGenerationGenerator(projectRepo, goGenPath string, versioned bo return g } +func ManifestGenerator(encoder jennies.ManifestOutputEncoder, extension string, appName string) *codejen.JennyList[codegen.Kind] { + g := codejen.JennyListWithNamer[codegen.Kind](namerFunc) + g.Append(&jennies.ManifestGenerator{ + AppName: appName, + Encoder: encoder, + FileExtension: extension, + }) + return g +} + +func ManifestGoGenerator(pkg string, appName string) *codejen.JennyList[codegen.Kind] { + g := codejen.JennyListWithNamer[codegen.Kind](namerFunc) + g.Append(&jennies.ManifestGoGenerator{ + Package: pkg, + AppName: appName, + }) + return g +} + func namerFunc(k codegen.Kind) string { if k == nil { return "nil" diff --git a/codegen/cuekind/generators_test.go b/codegen/cuekind/generators_test.go index 35287437..e24e1658 100644 --- a/codegen/cuekind/generators_test.go +++ b/codegen/cuekind/generators_test.go @@ -170,6 +170,53 @@ func TestTypeScriptResourceGenerator(t *testing.T) { }) } +func TestManifestGenerator(t *testing.T) { + parser, err := NewParser() + require.Nil(t, err) + + t.Run("resource", func(t *testing.T) { + kinds, err := parser.Parse(os.DirFS(TestCUEDirectory), "testKind", "testKind2") + require.Nil(t, err) + files, err := ManifestGenerator(yaml.Marshal, "yaml", "test-app-test-kind").Generate(kinds...) + require.Nil(t, err) + // Check number of files generated + // 5 -> object, spec, metadata, status, schema + assert.Len(t, files, 1) + // Check content against the golden files + compareToGolden(t, files, "manifest") + }) + + t.Run("model", func(t *testing.T) { + kinds, err := parser.Parse(os.DirFS(TestCUEDirectory), "customKind2") + fmt.Println(err) + require.Nil(t, err) + files, err := ManifestGenerator(yaml.Marshal, "yaml", "test-app-custom-kind-2").Generate(kinds...) + require.Nil(t, err) + // Check number of files generated + // 5 -> object, spec, metadata, status, schema + assert.Len(t, files, 1) + // Check content against the golden files + compareToGolden(t, files, "manifest") + }) +} + +func TestManifestGoGenerator(t *testing.T) { + parser, err := NewParser() + require.Nil(t, err) + + t.Run("resource", func(t *testing.T) { + kinds, err := parser.Parse(os.DirFS(TestCUEDirectory), "testKind", "testKind2") + require.Nil(t, err) + files, err := ManifestGoGenerator("generated", "test-app").Generate(kinds...) + require.Nil(t, err) + // Check number of files generated + // 5 -> object, spec, metadata, status, schema + assert.Len(t, files, 1) + // Check content against the golden files + compareToGolden(t, files, "manifest/go/testkinds") + }) +} + func compareToGolden(t *testing.T, files codejen.Files, pathPrefix string) { for _, f := range files { // Check if there's a golden generated file to compare against diff --git a/codegen/cuekind/testing/testkind.cue b/codegen/cuekind/testing/testkind.cue index 388c391e..25d65ae9 100644 --- a/codegen/cuekind/testing/testkind.cue +++ b/codegen/cuekind/testing/testkind.cue @@ -6,7 +6,10 @@ testKind: { kind: "TestKind" plural: "testkinds" group: "test" - apiResource: {} + apiResource: { + validation: operations: ["create","update"] + conversion: true + } current: "v1" codegen: frontend: false versions: { @@ -26,6 +29,7 @@ testKind: { timeField: string & time.Time } } + mutation: operations: ["create","update"] } } } \ No newline at end of file diff --git a/codegen/jennies/manifest.go b/codegen/jennies/manifest.go new file mode 100644 index 00000000..0fade994 --- /dev/null +++ b/codegen/jennies/manifest.go @@ -0,0 +1,208 @@ +//nolint:dupl +package jennies + +import ( + "bytes" + "fmt" + "go/format" + "strings" + + "github.com/grafana/codejen" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/codegen" + "github.com/grafana/grafana-app-sdk/codegen/templates" +) + +type ManifestOutputEncoder func(any) ([]byte, error) + +// ManifestGenerator generates a JSON/YAML App Manifest. +type ManifestGenerator struct { + Encoder ManifestOutputEncoder + FileExtension string + AppName string +} + +func (*ManifestGenerator) JennyName() string { + return "ManifestGenerator" +} + +// Generate creates one or more codec go files for the provided Kind +// nolint:dupl +func (m *ManifestGenerator) Generate(kinds ...codegen.Kind) (codejen.Files, error) { + manifest, err := buildManifest(kinds) + if err != nil { + return nil, err + } + + if m.AppName != "" { + manifest.AppName = m.AppName + } + if manifest.Group == "" { + if len(manifest.Kinds) > 0 { + // API Resource kinds that have no group are not allowed, error at this point + return nil, fmt.Errorf("all APIResource kinds must have a non-empty group") + } + // No kinds, make an assumption for the group name + manifest.Group = fmt.Sprintf("%s.ext.grafana.com", manifest.AppName) + } + + // Make into kubernetes format + output := make(map[string]any) + output["apiVersion"] = "apps.grafana.com/v1" + output["kind"] = "AppManifest" + output["metadata"] = map[string]string{ + "name": manifest.AppName, + } + output["spec"] = manifest + + files := make(codejen.Files, 0) + out, err := m.Encoder(output) + if err != nil { + return nil, err + } + files = append(files, codejen.File{ + RelativePath: fmt.Sprintf("%s-manifest.%s", manifest.AppName, m.FileExtension), + Data: out, + From: []codejen.NamedJenny{m}, + }) + + return files, nil +} + +type ManifestGoGenerator struct { + AppName string + Package string +} + +func (*ManifestGoGenerator) JennyName() string { + return "ManifestGoGenerator" +} + +func (g *ManifestGoGenerator) Generate(kinds ...codegen.Kind) (codejen.Files, error) { + manifest, err := buildManifest(kinds) + if err != nil { + return nil, err + } + + if g.AppName != "" { + manifest.AppName = g.AppName + } + if manifest.Group == "" { + if len(manifest.Kinds) > 0 { + // API Resource kinds that have no group are not allowed, error at this point + return nil, fmt.Errorf("all APIResource kinds must have a non-empty group") + } + // No kinds, make an assumption for the group name + manifest.Group = fmt.Sprintf("%s.ext.grafana.com", manifest.AppName) + } + + buf := bytes.Buffer{} + err = templates.WriteManifestGoFile(templates.ManifestGoFileMetadata{ + Package: g.Package, + ManifestData: *manifest, + }, &buf) + if err != nil { + return nil, err + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return nil, err + } + files := make(codejen.Files, 0) + files = append(files, codejen.File{ + Data: formatted, + RelativePath: "manifest.go", + From: []codejen.NamedJenny{g}, + }) + + return files, nil +} + +func buildManifest(kinds []codegen.Kind) (*app.ManifestData, error) { + manifest := app.ManifestData{ + Kinds: make([]app.ManifestKind, 0), + } + + for _, kind := range kinds { + if kind.Properties().APIResource == nil { + continue + } + if manifest.AppName == "" { + manifest.AppName = kind.Properties().Group + } + if manifest.Group == "" { + manifest.Group = kind.Properties().APIResource.Group + } + if kind.Properties().APIResource.Group == "" { + return nil, fmt.Errorf("all APIResource kinds must have a non-empty group") + } + if kind.Properties().APIResource.Group != manifest.Group { + return nil, fmt.Errorf("all kinds must have the same group %q", manifest.Group) + } + + mkind := app.ManifestKind{ + Kind: kind.Name(), + Scope: kind.Properties().APIResource.Scope, + Conversion: kind.Properties().APIResource.Conversion, + Versions: make([]app.ManifestKindVersion, 0), + } + + for _, version := range kind.Versions() { + mver := app.ManifestKindVersion{ + Name: version.Version, + } + if len(version.Mutation.Operations) > 0 { + operations, err := sanitizeAdmissionOperations(version.Mutation.Operations) + if err != nil { + return nil, fmt.Errorf("mutation operations error: %w", err) + } + mver.Admission = &app.AdmissionCapabilities{ + Mutation: &app.MutationCapability{ + Operations: operations, + }, + } + } + if len(version.Validation.Operations) > 0 { + if mver.Admission == nil { + mver.Admission = &app.AdmissionCapabilities{} + } + operations, err := sanitizeAdmissionOperations(version.Validation.Operations) + if err != nil { + return nil, fmt.Errorf("validation operations error: %w", err) + } + mver.Admission.Validation = &app.ValidationCapability{ + Operations: operations, + } + } + mkind.Versions = append(mkind.Versions, mver) + } + manifest.Kinds = append(manifest.Kinds, mkind) + } + + return &manifest, nil +} + +var validAdmissionOperations = map[codegen.KindAdmissionCapabilityOperation]app.AdmissionOperation{ + codegen.AdmissionCapabilityOperationAny: app.AdmissionOperationAny, + codegen.AdmissionCapabilityOperationConnect: app.AdmissionOperationConnect, + codegen.AdmissionCapabilityOperationCreate: app.AdmissionOperationCreate, + codegen.AdmissionCapabilityOperationDelete: app.AdmissionOperationDelete, + codegen.AdmissionCapabilityOperationUpdate: app.AdmissionOperationUpdate, +} + +func sanitizeAdmissionOperations(operations []codegen.KindAdmissionCapabilityOperation) ([]app.AdmissionOperation, error) { + sanitized := make([]app.AdmissionOperation, 0) + for _, op := range operations { + translated, ok := validAdmissionOperations[codegen.KindAdmissionCapabilityOperation(strings.ToUpper(string(op)))] + if !ok { + return nil, fmt.Errorf("invalid operation %q", op) + } + if translated == app.AdmissionOperationAny && len(operations) > 1 { + return nil, fmt.Errorf("cannot use any ('*') operation alongside named operations") + } + sanitized = append(sanitized, translated) + } + return sanitized, nil +} diff --git a/codegen/kind.go b/codegen/kind.go index 002c2e54..b2161c50 100644 --- a/codegen/kind.go +++ b/codegen/kind.go @@ -36,8 +36,25 @@ type KindProperties struct { // APIResourceProperties contains information about a Kind expressible as a kubernetes API resource type APIResourceProperties struct { - Group string `json:"group"` - Scope string `json:"scope"` + Group string `json:"group"` + Scope string `json:"scope"` + Validation KindAdmissionCapability `json:"validation"` + Mutation KindAdmissionCapability `json:"mutation"` + Conversion bool `json:"conversion"` +} + +type KindAdmissionCapabilityOperation string + +const ( + AdmissionCapabilityOperationCreate KindAdmissionCapabilityOperation = "CREATE" + AdmissionCapabilityOperationUpdate KindAdmissionCapabilityOperation = "UPDATE" + AdmissionCapabilityOperationDelete KindAdmissionCapabilityOperation = "DELETE" + AdmissionCapabilityOperationConnect KindAdmissionCapabilityOperation = "CONNECT" + AdmissionCapabilityOperationAny KindAdmissionCapabilityOperation = "*" +) + +type KindAdmissionCapability struct { + Operations []KindAdmissionCapabilityOperation `json:"operations"` } // KindCodegenProperties contains code generation directives for a Kind or KindVersion @@ -50,10 +67,12 @@ type KindVersion struct { Version string `json:"version"` // Schema is the CUE schema for the version // This should eventually be changed to JSONSchema/OpenAPI(/AST?) - Schema cue.Value `json:"schema"` // TODO: this should eventually be OpenAPI/JSONSchema (ast or bytes?) - Codegen KindCodegenProperties `json:"codegen"` - Served bool `json:"served"` - SelectableFields []string `json:"selectableFields"` + Schema cue.Value `json:"schema"` // TODO: this should eventually be OpenAPI/JSONSchema (ast or bytes?) + Codegen KindCodegenProperties `json:"codegen"` + Served bool `json:"served"` + SelectableFields []string `json:"selectableFields"` + Validation KindAdmissionCapability `json:"validation"` + Mutation KindAdmissionCapability `json:"mutation"` } // AnyKind is a simple implementation of Kind diff --git a/codegen/templates/manifest_go.tmpl b/codegen/templates/manifest_go.tmpl new file mode 100644 index 00000000..410a5542 --- /dev/null +++ b/codegen/templates/manifest_go.tmpl @@ -0,0 +1,43 @@ +package {{.Package}} + +import ( + "github.com/grafana/grafana-app-sdk/app" +) + +var appManifestData = app.ManifestData{ + AppName: "{{.ManifestData.AppName}}", + Group: "{{.ManifestData.Group}}", + Kinds: []app.ManifestKind{ {{ range .ManifestData.Kinds }} + { + Kind: "{{.Kind}}", + Scope: "{{.Scope}}", + Conversion: {{.Conversion}}, + Versions: []app.ManifestKindVersion{ {{ range .Versions }} + { + Name: "{{.Name}}", {{ if .Admission }} + Admission: &app.AdmissionCapabilities{ + {{ if .Admission.Validation }} Validation: &app.ValidationCapability{ + {{ if .Admission.Validation.Operations }} Operations: []app.AdmissionOperation{ + {{ range .Admission.Validation.Operations }}app.{{ $.ToAdmissionOperationName . }}, + {{ end }} }, {{ end }} + }, {{ end }} + {{ if .Admission.Mutation }} Validation: &app.MutationCapability{ + {{ if .Admission.Mutation.Operations }} Operations: []app.AdmissionOperation{ + {{ range .Admission.Mutation.Operations }}app.{{ $.ToAdmissionOperationName . }}, + {{ end }} }, {{ end }} + }, {{ end }} + }, {{ end }} + }, + {{ end }} }, + }, + {{ end }} }, +} + +func LocalManifest() app.Manifest { + return app.NewEmbeddedManifest(appManifestData) +} + +func RemoteManifest() app.Manifest { + return app.NewAPIServerManifest("{{ .ManifestData.AppName }}") +} + diff --git a/codegen/templates/templates.go b/codegen/templates/templates.go index 110bc594..9930c1e3 100644 --- a/codegen/templates/templates.go +++ b/codegen/templates/templates.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/codegen" ) @@ -34,6 +35,8 @@ var ( templateOperatorKubeconfig, _ = template.ParseFS(templates, "operator/kubeconfig.tmpl") templateOperatorMain, _ = template.ParseFS(templates, "operator/main.tmpl") templateOperatorConfig, _ = template.ParseFS(templates, "operator/config.tmpl") + + templateManifestGoFile, _ = template.ParseFS(templates, "manifest_go.tmpl") ) var ( @@ -360,6 +363,32 @@ func WriteOperatorConfig(out io.Writer) error { return templateOperatorConfig.Execute(out, nil) } +type ManifestGoFileMetadata struct { + Package string + ManifestData app.ManifestData +} + +func (ManifestGoFileMetadata) ToAdmissionOperationName(input app.AdmissionOperation) string { + switch strings.ToUpper(string(input)) { + case string(app.AdmissionOperationCreate): + return "AdmissionOperationCreate" + case string(app.AdmissionOperationUpdate): + return "AdmissionOperationUpdate" + case string(app.AdmissionOperationDelete): + return "AdmissionOperationDelete" + case string(app.AdmissionOperationConnect): + return "AdmissionOperationConnect" + case string(app.AdmissionOperationAny): + return "AdmissionOperationAny" + default: + return fmt.Sprintf("AdmissionOperation(\"%s\")", input) + } +} + +func WriteManifestGoFile(metadata ManifestGoFileMetadata, out io.Writer) error { + return templateManifestGoFile.Execute(out, metadata) +} + // ToPackageName sanitizes an input into a deterministic allowed go package name. // It is used to turn kind names or versions into package names when performing go code generation. func ToPackageName(input string) string { diff --git a/codegen/testing/golden_generated/manifest/go/testkinds/manifest.go.txt b/codegen/testing/golden_generated/manifest/go/testkinds/manifest.go.txt new file mode 100644 index 00000000..ca207fff --- /dev/null +++ b/codegen/testing/golden_generated/manifest/go/testkinds/manifest.go.txt @@ -0,0 +1,67 @@ +package generated + +import ( + "github.com/grafana/grafana-app-sdk/app" +) + +var appManifestData = app.ManifestData{ + AppName: "test-app", + Group: "test.ext.grafana.com", + Kinds: []app.ManifestKind{ + { + Kind: "TestKind", + Scope: "Namespaced", + Conversion: true, + Versions: []app.ManifestKindVersion{ + { + Name: "v1", + Admission: &app.AdmissionCapabilities{ + Validation: &app.ValidationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + }, + }, + + { + Name: "v2", + Admission: &app.AdmissionCapabilities{ + Validation: &app.ValidationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + Validation: &app.MutationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + }, + }, + }, + }, + + { + Kind: "TestKind2", + Scope: "Namespaced", + Conversion: false, + Versions: []app.ManifestKindVersion{ + { + Name: "v1", + }, + }, + }, + }, +} + +func LocalManifest() app.Manifest { + return app.NewEmbeddedManifest(appManifestData) +} + +func RemoteManifest() app.Manifest { + return app.NewAPIServerManifest("test-app") +} diff --git a/codegen/testing/golden_generated/manifest/test-app-custom-kind-2-manifest.yaml.txt b/codegen/testing/golden_generated/manifest/test-app-custom-kind-2-manifest.yaml.txt new file mode 100644 index 00000000..3b980de5 --- /dev/null +++ b/codegen/testing/golden_generated/manifest/test-app-custom-kind-2-manifest.yaml.txt @@ -0,0 +1,7 @@ +apiVersion: apps.grafana.com/v1 +kind: AppManifest +metadata: + name: test-app-custom-kind-2 +spec: + appName: test-app-custom-kind-2 + group: test-app-custom-kind-2.ext.grafana.com diff --git a/codegen/testing/golden_generated/manifest/test-app-test-kind-manifest.yaml.txt b/codegen/testing/golden_generated/manifest/test-app-test-kind-manifest.yaml.txt new file mode 100644 index 00000000..778d96da --- /dev/null +++ b/codegen/testing/golden_generated/manifest/test-app-test-kind-manifest.yaml.txt @@ -0,0 +1,33 @@ +apiVersion: apps.grafana.com/v1 +kind: AppManifest +metadata: + name: test-app-test-kind +spec: + appName: test-app-test-kind + group: test.ext.grafana.com + kinds: + - kind: TestKind + scope: Namespaced + versions: + - name: v1 + admission: + validation: + operations: + - CREATE + - UPDATE + - name: v2 + admission: + validation: + operations: + - CREATE + - UPDATE + mutation: + operations: + - CREATE + - UPDATE + conversion: true + - kind: TestKind2 + scope: Namespaced + versions: + - name: v1 + conversion: false diff --git a/go.work.sum b/go.work.sum index c3d77e7a..b9aee442 100644 --- a/go.work.sum +++ b/go.work.sum @@ -374,6 +374,8 @@ golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -423,6 +425,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go. google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240827150818-7e3bb234dfed/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.18.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=