From 69d129df39b8425e7b68b9cbad06460d92732916 Mon Sep 17 00:00:00 2001 From: Austin Pond Date: Thu, 5 Dec 2024 13:58:28 +0100 Subject: [PATCH] [App Manifest + Docs] Tutorial Doc Update for New Manifest, Associated Fixes (#525) Update the tutorial doc based on the changes from https://github.com/grafana/grafana-app-sdk/pull/483 and https://github.com/grafana/grafana-app-sdk/pull/491. Running through the tutorial again ran into a few bugs that this PR also resolves: * The template kind used `permissions` instead of `extraPermissions` * The default plugin name uses the full group, which contains `.` and is invalid. Fixed to use only the appName portion * The updated kind in `cue.def` still had a reference to `apiResource` in the version map for inheriting validation/mutation, fixed to not use apiResource * Prometheus metrics for watchers cannot be exposed in the `simple.App` design as they can for `simple.Operator`, updated `operator.OpinionatedWatcher` to expose metrics from wrapped watchers, and a method to `simple.App` to allow exposing arbitrary prometheus collectors to the runner. The tutorial front-end has also been updated to use the grafana API server, rather than the plugin back-end. The plugin back-end will be phased out of the tutorial entirely in the near future. --- .../templates/manifest.cue.tmpl | 4 +- codegen/cuekind/def.cue | 4 +- codegen/jennies/backendplugin.go | 3 +- .../go/testkinds/testapp_manifest.go.txt | 17 +- .../manifest/test-app-manifest.yaml.txt | 9 + .../issue-tracker/01-project-init.md | 26 +-- .../issue-tracker/02-defining-our-kinds.md | 19 +- .../issue-tracker/03-generate-kind-code.md | 79 +++++-- .../tutorials/issue-tracker/04-boilerplate.md | 215 +++++++++--------- .../issue-tracker/05-local-deployment.md | 152 ++++++------- docs/tutorials/issue-tracker/06-frontend.md | 4 +- .../issue-tracker/07-operator-watcher.md | 82 ++++--- docs/tutorials/issue-tracker/cue/issue-v1.cue | 20 +- docs/tutorials/issue-tracker/cue/issue-v2.cue | 20 +- docs/tutorials/issue-tracker/cue/issue-v3.cue | 26 +-- docs/tutorials/issue-tracker/cue/issue-v4.cue | 32 +-- .../frontend-files/issue-client.ts | 13 +- .../issue-tracker/frontend-files/main.tsx | 132 +++++------ operator/opinionatedwatcher.go | 18 +- simple/app.go | 15 +- 20 files changed, 453 insertions(+), 437 deletions(-) diff --git a/cmd/grafana-app-sdk/templates/manifest.cue.tmpl b/cmd/grafana-app-sdk/templates/manifest.cue.tmpl index e6793659..549c4f9c 100644 --- a/cmd/grafana-app-sdk/templates/manifest.cue.tmpl +++ b/cmd/grafana-app-sdk/templates/manifest.cue.tmpl @@ -7,9 +7,9 @@ manifest: { // kinds is the list of kinds that your app defines and manages. If your app deals with kinds defined/managed // by another app, use permissions.accessKinds to allow your app access kinds: [] - // permissions contains any additional permissions your app may require to function. + // extraPermissions contains any additional permissions your app may require to function. // Your app will always have all permissions for each kind it manages (the items defined in 'kinds'). - permissions: { + extraPermissions: { // If your app needs access to additional kinds supplied by other apps, you can list them here accessKinds: [ // Here is an example for your app accessing the playlist kind for reads and watch diff --git a/codegen/cuekind/def.cue b/codegen/cuekind/def.cue index 2a934fce..63993ced 100644 --- a/codegen/cuekind/def.cue +++ b/codegen/cuekind/def.cue @@ -139,8 +139,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 + validation: #AdmissionCapability | *S.validation + mutation: #AdmissionCapability | *S.mutation // additionalPrinterColumns is a list of additional columns to be printed in kubectl output additionalPrinterColumns?: [...#AdditionalPrinterColumns] } diff --git a/codegen/jennies/backendplugin.go b/codegen/jennies/backendplugin.go index 873234f1..052afd8e 100644 --- a/codegen/jennies/backendplugin.go +++ b/codegen/jennies/backendplugin.go @@ -2,6 +2,7 @@ package jennies import ( "bytes" + "strings" "github.com/grafana/codejen" @@ -36,7 +37,7 @@ func (m *backendPluginMainGenerator) Generate(decls ...codegen.Kind) (*codejen.F for _, decl := range decls { tmd.Resources = append(tmd.Resources, decl.Properties()) if decl.Properties().Group != "" { - tmd.PluginID = decl.Properties().Group + tmd.PluginID = strings.Split(decl.Properties().Group, ".")[0] } } diff --git a/codegen/testing/golden_generated/manifest/go/testkinds/testapp_manifest.go.txt b/codegen/testing/golden_generated/manifest/go/testkinds/testapp_manifest.go.txt index e739a7cc..7d0aa83c 100644 --- a/codegen/testing/golden_generated/manifest/go/testkinds/testapp_manifest.go.txt +++ b/codegen/testing/golden_generated/manifest/go/testkinds/testapp_manifest.go.txt @@ -33,14 +33,27 @@ var appManifestData = app.ManifestData{ Conversion: true, Versions: []app.ManifestKindVersion{ { - Name: "v1", + Name: "v1", + Admission: &app.AdmissionCapabilities{ + Validation: &app.ValidationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + }, Schema: &versionSchemaTestKindv1, }, { Name: "v2", Admission: &app.AdmissionCapabilities{ - + Validation: &app.ValidationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, Mutation: &app.MutationCapability{ Operations: []app.AdmissionOperation{ app.AdmissionOperationCreate, diff --git a/codegen/testing/golden_generated/manifest/test-app-manifest.yaml.txt b/codegen/testing/golden_generated/manifest/test-app-manifest.yaml.txt index d41fbd64..1dff627e 100644 --- a/codegen/testing/golden_generated/manifest/test-app-manifest.yaml.txt +++ b/codegen/testing/golden_generated/manifest/test-app-manifest.yaml.txt @@ -10,6 +10,11 @@ spec: scope: Namespaced versions: - name: v1 + admission: + validation: + operations: + - CREATE + - UPDATE schema: spec: properties: @@ -58,6 +63,10 @@ spec: x-kubernetes-preserve-unknown-fields: true - name: v2 admission: + validation: + operations: + - CREATE + - UPDATE mutation: operations: - CREATE diff --git a/docs/tutorials/issue-tracker/01-project-init.md b/docs/tutorials/issue-tracker/01-project-init.md index cd16b4e7..decfd62c 100644 --- a/docs/tutorials/issue-tracker/01-project-init.md +++ b/docs/tutorials/issue-tracker/01-project-init.md @@ -19,21 +19,13 @@ go install github.com/grafana/grafana-app-sdk/cmd/grafana-app-sdk@latest If you're unfamiliar with `go install`, it's similar to `go get`, but will compile a binary for the `main` package in what it pulls, and put that in `$GOPATH/bin`. If you don't have `$GOPATH/bin` in your path, you will want to add it, otherwise the CLI commands won't work for you. You can check if the CLI was installed successfully with: You can then check if the install was successful by running. -> [!NOTE] -> There is currently a [known issue with running go install](https://github.com/grafana/grafana-app-sdk/issues/189) for many versions. -> You can install locally with -> ```shell -> git clone git@github.com:grafana/grafana-app-sdk.git && cd grafana-app-sdk/cmd/grafana-app-sdk && go install -> ``` -> But be advised this will install the latest `main` commit. To install a specific version, use `git checkout ` before running `go install`. -> -> Alternatively, you can install by running `make build` in the repository root, and copying `target/grafana-app-sdk` into your `PATH`. - -If you're not comfortable using `go install`, the [github releases page](https://github.com/grafana/grafana-app-sdk/releases) for the project includes a binary for each architecture per release. You can download the binary and add it to your `PATH` to use the SDK CLI the same way as if you used `go install`. ```shell grafana-app-sdk --help ``` +> [!NOTE] +> If you're not comfortable using `go install`, the [github releases page](https://github.com/grafana/grafana-app-sdk/releases) for the project includes a binary for each architecture per release. You can download the binary and add it to your `PATH` to use the SDK CLI the same way as if you used `go install`. + Now that we have the CLI installed, let's initialize our project. In this tutorial, we're going to use `github.com/grafana/issue-tracker-project` as our go module name, but you can use whatever name you like--it won't affect anything except some imports on code that we work on later. ```shell grafana-app-sdk project init "github.com/grafana/issue-tracker-project" @@ -44,6 +36,7 @@ $ grafana-app-sdk project init "github.com/grafana/issue-tracker-project" * Writing file go.mod * Writing file go.sum * Writing file kinds/cue.mod/module.cue + * Writing file kinds/manifest.cue * Writing file Makefile * Writing file local/config.yaml * Writing file local/scripts/cluster.sh @@ -56,6 +49,10 @@ $ tree . │   └── operator ├── go.mod ├── go.sum +├── kinds +│   ├── cue.mod +│   │   └── module.cue +│   └── manifest.cue ├── local │   ├── Tiltfile │   ├── additional @@ -66,12 +63,9 @@ $ tree . │   ├── cluster.sh │   └── push_image.sh ├── pkg -├── plugin -└── kinds - └── cue.mod - └── module.cue +└── plugin -12 directories, 8 files +12 directories, 9 files ``` As we can see from the command output, and the `tree` command, the `project init` command created a go module in the current directory, a Makefile, and several other directories. diff --git a/docs/tutorials/issue-tracker/02-defining-our-kinds.md b/docs/tutorials/issue-tracker/02-defining-our-kinds.md index f4391f9d..74214c78 100644 --- a/docs/tutorials/issue-tracker/02-defining-our-kinds.md +++ b/docs/tutorials/issue-tracker/02-defining-our-kinds.md @@ -22,8 +22,7 @@ package kinds issue: { kind: "Issue" - group: "issue-tracker-project" - apiResource: {} + scope: "Namespaced" codegen: { frontend: true backend: true @@ -52,7 +51,7 @@ Now, we get to the actual definition of our `issue` model: ```cue issue: { kind: "Issue" - apiResource: {} + scope: "Namespaced" codegen: { frontend: true backend: true @@ -63,13 +62,13 @@ issue: { ``` Here we have a collection of metadata relating to our model, and then a `versions` definition, which we'll get to in a moment. Let's break down the other fields first: -| Snippit | Meaning | -|---------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `kind: "Issue"` | This is just the human-readable name, which will be used for naming some things in-code. | -| `apiResource: {}` | This field is an indicator that the kind is expressible as an API Server resource (typically, a Custom Resource Definition). From a codegen perspective, that means that generated go code will be compatible with `resource`-package interfaces. There are fields that can be set within `apiResource`, but we're ok with its default values | - | `codegen.frontend: true` | This instructs the CLI to generate front-end (TypeScript interfaces) code for our schema. This defaults to `true`, but we're specifying it here for clarity. | -| `codegen.backend: true` | This instructs the CLI to generate back-end (go) code for our schema. This defaults to `true`, but we're specifying it here for clarity. | -| `currentVersion: [0,0]` | This is the current version of the Schema to use for codegen (will default to the latest if not present) | +| Snippit | Meaning | +|---------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `kind: "Issue"` | This is just the human-readable name, which will be used for naming some things in-code. | +| `scope: "Namespaced"` | This is an optional field, which designates whether instances of the kind (resources) are created on a per-tenant basis (Namespaced) or globally (Cluster). It defaults to Namespaced if you leave the field out. | + | `codegen.frontend: true` | This instructs the CLI to generate front-end (TypeScript interfaces) code for our schema. This defaults to `true`, but we're specifying it here for clarity. | +| `codegen.backend: true` | This instructs the CLI to generate back-end (go) code for our schema. This defaults to `true`, but we're specifying it here for clarity. | +| `current: "v1"` | This is the current version of the Schema to use for codegen. | ok, now back to `versions`. In the CUE kind, `versions` is a map of a version name to an object containing meta-information about the version, and its `schema`. The map is unordered, so there is no implicit or explicit sequential relationship between versions in your Kind definition, but consider naming conventions that portray the evolution of the kind (for example, `v1`, `v1beta`, `v2`, etc. like kubernetes' kind versions). The `current` top-level Kind attribute declares which version in this map is the current, diff --git a/docs/tutorials/issue-tracker/03-generate-kind-code.md b/docs/tutorials/issue-tracker/03-generate-kind-code.md index 40a5e5bc..4c21d8d8 100644 --- a/docs/tutorials/issue-tracker/03-generate-kind-code.md +++ b/docs/tutorials/issue-tracker/03-generate-kind-code.md @@ -1,10 +1,50 @@ # Generating Kind Code -Now that we have our kind and schema defined, we want to generate code from them that we can use. In the future, we'll want to re-generate this code whenever we change anything in our `kinds` directory. The SDK provides a command for this: `grafana-app-sdk generate`, but our project init also gave us a make target which will do the same thing, so you can run either. Here, I'm running the make target: +Now that we have our kind and schema defined, we want to generate code from them that we can use. +To do this, we need to add the kind to our **manifest**. The manifest is information about our app, +including the kinds it provides. If we don't add our kind to the manifest, it isn't considered part of our app. + +Our project init created a default manifest for us, found in `./kinds/manifest.cue`: +```cue +package kinds + +manifest: { + // appName is the unique name of your app. It is used to reference the app from other config objects, + // and to generate the group used by your app in the app platform API. + appName: "issue-tracker-project" + // kinds is the list of kinds that your app defines and manages. If your app deals with kinds defined/managed + // by another app, use permissions.accessKinds to allow your app access + kinds: [] + // extraPermissions contains any additional permissions your app may require to function. + // Your app will always have all permissions for each kind it manages (the items defined in 'kinds'). + extraPermissions: { + // If your app needs access to additional kinds supplied by other apps, you can list them here + accessKinds: [ + // Here is an example for your app accessing the playlist kind for reads and watch + // { + // group: "playlist.grafana.app" + // resource: "playlists" + // actions: ["get","list","watch"] + // } + ] + } +} +``` + +By default, the `appName` is the final part of our module path. We don't need to worry about `extraPermissions`, as our app doesn't need to work with any other apps' kinds. +However, right now, our `kinds` list is empty. To add our kind to the manifest, we just need to add the field name of our kind: +```cue +kinds: [issue] +``` + +> [!TIP] +> If you add a new kind to your project with the command `grafana-app-sdk project kind add `, it will automatically add it to the manifest as well + +In the future, we'll want to re-generate this code whenever we change anything in our `kinds` directory. The SDK provides a command for this: `grafana-app-sdk generate`, but our project init also gave us a make target which will do the same thing, so you can run either. Here, I'm running the make target: ```shell make generate ``` -This command should ouput a list of all the files it writes: +This command should output a list of all the files it writes: ```shell $ make generate * Writing file pkg/generated/resource/issue/v1/issue_codec_gen.go @@ -17,7 +57,9 @@ $ make generate * Writing file plugin/src/generated/issue/v1/types.metadata.gen.ts * Writing file plugin/src/generated/issue/v1/types.spec.gen.ts * Writing file plugin/src/generated/issue/v1/types.status.gen.ts - * Writing file definitions/issue.issue-tracker-project.ext.grafana.com.json + * Writing file definitions/issue.issuetrackerproject.ext.grafana.com.json + * Writing file definitions/issue-tracker-project-manifest.json + * Writing file pkg/generated/issuetrackerproject_manifest.go ``` That's a bunch of files written! Let's tree the directory to understand the structure a bit better. ```shell @@ -27,13 +69,15 @@ $ tree . ├── cmd │   └── operator ├── definitions -│   └── issue.issue-tracker-project.ext.grafana.com.json +│   ├── issue-tracker-project-manifest.json +│   └── issue.issuetrackerproject.ext.grafana.com.json ├── go.mod ├── go.sum ├── kinds │   ├── cue.mod │   │   └── module.cue -│   └── issue.cue +│   ├── issue.cue +│   └── manifest.cue ├── local │   ├── Tiltfile │   ├── additional @@ -45,6 +89,7 @@ $ tree . │   └── push_image.sh ├── pkg │   └── generated +│   ├── issuetrackerproject_manifest.go │   └── resource │   └── issue │   └── v1 @@ -64,15 +109,11 @@ $ tree . ├── types.spec.gen.ts └── types.status.gen.ts -21 directories, 20 files +21 directories, 23 files ``` -So we can now see that all our generated go code lives in the `pkg/generated` package. Since our `target` was `"resource"`, the generated code for `issue` is in the `pkg/generated/resource` package. -Each `resource`-target kind then lives in a package defined by the name of the kind: in our case, that is `issue`. If we created another kind in our `kinds` directory called "foo", we'd see a `pkg/generated/resource/foo` directory. - -If we had a separate kind which didn't have the `apiResource` field, we'd see a `pkg/generated/models` package directory. We'll see that later, in our follow-up, where we extend on the project. - -Note that the go types are in versioned packages, as by default the SDK will generate types for each version of the kind. +All of our generated go code lives in `pkg/generated`, and all the generated TypeScript lives in `plugin/src/generated`. +We also have some JSON files in `definitions` Note that we also have generated TypeScript in our previously-empty `plugin` directory. By convention, the Grafana plugin for your project will live in the `plugin` directory, so here we've got some TypeScript generated in `plugin/src/generated` to use when we start working on the front-end of our plugin. @@ -85,6 +126,7 @@ Let's take a closer look at the list of files: ```shell $ tree pkg/generated pkg/generated +├── issuetrackerproject_manifest.go └── resource └── issue └── v1 @@ -95,7 +137,7 @@ pkg/generated ├── issue_spec_gen.go └── issue_status_gen.go -4 directories, 6 files +4 directories, 7 files ``` The exported go types from our kind's `v1` schema definition are `issue_spec_gen.go` and `issue_status_gen.go`. @@ -108,6 +150,10 @@ Likewise, `issue_schema_gen.go` defines a `resource.Schema` for this specific ve in addition to a `resource.Kind` for the kind. Finally, `issue_codec_gen.go` contains code for a kubernetes-JSON-bytes<->Issue `Object` codec, which is used by the `Kind` for marshaling and unmarshaling our Object when interacting with the API server. +Finally, we have `issuetrackerproject_manifest.go` in the `pkg/generated` directory. This contains an in-code version of our manifest. +As we'll see a bit later, the manifest is also used in code to communicate app capabilities, so we have this in-code representation, +as well as an API server one. + ## Generated TypeScript Code ```shell @@ -135,12 +181,15 @@ Finally, we have the custom resource definition file that describes our `issue` Note that this is a CRD of the kind, not just the schema, so the CRD will contain all schema versions in the kind. This can be used to set up kubernetes as our storage layer for our project. +We also have a manifest here, which will be used by the grafana API server in the future to register the app. + ```shell $ tree definitions definitions -└── issue.issue-tracker-project.ext.grafana.com.json +├── issue-tracker-project-manifest.json +└── issue.issuetrackerproject.ext.grafana.com.json -1 directory, 1 file +1 directory, 2 files ``` So now we have a bunch of generated code, but we still need a project to actually use it in. diff --git a/docs/tutorials/issue-tracker/04-boilerplate.md b/docs/tutorials/issue-tracker/04-boilerplate.md index 6bba75fc..2ced3f3b 100644 --- a/docs/tutorials/issue-tracker/04-boilerplate.md +++ b/docs/tutorials/issue-tracker/04-boilerplate.md @@ -46,43 +46,12 @@ Let's give our plugin an ID (I'm going to use `issue-tracker-project`, but you c ```shell grafana-app-sdk project component add frontend backend operator --plugin-id="issue-tracker-project" ``` -Just like with any other command that writes files, the output is a list of all written files: +Just like with any other command that writes files, the output is a list of all written files, though the front-end files are created with `yarn` and are not listed. ```shell $ grafana-app-sdk project component add frontend backend operator --plugin-id="issue-tracker-project" - * Writing file plugin/.config/.eslintrc - * Writing file plugin/.config/.prettierrc.js - * Writing file plugin/.config/Dockerfile - * Writing file plugin/.config/README.md - * Writing file plugin/.config/jest/mocks/react-inlinesvg.tsx - * Writing file plugin/.config/jest/utils.js - * Writing file plugin/.config/jest-setup.js - * Writing file plugin/.config/jest.config.js - * Writing file plugin/.config/tsconfig.json - * Writing file plugin/.config/types/custom.d.ts - * Writing file plugin/.config/webpack/constants.ts - * Writing file plugin/.config/webpack/utils.ts - * Writing file plugin/.config/webpack/webpack.config.ts - * Writing file plugin/.eslintrc - * Writing file plugin/.nvmrc - * Writing file plugin/.prettierrc.js - * Writing file plugin/CHANGELOG.md - * Writing file plugin/LICENSE - * Writing file plugin/README.md - * Writing file plugin/jest-setup.js - * Writing file plugin/jest.config.js - * Writing file plugin/src/App.tsx - * Writing file plugin/src/components/Routes/Routes.tsx - * Writing file plugin/src/components/Routes/index.tsx - * Writing file plugin/src/module.ts - * Writing file plugin/src/pages/index.tsx - * Writing file plugin/src/pages/main.tsx - * Writing file plugin/src/types.ts - * Writing file plugin/src/utils/utils.plugin.ts - * Writing file plugin/src/utils/utils.routing.ts - * Writing file plugin/tsconfig.json +Creating plugin frontend using `yarn create @grafana/plugin` (this may take a moment)... * Writing file plugin/src/plugin.json * Writing file plugin/src/constants.ts - * Writing file plugin/package.json * Writing file plugin/pkg/main.go * Writing file pkg/plugin/handler_issue.go * Writing file pkg/plugin/plugin.go @@ -98,64 +67,79 @@ $ grafana-app-sdk project component add frontend backend operator --plugin-id="i * Writing file pkg/watchers/watcher_issue.go * Writing file cmd/operator/Dockerfile ``` -Wow, that's a lot more files written out than in our Kind codegen. Let's take a look at the tree to get a better picture of everything: +Let's take a look at the tree to get a better picture of everything: ```shell $ tree -I "generated|definitions|kinds|local" . . ├── Makefile ├── cmd -│ └── operator -│ ├── Dockerfile -│ ├── config.go -│ ├── kubeconfig.go -│ └── main.go +│   └── operator +│   ├── Dockerfile +│   ├── config.go +│   ├── kubeconfig.go +│   └── main.go ├── go.mod ├── go.sum ├── pkg -│ ├── app -│ │ └── app.go -│ ├── plugin -│ │ ├── handler_issue.go -│ │ ├── plugin.go -│ │ └── secure -│ │ ├── data.go -│ │ ├── middleware.go -│ │ └── retriever.go -│ └── watchers -│ ├── watcher_foo.go -│ └── watcher_issue.go +│   ├── app +│   │   └── app.go +│   ├── plugin +│   │   ├── handler_issue.go +│   │   ├── plugin.go +│   │   └── secure +│   │   ├── data.go +│   │   ├── middleware.go +│   │   └── retriever.go +│   └── watchers +│   └── watcher_issue.go └── plugin ├── CHANGELOG.md ├── LICENSE ├── Magefile.go ├── README.md + ├── docker-compose.yaml ├── jest-setup.js ├── jest.config.js ├── package.json ├── pkg - │ └── main.go + │   └── main.go + ├── playwright.config.ts + ├── provisioning + │   └── plugins + │   ├── README.md + │   └── apps.yaml ├── src - │ ├── App.tsx - │ ├── components - │ │ └── Routes - │ │ ├── Routes.tsx - │ │ └── index.tsx - │ ├── constants.ts - │ ├── module.ts - │ ├── pages - │ │ ├── index.tsx - │ │ └── main.tsx - │ ├── plugin.json - │ ├── types.ts - │ └── utils - │ ├── utils.plugin.ts - │ └── utils.routing.ts + │   ├── README.md + │   ├── components + │   │   ├── App + │   │   │   ├── App.test.tsx + │   │   │   └── App.tsx + │   │   ├── AppConfig + │   │   │   ├── AppConfig.test.tsx + │   │   │   └── AppConfig.tsx + │   │   └── testIds.ts + │   ├── constants.ts + │   ├── img + │   │   └── logo.svg + │   ├── module.tsx + │   ├── pages + │   │   ├── PageFour.tsx + │   │   ├── PageOne.tsx + │   │   ├── PageThree.tsx + │   │   └── PageTwo.tsx + │   ├── plugin.json + │   └── utils + │   └── utils.routing.ts + ├── tests + │   ├── appConfig.spec.ts + │   ├── appNavigation.spec.ts + │   └── fixtures.ts └── tsconfig.json -15 directories, 35 files +20 directories, 45 files ``` -Excluding our previously-generated files, we can see that we have a few new go packages (`pkg/watchers`, `pkg/plugin`, and `pkg/plugin/secure`), some go files and a Dockerfile in `cmd/operator`, and a bunch of new stuff in the `plugin` directory. +Excluding our previously-generated files, we can see that we have a few new go packages (`pkg/app`, `pkg/watchers`, `pkg/plugin`, and `pkg/plugin/secure`), some go files and a Dockerfile in `cmd/operator`, and a bunch of new stuff in the `plugin` directory. If we had split up our `project add` into `project add backend`, we'd only get our generated go files in `pkg/plugin`, `project add frontend` would only give us the non-`plugin/pkg` plugin files, and `project add operator` would give us the `pkg/watchers` and `cmd/operator` files. As we can see, none of these `project add` components have overlapping code, which is deliberate. If you prefer to not use boilerplate for a given component, you can simply not add it and not worry that another component will depend on boilerplate from it. @@ -318,37 +302,53 @@ plugin ├── LICENSE ├── Magefile.go ├── README.md +├── docker-compose.yaml ├── jest-setup.js ├── jest.config.js ├── package.json ├── pkg │   └── main.go +├── playwright.config.ts +├── provisioning +│   └── plugins +│   ├── README.md +│   └── apps.yaml ├── src -│   ├── App.tsx +│   ├── README.md │   ├── components -│   │   └── Routes -│   │   ├── Routes.tsx -│   │   └── index.tsx +│   │   ├── App +│   │   │   ├── App.test.tsx +│   │   │   └── App.tsx +│   │   ├── AppConfig +│   │   │   ├── AppConfig.test.tsx +│   │   │   └── AppConfig.tsx +│   │   └── testIds.ts │   ├── constants.ts │   ├── generated │   │   └── issue │   │   └── v1 -│   │   └── issue_object_gen.ts -│   │   └── types.metadata.gen.ts -│   │   └── types.spec.gen.ts +│   │   ├── issue_object_gen.ts +│   │   ├── types.metadata.gen.ts +│   │   ├── types.spec.gen.ts │   │   └── types.status.gen.ts -│   ├── module.ts +│   ├── img +│   │   └── logo.svg +│   ├── module.tsx │   ├── pages -│   │   ├── index.tsx -│   │   └── main.tsx +│   │   ├── PageFour.tsx +│   │   ├── PageOne.tsx +│   │   ├── PageThree.tsx +│   │   └── PageTwo.tsx │   ├── plugin.json -│   ├── types.ts │   └── utils -│   ├── utils.plugin.ts │   └── utils.routing.ts +├── tests +│   ├── appConfig.spec.ts +│   ├── appNavigation.spec.ts +│   └── fixtures.ts └── tsconfig.json -10 directories, 21 files +15 directories, 35 files ``` We can also safely _ignore_ a lot of this generation. If you create a grafana plugin, there's a certain amount of metadata that needs to be created, and, likewise, when you create a react app (which front-end plugins are), there's some other data that needs to exist. So basically everything in the root `plugin` directory is something we can ignore for the moment, as it's just things telling either grafana how to handle this app, or react how to compile it. But, as a quick breakdown, here's what each file does that we're going to ignore: @@ -365,44 +365,33 @@ That leaves us with just our varying TypeScript files. ### Pages -`pages/` contains the acual front-end pages to be displayed for the app. `main.tsx` is your main plugin page, which by default just contains a simple statement declaring it your main landing page: +`pages/` contains the actual front-end pages to be displayed for the app. `PageOne.tsx` is your main plugin page, which by default just contains a simple statement declaring it your main landing page: ```TypeScript -export const MainPage = () => { - useStyles2(getStyles); - - return ( -
-

Main Landing Page

-
This is your main landing page
-
- ); -}; +function PageOne() { + const s = useStyles2(getStyles); + + return ( + +
+ This is page one. +
+ + Full-width page example + +
+
+
+); +} ``` +There are other pages generated here as examples from the output of `yarn create @grafana/plugin`. The routing for these pages is in `components/App/App.tsx`. `MainPage` is used by the router when displaying pages--you can add more by creating other exported functions and registering them in the router. -### Router - -`components/Routes/Router.tsx` contains the router for your app frontend. By default only the `MainPage` is routed, and matches any path: -```TypeScript -export const Routes = () => { - useNavigation(); - - return ( - - - - {/* Default page */} - - - - - ); -}; -``` +### Components -`ROUTES.Main` is a constant pulled from `constants.ts`. `useNavigation` and `prefixRoute` are pulled from `utils`. +`components` contains your front-end App declaration and AppConfig. ### Types diff --git a/docs/tutorials/issue-tracker/05-local-deployment.md b/docs/tutorials/issue-tracker/05-local-deployment.md index 0ff37221..0b036fed 100644 --- a/docs/tutorials/issue-tracker/05-local-deployment.md +++ b/docs/tutorials/issue-tracker/05-local-deployment.md @@ -21,7 +21,7 @@ make deps ``` This will `go get` all the go modules we use, and then vendor them in the `vendor` directory. -Now we can run our biuld successfully: +Now we can run our build successfully: ```shell make build ``` @@ -224,112 +224,87 @@ Since our plugin is automatically installed, we can go to [grafana.k3d.localhost Right now, if I do a curl to our list endpoint, we'll get back a response with an empty list: ```shell $ curl http://grafana.k3d.localhost:9999/api/plugins/issue-tracker-project-app/resources/v1/issues | jq . -``` { - "kind": "IssueList", - "apiVersion": "issue-tracker-project.ext.grafana.com/v1", - "metadata": { - "resourceVersion": "1079" - }, - "items": [] + "kind": "IssueList", + "apiVersion": "issuetrackerproject.ext.grafana.com/v1", + "metadata": { + "resourceVersion": "1159" + }, + "items": [] } ``` -Just to demonstrate our API surface, we can try creating a new issue from the API: +Our kinds are also available via the grafana API server, located at [http://grafana.k3d.localhost:9999/apis]. This is a kubernetes-compatible API server, and we can interact with it via cURL, or kubectl. +Let's also list our issues that way: ```shell -$ curl -X POST -H "contant-type:application/json" -d '{"metadata":{"name":"test-issue","namespace":"default"},"spec":{"title":"Test","description":"A test issue","status":"open"}}' http://grafana.k3d.localhost:9999/api/plugins/issue-tracker-project-app/resources/v1/issues -{"kind":"Issue","apiVersion":"issue-tracker-project.ext.grafana.com/v1","metadata":{"name":"test-issue","namespace":"default","uid":"027d84d6-4b0d-4154-8343-83a9280089d1","resourceVersion":"1104","generation":1,"creationTimestamp":"2024-02-21T17:05:32Z","labels":{"grafana-app-sdk-resource-version":"v1"},"managedFields":[{"manager":"gpx_issue-tracker-project-app_linux_arm64","operation":"Update","apiVersion":"issue-tracker-project.ext.grafana.com/v1","time":"2024-02-21T17:05:32Z","fieldsType":"FieldsV1","fieldsV1":{"f:metadata":{"f:labels":{".":{},"f:grafana-app-sdk-resource-version":{}}},"f:spec":{".":{},"f:description":{},"f:status":{},"f:title":{}}}}]},"spec":{"description":"A test issue","status":"open","title":"Test"},"status":{}} -$ curl http://grafana.k3d.localhost:9999/api/plugins/issue-tracker-project-app/resources/v1/issues | jq . +curl http://grafana.k3d.localhost:9999/apis/issuetrackerproject.ext.grafana.com/v1/namespaces/default/issues { + "apiVersion": "issuetrackerproject.ext.grafana.com/v1", + "items": [], "kind": "IssueList", - "apiVersion": "issue-tracker-project.ext.grafana.com/v1", "metadata": { - "resourceVersion": "1109" - }, - "items": [ - { - "kind": "Issue", - "apiVersion": "issue-tracker-project.ext.grafana.com/v1", - "metadata": { - "name": "test-issue", - "namespace": "default", - "uid": "027d84d6-4b0d-4154-8343-83a9280089d1", - "resourceVersion": "1105", - "generation": 1, - "creationTimestamp": "2024-02-21T17:05:32Z", - "labels": { - "grafana-app-sdk-resource-version": "v1" - }, - "finalizers": [ - "issue-tracker-project-operator-issues-finalizer" - ], - "managedFields": [ - { - "manager": "gpx_issue-tracker-project-app_linux_arm64", - "operation": "Update", - "apiVersion": "issue-tracker-project.ext.grafana.com/v1", - "time": "2024-02-21T17:05:32Z", - "fieldsType": "FieldsV1", - "fieldsV1": { - "f:metadata": { - "f:labels": { - ".": {}, - "f:grafana-app-sdk-resource-version": {} - } - }, - "f:spec": { - ".": {}, - "f:description": {}, - "f:status": {}, - "f:title": {} - } - } - }, - { - "manager": "operator", - "operation": "Update", - "apiVersion": "issue-tracker-project.ext.grafana.com/v1", - "time": "2024-02-21T17:05:32Z", - "fieldsType": "FieldsV1", - "fieldsV1": { - "f:metadata": { - "f:finalizers": { - ".": {}, - "v:\"issue-tracker-project-operator-issues-finalizer\"": {} - } - } - } + "continue": "", + "resourceVersion": "1159" + } +} +``` +We can see the output is nearly identical, as the plugin backend is just a proxy to the API server. From this point, we could use the plugin backend or API server API, +but seeing as the plugin backend will eventually be phased out of the default path, let's use the API server here, and create an Issue: +```shell +$ curl -X POST -H "content-type:application/json" -d '{"kind":"Issue","apiVersion":"issuetrackerproject.ext.grafana.com/v1","metadata":{"name":"test-issue","namespace":"default"},"spec":{"title":"Test","description":"A test issue","status":"open"}}' http://grafana.k3d.localhost:9999/apis/issuetrackerproject.ext.grafana.com/v1/namespaces/default/issues +{ + "apiVersion": "issuetrackerproject.ext.grafana.com/v1", + "kind": "Issue", + "metadata": { + "creationTimestamp": "2024-12-04T23:44:22Z", + "generation": 1, + "managedFields": [ + { + "apiVersion": "issuetrackerproject.ext.grafana.com/v1", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:spec": { + ".": {}, + "f:description": {}, + "f:status": {}, + "f:title": {} } - ] - }, - "spec": { - "description": "A test issue", - "status": "open", - "title": "Test" - }, - "status": {} - } - ] + }, + "manager": "curl", + "operation": "Update", + "time": "2024-12-04T23:44:22Z" + } + ], + "name": "test-issue", + "namespace": "default", + "resourceVersion": "1408", + "uid": "c3c7c651-324f-41f0-8ddd-85daa25195d8" + }, + "spec": { + "description": "A test issue", + "status": "open", + "title": "Test" + } } ``` +Now if we list issues again, we'll see the issue we just made in the output. You can see that this includes metadata which we didn't define in our CUE, but was implicitly added (kubernetes metadata). For fun, we can also interact with our resources through kubectl: ```shell $ kubectl get issue test-issue -o yaml +apiVersion: issuetrackerproject.ext.grafana.com/v1 kind: Issue metadata: - creationTimestamp: "2024-02-21T17:05:32Z" + creationTimestamp: "2024-12-04T23:44:22Z" finalizers: - - issue-tracker-project-operator-issues-finalizer + - issue-tracker-project-issues-finalizer generation: 1 - labels: - grafana-app-sdk-resource-version: v1 name: test-issue namespace: default - resourceVersion: "1105" - uid: 027d84d6-4b0d-4154-8343-83a9280089d1 + resourceVersion: "1409" + uid: c3c7c651-324f-41f0-8ddd-85daa25195d8 spec: description: A test issue status: open @@ -343,18 +318,19 @@ with the option to extend them for custom metadata. We can also see that the operator is monitoring adds/updates/deletes to our issues if we take a look at its logs, either through the Tilt console, or via kubectl: ``` -Added {default test-issue} +{"time":"2024-12-04T23:44:22.388481222Z","level":"DEBUG","msg":"Added resource","name":"test-issue","traceID":"cfffb1cf88e0370ad48a185b3a321881"} ``` If we delete the issue, we'll also see that delete show up in our operator: ```shell -$ curl -X DELETE http://grafana.k3d.localhost:9999/api/plugins/issue-tracker-project-app/resources/v1/issues/test-issue +kubectl delete issue test-issue ``` Now the operator logs show: ``` -Added {default test-issue} -Deleted {default test-issue} +{"time":"2024-12-04T23:44:22.388481222Z","level":"DEBUG","msg":"Added resource","name":"test-issue","traceID":"cfffb1cf88e0370ad48a185b3a321881"} +{"time":"2024-12-04T23:46:47.696342678Z","level":"DEBUG","msg":"Deleted resource","name":"test-issue","traceID":"cf4bfc9b933da55ef6cea6187689f814"} ``` +(plus some other logging) Alright! We have a local test environment that we can keep up and running if we like for the rest of the tutorial, or we can stop it. To take down the deployed resources from the cluster, you can use `make local/down`. To delete the cluster entirely, you can use `make local/clean`. For the rest of this tutorial, we're going to assume the local environment is still up and running. Now, it's time to actually write some code, [starting with fleshing out our plugin's front-end](06-frontend.md). diff --git a/docs/tutorials/issue-tracker/06-frontend.md b/docs/tutorials/issue-tracker/06-frontend.md index 6a4c13db..626bbde4 100644 --- a/docs/tutorials/issue-tracker/06-frontend.md +++ b/docs/tutorials/issue-tracker/06-frontend.md @@ -17,9 +17,9 @@ The client uses grafana libraries to make fetch requests to perform relevent act ## Main Page We already have a very empty generated main page located at `plugin/src/pages/main.tsx`. We're going to overwrite all of this with new contents. -Either copy [the contents of this file](frontend-files/main.tsx) into `plugin/src/pages/main.tsx` (overwriting the current contents), or do it with curl: +Either copy [the contents of this file](frontend-files/main.tsx) into `plugin/src/pages/PageOne.tsx` (overwriting the current contents), or do it with curl: ```bash -curl -o plugin/src/pages/main.tsx https://raw.githubusercontent.com/grafana/grafana-app-sdk/main/docs/tutorials/issue-tracker/frontend-files/main.tsx +curl -o plugin/src/pages/PageOne.tsx https://raw.githubusercontent.com/grafana/grafana-app-sdk/main/docs/tutorials/issue-tracker/frontend-files/main.tsx ``` We've now updated the main page to display a list of our `Issue` resources, with some basic options. diff --git a/docs/tutorials/issue-tracker/07-operator-watcher.md b/docs/tutorials/issue-tracker/07-operator-watcher.md index ff1bb675..41c0d88e 100644 --- a/docs/tutorials/issue-tracker/07-operator-watcher.md +++ b/docs/tutorials/issue-tracker/07-operator-watcher.md @@ -7,20 +7,34 @@ By default, the operator is run as a separate container alongside your grafana d The operator is a logical pattern which runs one or more controllers. The typical use-case for a controller is the `operator.InformerController`, which holds: * One or more informers, which subscribe to events for a particular resource kind and namespace * One or more watchers, which consume events for particular kinds -In our case, the boilerplate code uses the `simple.Operator` operator type, which handles dealing with tying together informers and watchers (or reconcilers) in an `InformerController` for us in `cmd/operator/main.go`: + +Your app can be run as a standalone operator by making use of the `operator.Runner`. This takes the functionality exposed by your app, and runs it as a standard kubernetes operator, +including exposing webhooks for the kubernetes API server to hook into. Other means of running the app (runners) will be introduced at a later date. + +In our case, the boilerplate code uses the `simple.App` to build our app, and the `operator.Runner` as the runner. +Since the runner only exposes behavior from the app in an expected way for the runner type, the watcher for our `Issue` type is where we build the App, +in `pkg/app/app.go`: ```golang -issueWatcher, err := watchers.NewIssueWatcher() -if err != nil { - logging.DefaultLogger.With("error", err).Error("Unable to create IssueWatcher") - panic(err) +func New(cfg app.Config) (app.App, error) { + issueWatcher, err := watchers.NewIssueWatcher() + if err != nil { + return nil, fmt.Errorf("unable to create IssueWatcher: %w", err) + } + + config := simple.AppConfig{ + // Trimmed + ManagedKinds: []simple.AppManagedKind{ + { + Kind: issuev1.Kind(), + Watcher: issueWatcher, + }, + }, } -err = runner.WatchKind(issue.Kind(), issueWatcher, simple.ListWatchOptions{ - Namespace: resource.NamespaceAll, -}) ``` -This code creates a watcher for receiving events, and then has the `simple.Operator` attach it to its own internal controller, and create an informer for it that uses the filtering provided in `simple.ListWatchOptions`. As a nice addition to this, `simpple.Operator.WatchKind` also wraps our `IssueWatcher` in an `operator.OpinionatedWatcher`, which handles making sure the operator is always in-the-know on events via finalizers (so, for example, a `delete` event is blocked from completing until it gets processed by our operator). +This code creates a watcher for receiving events, and then has the `simple.App` attach it to its own internal controller for the kind. +If we wanted to introduce filtering (for example, only watch a specific namespace, or label value), we could add `ReconcileOptions` to the `AppManagedKind`. -The other thing we're doing here is calling `watchers.NewIssueWatcher()`. The `watchers` package was added by our `project add operator` command, so let's take a look at what's there: +Since the way we make our Issue watcher is by calling `watchers.NewIssueWatcher()`, let's take a look at the `watchers` package (which was added by our `project add operator` command), and open `pkg/watchers/watcher_issue.go`: ```go var _ operator.ResourceWatcher = &IssueWatcher{} @@ -41,11 +55,11 @@ func (s *IssueWatcher) Add(ctx context.Context, rObj resource.Object) error { } // TODO - fmt.Println("Added ", object.StaticMetadata().Identifier()) + logging.FromContext(ctx).Debug("Added resource", "name", object.GetStaticMetadata().Identifier().Name) return nil } ``` -Each method does a check to see if the provided `resource.Object` is of type `*issue.Issue` (it always should be, provided we gave the informer a client with the correct `resource.Schema`). We then just print a line declaring what resource was added, which we saw when [testing our local deployment](05-local-deployment.md). +Each method does a check to see if the provided `resource.Object` is of type `*issue.Issue` (it always should be, provided we attached this watcher to the correct `resource.Kind`). We then just log a line declaring what resource was added, which we saw when [testing our local deployment](05-local-deployment.md). So what else can we do in our watcher? @@ -172,25 +186,9 @@ func (s *IssueWatcher) PrometheusCollectors() []prometheus.Collector { return []prometheus.Collector{s.statsGauge} } ``` -Easy! Now all we need to do is register it with our operator so it can expose it. The `simple.Operator` provides a method we can call in our `main` function, which we can add right after the part that creates and adds the watcher with `runner.WatchKind` (`runner` is the variable name for our `simple.Operator`): -```go -// Wrap our resource watchers in OpinionatedWatchers, then add them to the controller -issueWatcher, err := watchers.NewIssueWatcher() -if err != nil { - logging.DefaultLogger.With("error", err).Error("Unable to create IssueWatcher") - panic(err) -} -err = runner.WatchKind(issue.Kind(), issueWatcher, simple.ListWatchOptions{ - Namespace: resource.NamespaceAll, -}) -if err != nil { - logging.DefaultLogger.With("error", err).Error("Error adding Issue watcher to controller") - panic(err) -} -// Register prometheus collectors from the watcher -runner.RegisterMetricsCollectors(issueWatcher.PrometheusCollectors()...) -``` -There we go! Now we'll be exposing the metric through the operator's `/metrics` prometheus scrape endpoint. Our local deployment already scrapes our operator, so if we re-build and re-deploy our operator, we'll start picking up that new metric in our local grafana. +Easy! Now, since we implemented that interface, the metrics will be automatically picked up by the app. +The operator runner then exposes the app's metrics via a `/metrics` endpoint, which the local setup automatically scrapes. +So if we re-build and re-deploy our operator, we'll start picking up that new metric in our local grafana. ```shell make build/operator && make local/push_operator ``` @@ -275,13 +273,13 @@ if _, err := s.issueStore.UpdateSubresource(ctx, object.GetStaticMetadata().Iden ``` Now that our watcher methods are performing an action that can result in an error, we want to return said error if it occurs, so that the action can be retried. We want to do this _before_ our gauge code, because if we return an error and retry, we don't want to increase the gauge again. We also want this timestamp to reflect the last time we updated the gauge. -We can't re-build our operator yet, because now our `NewIssueWatcher` function requires an argument: a `resource.ClientGenerator`. A `ClientGenerator` is an object which can return `resource.Client` implementations for a provided kind. We can generate one from scratch for `k8s.Client` with `k8s.NewClientRegistry`, but `simple.NewOperator` actually already does this for us, and we can access the `ClientGenerator` it created with the `ClientGenerator()` method. So our line in `main.go` just needs to go from this: +We can't re-build our operator yet, because now our `NewIssueWatcher` function requires an argument: a `resource.ClientGenerator`. A `ClientGenerator` is an object which can return `resource.Client` implementations for a provided kind. We can generate one from scratch for `k8s.Client` with `k8s.NewClientRegistry`, using a kubernetes config, such as the one provided by the app config: ```go issueWatcher, err := watchers.NewIssueWatcher() ``` to this: ```go -issueWatcher, err := watchers.NewIssueWatcher(runner.ClientGenerator()) +issueWatcher, err := watchers.NewIssueWatcher(k8s.NewClientRegistry(cfg.KubeConfig, k8s.DefaultClientConfig())) ``` Now, because we altered the schema of our kind, we'll need to re-deploy our local environment after re-building and pushing our operator: ```shell @@ -292,35 +290,33 @@ make local/down && make local/up ``` Now, if you make a new issue, you'll see the `status.processedTimestamp` get updated. ```shell -echo '{"kind":"Issue","apiVersion":"issue-tracker-project.ext.grafana.com/v1","metadata":{"name":"test-issue","namespace":"default"},"spec":{"title":"Foo","description":"bar","status":"open"}}' | kubectl create -f - +echo '{"kind":"Issue","apiVersion":"issuetrackerproject.ext.grafana.com/v1","metadata":{"name":"test-issue","namespace":"default"},"spec":{"title":"Foo","description":"bar","status":"open"}}' | kubectl create -f - ``` ``` % kubectl get issue test-issue -oyaml -apiVersion: issue-tracker-project.ext.grafana.com/v1 +apiVersion: issuetrackerproject.ext.grafana.com/v1 kind: Issue metadata: - creationTimestamp: "2024-04-29T16:43:32Z" + creationTimestamp: "2024-12-05T00:52:46Z" finalizers: - - issue-tracker-project-operator-issues-finalizer + - issue-tracker-project-issues-finalizer generation: 1 - labels: - status: open name: test-issue namespace: default - resourceVersion: "4109" - uid: d92f5b1c-5e9e-4779-9087-a13a56ea4982 + resourceVersion: "3041" + uid: 8e47278a-da1e-48c6-af5d-2a6c68642cf8 spec: description: bar status: open title: Foo status: - processedTimestamp: "2024-04-29T16:43:32.1227568Z" + processedTimestamp: "2024-12-05T00:52:46.299978927Z" ``` Now whenever the watcher processes an Add, Update, or Sync, it'll update the `processedTimestamp`. Tracking data in the `status` subresource is an operator best practice. For other considerations when writing an operator, check out [Considerations When Writing an Operator](../../writing-an-operator.md#considerations-when-writing-an-operator). -Now that we have a watcher that does something, let's look at adding some other capabilities to our operator: [API admission control](08-adding-admission-control.md). +Now that we have a watcher that does something, let's look at adding some other capabilities to our app: [API admission control](08-adding-admission-control.md). ### Prev: [Writing Our Front-End](06-frontend.md) ### Next: [Adding Admission Control](08-adding-admission-control.md) \ No newline at end of file diff --git a/docs/tutorials/issue-tracker/cue/issue-v1.cue b/docs/tutorials/issue-tracker/cue/issue-v1.cue index 750f95ea..81723ce8 100644 --- a/docs/tutorials/issue-tracker/cue/issue-v1.cue +++ b/docs/tutorials/issue-tracker/cue/issue-v1.cue @@ -8,21 +8,11 @@ issue: { // The human-readable plural form of the "name" field. // Will default to +"s" if not present. pluralName: "Issues" - // Group determines the grouping of the kind in the API server and elsewhere. - // This is typically the same root as the plugin ID. - group: "issue-tracker-project" - // apiResource is a field that indicates to the codegen, and to the CUE kind system (kindsys), that this is a kind - // which can be expressed as an API Server resource (Custom Resource Definition or otherwise). - // When present, it also imposes certain generation and runtime restrictions on the form of the kind's schema(s). - // It can be left as an empty object, or the fields can be populated, as we do below, - // to either be explicit or use non-default values (here we ware being explicit about the fields). - apiResource: { - // [OPTIONAL] - // Scope is the scope of the API server resource, limited to "Namespaced" (default), or "Cluster" - // "Namespaced" kinds have resources which live in specific namespaces, whereas - // "Cluster" kinds' resources all exist in a global namespace and cannot be localized to a single one. - scope: "Namespaced" - } + // [OPTIONAL] + // Scope is the scope of the API server resource, limited to "Namespaced" (default), or "Cluster" + // "Namespaced" kinds have resources which live in specific namespaces, whereas + // "Cluster" kinds' resources all exist in a global namespace and cannot be localized to a single one. + scope: "Namespaced" // Current is the current version of the Schema. current: "v1" // Codegen is an object which provides information to the codegen tooling about what sort of code you want generated. diff --git a/docs/tutorials/issue-tracker/cue/issue-v2.cue b/docs/tutorials/issue-tracker/cue/issue-v2.cue index aacdb115..e9322550 100644 --- a/docs/tutorials/issue-tracker/cue/issue-v2.cue +++ b/docs/tutorials/issue-tracker/cue/issue-v2.cue @@ -10,21 +10,11 @@ issue: { // The human-readable plural form of the "name" field. // Will default to +"s" if not present. pluralName: "Issues" - // Group determines the grouping of the kind in the API server and elsewhere. - // This is typically the same root as the plugin ID. - group: "issue-tracker-project" - // apiResource is a field that indicates to the codegen, and to the CUE kind system (kindsys), that this is a kind - // which can be expressed as an API Server resource (Custom Resource Definition or otherwise). - // When present, it also imposes certain generation and runtime restrictions on the form of the kind's schema(s). - // It can be left as an empty object, or the fields can be populated, as we do below, - // to either be explicit or use non-default values (here we ware being explicit about the fields). - apiResource: { - // [OPTIONAL] - // Scope is the scope of the API server resource, limited to "Namespaced" (default), or "Cluster" - // "Namespaced" kinds have resources which live in specific namespaces, whereas - // "Cluster" kinds' resources all exist in a global namespace and cannot be localized to a single one. - scope: "Namespaced" - } + // [OPTIONAL] + // Scope is the scope of the API server resource, limited to "Namespaced" (default), or "Cluster" + // "Namespaced" kinds have resources which live in specific namespaces, whereas + // "Cluster" kinds' resources all exist in a global namespace and cannot be localized to a single one. + scope: "Namespaced" // Current is the current version of the Schema. current: "v1" // Codegen is an object which provides information to the codegen tooling about what sort of code you want generated. diff --git a/docs/tutorials/issue-tracker/cue/issue-v3.cue b/docs/tutorials/issue-tracker/cue/issue-v3.cue index 7a63631c..837cc41d 100644 --- a/docs/tutorials/issue-tracker/cue/issue-v3.cue +++ b/docs/tutorials/issue-tracker/cue/issue-v3.cue @@ -10,24 +10,14 @@ issue: { // The human-readable plural form of the "name" field. // Will default to +"s" if not present. pluralName: "Issues" - // Group determines the grouping of the kind in the API server and elsewhere. - // This is typically the same root as the plugin ID. - group: "issue-tracker-project" - // apiResource is a field that indicates to the codegen, and to the CUE kind system (kindsys), that this is a kind - // which can be expressed as an API Server resource (Custom Resource Definition or otherwise). - // When present, it also imposes certain generation and runtime restrictions on the form of the kind's schema(s). - // It can be left as an empty object, or the fields can be populated, as we do below, - // to either be explicit or use non-default values (here we ware being explicit about the fields). - apiResource: { - // [OPTIONAL] - // Scope is the scope of the API server resource, limited to "Namespaced" (default), or "Cluster" - // "Namespaced" kinds have resources which live in specific namespaces, whereas - // "Cluster" kinds' resources all exist in a global namespace and cannot be localized to a single one. - scope: "Namespaced" - // Validation is used when generating the manifest to indicate support for validating admission control - // for this kind. Here, we list that we want to do validation for CREATE and UPDATE operations. - validation: operations: ["CREATE","UPDATE"] - } + // [OPTIONAL] + // Scope is the scope of the API server resource, limited to "Namespaced" (default), or "Cluster" + // "Namespaced" kinds have resources which live in specific namespaces, whereas + // "Cluster" kinds' resources all exist in a global namespace and cannot be localized to a single one. + scope: "Namespaced" + // Validation is used when generating the manifest to indicate support for validating admission control + // for this kind. Here, we list that we want to do validation for CREATE and UPDATE operations. + validation: operations: ["CREATE","UPDATE"] // Current is the current version of the Schema. current: "v1" // Codegen is an object which provides information to the codegen tooling about what sort of code you want generated. diff --git a/docs/tutorials/issue-tracker/cue/issue-v4.cue b/docs/tutorials/issue-tracker/cue/issue-v4.cue index 9af67198..5aaad82b 100644 --- a/docs/tutorials/issue-tracker/cue/issue-v4.cue +++ b/docs/tutorials/issue-tracker/cue/issue-v4.cue @@ -10,27 +10,17 @@ issue: { // The human-readable plural form of the "name" field. // Will default to +"s" if not present. pluralName: "Issues" - // Group determines the grouping of the kind in the API server and elsewhere. - // This is typically the same root as the plugin ID. - group: "issue-tracker-project" - // apiResource is a field that indicates to the codegen, and to the CUE kind system (kindsys), that this is a kind - // which can be expressed as an API Server resource (Custom Resource Definition or otherwise). - // When present, it also imposes certain generation and runtime restrictions on the form of the kind's schema(s). - // It can be left as an empty object, or the fields can be populated, as we do below, - // to either be explicit or use non-default values (here we ware being explicit about the fields). - apiResource: { - // [OPTIONAL] - // Scope is the scope of the API server resource, limited to "Namespaced" (default), or "Cluster" - // "Namespaced" kinds have resources which live in specific namespaces, whereas - // "Cluster" kinds' resources all exist in a global namespace and cannot be localized to a single one. - scope: "Namespaced" - // Validation is used when generating the manifest to indicate support for validating admission control - // for this kind. Here, we list that we want to do validation for CREATE and UPDATE operations. - validation: operations: ["CREATE","UPDATE"] - // Validation is used when generating the manifest to indicate support for mutating admission control - // for this kind. Here, we list that we want to do mutation for CREATE and UPDATE operations. - mutation: operations: ["CREATE","UPDATE"] - } + // [OPTIONAL] + // Scope is the scope of the API server resource, limited to "Namespaced" (default), or "Cluster" + // "Namespaced" kinds have resources which live in specific namespaces, whereas + // "Cluster" kinds' resources all exist in a global namespace and cannot be localized to a single one. + scope: "Namespaced" + // Validation is used when generating the manifest to indicate support for validating admission control + // for this kind. Here, we list that we want to do validation for CREATE and UPDATE operations. + validation: operations: ["CREATE","UPDATE"] + // Validation is used when generating the manifest to indicate support for mutating admission control + // for this kind. Here, we list that we want to do mutation for CREATE and UPDATE operations. + mutation: operations: ["CREATE","UPDATE"] // Current is the current version of the Schema. current: "v1" // Codegen is an object which provides information to the codegen tooling about what sort of code you want generated. diff --git a/docs/tutorials/issue-tracker/frontend-files/issue-client.ts b/docs/tutorials/issue-tracker/frontend-files/issue-client.ts index f616eb63..305fa24f 100644 --- a/docs/tutorials/issue-tracker/frontend-files/issue-client.ts +++ b/docs/tutorials/issue-tracker/frontend-files/issue-client.ts @@ -1,7 +1,6 @@ import { Issue } from '../generated/issue/v1/issue_object_gen'; import { BackendSrvRequest, getBackendSrv, FetchResponse } from '@grafana/runtime'; import { lastValueFrom } from 'rxjs'; -import { PLUGIN_API_URL } from '../constants'; export interface ListResponse { items: T[]; @@ -11,11 +10,13 @@ export class IssueClient { apiEndpoint: string constructor() { - this.apiEndpoint = PLUGIN_API_URL + '/issues'; + this.apiEndpoint = '/apis/issuetrackerproject.ext.grafana.com/v1/namespaces/default/issues'; } async create(title: string, description: string): Promise> { let issue = { + kind: 'Issue', + apiVersion: 'issuetrackerproject.ext.grafana.com/v1', spec: { title: title, description: description, @@ -27,7 +28,9 @@ export class IssueClient { } } const options: BackendSrvRequest = { - headers: {}, + headers: { + 'content-type':'application/json', + }, method: 'POST', url: this.apiEndpoint, data: JSON.stringify(issue), @@ -71,7 +74,9 @@ export class IssueClient { async update(name: string, updated: Issue): Promise> { const options: BackendSrvRequest = { - headers: {}, + headers: { + 'content-type':'application/json', + }, method: 'PUT', url: this.apiEndpoint + '/' + name, data: JSON.stringify(updated), diff --git a/docs/tutorials/issue-tracker/frontend-files/main.tsx b/docs/tutorials/issue-tracker/frontend-files/main.tsx index a3943dab..f8f19fd8 100644 --- a/docs/tutorials/issue-tracker/frontend-files/main.tsx +++ b/docs/tutorials/issue-tracker/frontend-files/main.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React from 'react'; import { css } from '@emotion/css'; import { useForm } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; @@ -6,6 +6,7 @@ import { useStyles2, Button, IconButton, Field, Input, Card, TagList } from '@gr import { IssueClient } from '../api/issue_client'; import { Issue } from '../generated/issue/v1/issue_object_gen'; import { useState, useEffect } from 'react'; +import { PluginPage } from '@grafana/runtime'; // This is used for the create new issue form type ReactHookFormProps = { @@ -13,10 +14,8 @@ type ReactHookFormProps = { description: string; }; - -// MainPage is the main (and only) page of the plugin, where issues are listed, and can be created, updated, or deleted -export const MainPage = () => { - useStyles2(getStyles); +function PageOne() { + const s = useStyles2(getStyles); let issues: Issue[] = []; const [issuesData, setIssuesData] = useState(issues); @@ -49,7 +48,7 @@ export const MainPage = () => { }; const updateStatus = async (issue: Issue, newStatus: string) => { - issue.spec.status = newStatus; + issue.spec.status = newStatus; await ic.update(issue.metadata.name, issue); await listIssues(); } @@ -57,11 +56,11 @@ export const MainPage = () => { // Form handling const { handleSubmit, register } = useForm({ - mode: 'onChange', - defaultValues: { - title: '', - description: '', - }, + mode: 'onChange', + defaultValues: { + title: '', + description: '', + }, }); const handleCreate = handleSubmit((issue) => { @@ -72,16 +71,16 @@ export const MainPage = () => { const getActions = (issue: Issue) => { if(issue.spec.status === 'open') { return ( - - - + + + ) } else if(issue.spec.status === 'in_progress') { return ( - - - - + + + + ) } else { return @@ -89,57 +88,60 @@ export const MainPage = () => { } return ( -
-

Issue list

- {issuesData.length > 0 && ( -
    - {issuesData.map((issue: any) => ( -
  • - - {issue.spec.title} - {issue.spec.description} - - - - { getActions(issue) } - - { - deleteIssue(issue.metadata.name); - }} - > - Delete - - - -
  • - ))} -
- )} -
-

Create New Issue

-
- - - - - - - -
-
+ +
+

Issue list

+ {issuesData.length > 0 && ( +
    + {issuesData.map((issue: any) => ( +
  • + + {issue.spec.title} + {issue.spec.description} + + + + { getActions(issue) } + + { + deleteIssue(issue.metadata.name); + }} + > + Delete + + + +
  • + ))} +
+ )} +
+

Create New Issue

+
+ + + + + + + +
+
+
); -}; +} +export default PageOne; const getStyles = (theme: GrafanaTheme2) => ({ marginTop: css` margin-top: ${theme.spacing(2)}; - `, + `, }); diff --git a/operator/opinionatedwatcher.go b/operator/opinionatedwatcher.go index f860b1a9..b9ed77a6 100644 --- a/operator/opinionatedwatcher.go +++ b/operator/opinionatedwatcher.go @@ -4,10 +4,12 @@ import ( "context" "fmt" + "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/codes" "k8s.io/utils/strings/slices" "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana-app-sdk/metrics" "github.com/grafana/grafana-app-sdk/resource" ) @@ -44,6 +46,7 @@ type OpinionatedWatcher struct { finalizer string schema resource.Schema client PatchClient + collectors []prometheus.Collector } // FinalizerSupplier represents a function that creates string finalizer from provider schema. @@ -72,9 +75,10 @@ func NewOpinionatedWatcherWithFinalizer(sch resource.Schema, client PatchClient, return nil, fmt.Errorf("finalizer length cannot exceed 63 chars: %s", finalizer) } return &OpinionatedWatcher{ - client: client, - schema: sch, - finalizer: finalizer, + client: client, + schema: sch, + finalizer: finalizer, + collectors: make([]prometheus.Collector, 0), }, nil } @@ -92,6 +96,10 @@ func (o *OpinionatedWatcher) Wrap(watcher ResourceWatcher, syncToAdd bool) { // if syncToAdd { o.SyncFunc = watcher.Add } + + if cast, ok := watcher.(metrics.Provider); ok { + o.collectors = append(o.collectors, cast.PrometheusCollectors()...) + } } // Add is part of implementing ResourceWatcher, @@ -267,6 +275,10 @@ func (*OpinionatedWatcher) Delete(context.Context, resource.Object) error { return nil } +func (o *OpinionatedWatcher) PrometheusCollectors() []prometheus.Collector { + return o.collectors +} + // addFunc is a wrapper for AddFunc which makes a nil check to avoid panics func (o *OpinionatedWatcher) addFunc(ctx context.Context, object resource.Object) error { if o.AddFunc != nil { diff --git a/simple/app.go b/simple/app.go index db641ebc..91a9bef5 100644 --- a/simple/app.go +++ b/simple/app.go @@ -104,6 +104,7 @@ type App struct { converters map[string]Converter customRoutes map[string]AppCustomRouteHandler patcher *k8s.DynamicPatcher + collectors []prometheus.Collector } // AppConfig is the configuration used by App @@ -212,6 +213,7 @@ func NewApp(config AppConfig) (*App, error) { converters: make(map[string]Converter), customRoutes: make(map[string]AppCustomRouteHandler), cfg: config, + collectors: make([]prometheus.Collector, 0), } discoveryRefresh := config.DiscoveryRefreshInterval if discoveryRefresh == 0 { @@ -397,8 +399,17 @@ func (a *App) RegisterKindConverter(groupKind schema.GroupKind, converter k8s.Co // PrometheusCollectors implements metrics.Provider and returns prometheus collectors used by the app for exposing metrics func (a *App) PrometheusCollectors() []prometheus.Collector { - // TODO: other collectors? - return a.runner.PrometheusCollectors() + collectors := make([]prometheus.Collector, 0) + collectors = append(collectors, a.collectors...) + collectors = append(collectors, a.runner.PrometheusCollectors()...) + return collectors +} + +// RegisterMetricsCollectors registers additional prometheus collectors for the app, in addition to those provided +// by any Runnables the app will run as part of Runner(). These additional prometheus collectors are exposed +// as a part of the list returned by PrometheusCollectors(). +func (a *App) RegisterMetricsCollectors(collectors ...prometheus.Collector) { + a.collectors = append(a.collectors, collectors...) } // Validate implements app.App and handles Validating Admission Requests