Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

friction report: developing a simple generator function #2435

Open
howardjohn opened this issue Aug 8, 2021 · 3 comments
Open

friction report: developing a simple generator function #2435

howardjohn opened this issue Aug 8, 2021 · 3 comments
Labels
area/fn-sdk Typescript SDK customer deep engagement documentation Improvements or additions to documentation triaged Issue has been triaged by adding an `area/` label
Milestone

Comments

@howardjohn
Copy link
Contributor

howardjohn commented Aug 8, 2021

This is not a bug really; rather a documentation of my experience trying to write a simple generator function. I have always found friction reports super helpful for me, but feel free to close this immediately.

Goal: write a generator that takes in a config (declaratively) with two values: revision and id, and fetch the grafana dashboard (ie https://grafana.com/api/dashboards/9614/revisions/1/download) into a ConfigMap local yaml file.

Timeline: about 3 hours

I first started digging into https://kpt.dev/book/05-developing-functions/02-developing-in-Go and the github.com/GoogleContainerTools/kpt-functions-catalog for some real examples. Right off the bat I ran into issues; the framework.Command method no longer exists in new kyaml versions, and kpt-functions-catalog uses a newer version (v0.10.21) than the docs. I don't know the context, but no deprecation period and a breaking change in a patch release seems not very user friendly.

Once I got a hello world compiling, I quickly found it challenging to debug at all to get a feel of the inputs/outputs I was expecting. Documented more in #2434.

Next I started looking for some examples of generators; however, almost everything in kpt-functions-catalog is just doing transformations on existing objects or validation. UpsertResource was somewhat similar but a bit complex to follow, so I was somewhat on my own. Luckily the task was trivial - I just needed to create a single configmap.

I started out just trying to do that, and not worry about getting the actual data into the configmap yet. I see I need to create an RNode and insert it into Items... should be simple enough. RNode has very somewhat minimal documentation on how to actually create one, so I dug through the ~100 methods it has and found a few promising ones: ConvertJSONToYamlNode and FromMap. I also found I could just yaml.Unmarshal into one (I think?).

I first attempt to create a v1.ConfigMap object, marshal to yaml string, then unmarshal into RNode. However, yaml.Marshal is actually outputting a completely incorrect format for these (missing type data, keys are all lowercase like resourceversion, etc), so this didn't work. I then attempted to use the stdlib json.Marshal, but ran into the fact that the type metadata is not auto populated then. I started trying to find some examples of actually doing this with schemes, decoders, etc but gave up after a few minutes, figuring this must not be the recommended approach to do this or it would be easier+documented.

From the looks of things, all examples are using yaml.Parse with Sprintf, or go templating. I would prefer strongly typed v1.ConfigMap, but was willing to compromise with go template, especially since TemplateProcessor seemed to handle a bunch of things.

Because my code wasn't strictly as straightforward as plumbing some data from input to output, I had created my own Processor that then called TemplateProcessor{}.Process(resourceList). This turned out to not be right; TemplateProcessor{}.Process only works when used directly, as TemplateData will get set to the resourceList.FunctionConfig. Makes sense after running through a debugger, but got me stuck for 10 minutes or so. Replacing Process with Filter got things rolling again, and I finally got configmap written to a file.

However, I didn't like the filename. There was no obvious method on RNode to set the filename, but I stumbled upon a config.kubernetes.io/path at some point in my debugging so tried it out and it worked; it appears nowhere in the kpt.dev documentation though.

Next, I just needed to get the grafana dashboard downloaded and into the configmap. The download part was simple enough, but then I ran into issues with my previous choice to use go templating -- because the data is json (ie multiline, has quotes, etc), we have escaping issues just sticking it into the configmap like

data:
  "{{.Name}}.json": {{.Dashboard}}

Because TemplateProcessor doesn't add in sprig functions, the usual fun tricks of helm (which I am using kpt to avoid using 🙂 ) like |ident 4 won't work either. There didn't seem to be a way to add functions to TemplateProcessor either.

After trying a few variations of the template, I still couldn't get the escaping to work.

My end code:

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"os"

	"sigs.k8s.io/kustomize/kyaml/fn/framework"
	"sigs.k8s.io/kustomize/kyaml/fn/framework/command"
	"sigs.k8s.io/kustomize/kyaml/fn/framework/parser"
)

type Dashboard struct {
	Revision string `json:"revision"`
	ID       string `json:"id"`
}

func main() {
	asp := GrafanaProcessor{}
	cmd := command.Build(&asp, command.StandaloneEnabled, false)
	if err := cmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

type GrafanaProcessor struct{}

func (urp *GrafanaProcessor) Process(rl *framework.ResourceList) error {
	c := rl.FunctionConfig
	data := c.GetDataMap()
	db := Dashboard{
		Revision: data["revision"],
		ID:       data["id"],
	}
	js, err := fetchDashboard(db)
	if err != nil {
		return fmt.Errorf("fetch dashboard: %v", err)
	}
	added, err := framework.TemplateProcessor{
		TemplateData: map[string]string{
			"Name":      c.GetName() + "-dashboard",
			"Namespace": c.GetNamespace(),
			"Label":     "grafana_dashboard",
			"Dashboard": fmt.Sprintf("%q", js),
		},
		MergeResources: true,
		ResourceTemplates: []framework.ResourceTemplate{{
			Templates: parser.TemplateStrings(`
apiVersion: v1
kind: ConfigMap
metadata:
  name: "{{.Name}}"
  namespace: "{{.Namespace}}"
  annotations:
    config.kubernetes.io/path: "{{.Name}}.yaml"
  labels:
    "{{ .Label }}": "true"
data:
  "{{.Name}}.json": {{.Dashboard}}
`)}},
	}.Filter(rl.Items)
	for _, a := range added {
		s, _ := a.String()
		fmt.Fprintln(os.Stderr, s)
	}
	rl.Items = added
	return err
}

func fetchDashboard(db Dashboard) (string, error) {
	url := fmt.Sprintf("https://grafana.com/api/dashboards/%s/revisions/%s/download", db.ID, db.Revision)
	resp, err := http.Get(url)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	return string(b), nil
}

Next I tried to backtrack, and instead use the typed ConfigMap. I realized I can use sigs.k8s.io/yaml instead of sigs.k8s.io/kustomize/kyaml/yaml and get proper marshaling; that worked. Now just needed to unmarshal it into a node

Ran into an interesting quirk of the library
This panics with obscure error:

	var node *yaml.Node
	if err := yaml.Unmarshal(b, node); err != nil {
		return err
	}

This works:

	var node yaml.Node
	if err := yaml.Unmarshal(b, &node); err != nil {
		return err
	}

Once that got moving, I found I was actually wrong - sigs.k8s.io/yaml does NOT automatically work, and I got an error that my output is not a KRM format. Oops. Since my function is so simple, I guess I will bite the bullet and just manually specify the type meta...:

		TypeMeta: v1.TypeMeta{
			Kind:       "ConfigMap",
			APIVersion: "v1",
		},

Next all I needed to do was get the merging TemplateProcessor was previously giving me for free. A quick look through the code led me to the MergeFilter. This might cause issues since I really want a replace, not a merge, but should be good enough for now:

	rl.Items = append(rl.Items, yaml.NewRNode(&node))
	rl.Items, err = filters.MergeFilter{}.Filter(rl.Items)

And finally things are working!

End code:

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"os"

	corev1 "k8s.io/api/core/v1"
	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"sigs.k8s.io/kustomize/kyaml/fn/framework"
	"sigs.k8s.io/kustomize/kyaml/fn/framework/command"
	"sigs.k8s.io/kustomize/kyaml/kio/filters"
	"sigs.k8s.io/kustomize/kyaml/yaml"
	kyaml "sigs.k8s.io/yaml"
)

type Dashboard struct {
	Revision string `json:"revision"`
	ID       string `json:"id"`
}

func main() {
	asp := GrafanaProcessor{}
	cmd := command.Build(&asp, command.StandaloneEnabled, false)
	if err := cmd.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

type GrafanaProcessor struct{}

func (urp *GrafanaProcessor) Process(rl *framework.ResourceList) error {
	c := rl.FunctionConfig
	data := c.GetDataMap()
	db := Dashboard{
		Revision: data["revision"],
		ID:       data["id"],
	}
	js, err := fetchDashboard(db)
	if err != nil {
		return fmt.Errorf("fetch dashboard: %v", err)
	}
	cm := &corev1.ConfigMap{
		TypeMeta: v1.TypeMeta{
			Kind:       "ConfigMap",
			APIVersion: "v1",
		},
		ObjectMeta: v1.ObjectMeta{
			Name:      c.GetName() + "-dashboard",
			Namespace: c.GetNamespace(),
			Annotations: map[string]string{
				"config.kubernetes.io/path": c.GetName() + "-dashboard.yaml",
			},
			Labels: map[string]string{
				"grafana_dashboard": "true",
			},
		},
		Data: map[string]string{c.GetName() + ".json": js},
	}
	b, err := kyaml.Marshal(cm)
	if err != nil {
		return err
	}
	var node yaml.Node
	if err := yaml.Unmarshal(b, &node); err != nil {
		return err
	}
	rl.Items = append(rl.Items, yaml.NewRNode(&node))
	rl.Items, err = filters.MergeFilter{}.Filter(rl.Items)
	if err != nil {
		return err
	}
	return nil
}

func fetchDashboard(db Dashboard) (string, error) {
	url := fmt.Sprintf("https://grafana.com/api/dashboards/%s/revisions/%s/download", db.ID, db.Revision)
	resp, err := http.Get(url)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	return string(b), nil
}

Overall, there was no one thing that was broken/bad/hard - but rather at most steps I encountered small barriers that overtime led to a more frustrating experience than I expected. I hope over time as the ecosystem matures a bit this will get streamlined a bit - I think a large function catalog would be great for the project

@mengqiy mengqiy added area/fn-sdk Typescript SDK triaged Issue has been triaged by adding an `area/` label documentation Improvements or additions to documentation customer deep engagement labels Aug 9, 2021
@mengqiy mengqiy self-assigned this Aug 9, 2021
@mengqiy
Copy link
Contributor

mengqiy commented Aug 9, 2021

Thanks for this detailed friction log!
We will address them.

@mengqiy mengqiy added this to the Q3-2021 milestone Aug 25, 2021
@mengqiy
Copy link
Contributor

mengqiy commented Nov 1, 2021

Some of the low-hanging fruits has been addressed in Q3.
We are working on improving the golang SDK in Q4.

@bgrant0607
Copy link
Contributor

See also #2528

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/fn-sdk Typescript SDK customer deep engagement documentation Improvements or additions to documentation triaged Issue has been triaged by adding an `area/` label
Projects
None yet
Development

No branches or pull requests

3 participants