Skip to content

friction report: developing a simple generator function #2435

Open
@howardjohn

Description

@howardjohn

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/fn-sdkTypescript SDKcustomerdeep engagementdocumentationImprovements or additions to documentationtriagedIssue has been triaged by adding an `area/` label

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions