diff --git a/documentation/content/en/book/05-developing-functions/_index.md b/documentation/content/en/book/05-developing-functions/_index.md index 255f04c9b6..0013515bb4 100644 --- a/documentation/content/en/book/05-developing-functions/_index.md +++ b/documentation/content/en/book/05-developing-functions/_index.md @@ -133,8 +133,14 @@ for writing functions that manipulate KRM. Go provides: ### Quickstart -In this quickstart, we will write a function called "set-annotation" that adds an annotation -`config.kubernetes.io/managed-by=kpt` to all `Deployment` resources. +In this quickstart, we will start from the get-started scaffold — a small +"hello world" function that stamps a greeting annotation on every resource — and +adapt it into a function that adds `config.kubernetes.io/managed-by=kpt` to all +`Deployment` resources. + +For a deeper treatment of function development — choosing an interface, testing +with golden files, and containerizing — see the +[KRM Function Developer Guide]({{% relref "/guides/krm-functions" %}}). #### Set up your project @@ -181,6 +187,7 @@ package main import ( "context" _ "embed" + "fmt" "os" "github.com/kptdev/krm-functions-sdk/go/fn" @@ -192,26 +199,48 @@ var readme []byte //go:embed metadata.yaml var metadata []byte -var _ fn.Runner = &YourFunction{} +// greetingAnnotation is the annotation this example stamps onto every resource. +const greetingAnnotation = "example.kpt.dev/greeting" -// TODO: Change to your functionConfig "Kind" name. -type YourFunction struct { - FnConfigBool bool - FnConfigInt int - FnConfigFoo string +var _ fn.Runner = &HelloWorld{} + +// HelloWorld is the functionConfig for this example. The struct name is used as +// the functionConfig `kind`, and each exported field is populated from the +// matching functionConfig key via its JSON tag. +// +// TODO: Rename this struct to your functionConfig "kind" and replace the fields +// with the configuration your function needs. +type HelloWorld struct { + Greeting string `json:"greeting,omitempty"` + Name string `json:"name,omitempty"` } // Run is the main function logic. // `items` is parsed from the STDIN "ResourceList.Items". -// `functionConfig` is from the STDIN "ResourceList.FunctionConfig". The value has been assigned to the r attributes +// `functionConfig` is from the STDIN "ResourceList.FunctionConfig". Its values +// have already been unmarshaled into the receiver's fields. // `results` is the "ResourceList.Results" that you can write result info to. -func (r *YourFunction) Run(ctx *fn.Context, functionConfig *fn.KubeObject, items fn.KubeObjects, results *fn.Results) bool { - // TODO: Write your code. - return true +func (r *HelloWorld) Run(ctx *fn.Context, functionConfig *fn.KubeObject, items fn.KubeObjects, results *fn.Results) bool { + greeting := r.Greeting + if greeting == "" { + greeting = "Hello" + } + name := r.Name + if name == "" { + name = "world" + } + message := fmt.Sprintf("%s, %s!", greeting, name) + for _, obj := range items { + if err := obj.SetAnnotation(greetingAnnotation, message); err != nil { + results.ErrorE(err) + } + } + results.Infof("greeted %d resource(s) with %q", len(items), message) + return results.ExitCode() == 0 } func main() { - runner := fn.WithContext(context.Background(), &YourFunction{}) + runner := fn.WithContext(context.Background(), &HelloWorld{}) if err := fn.AsMain(runner, fn.WithDocs(readme, metadata)); err != nil { os.Exit(1) } @@ -225,19 +254,21 @@ Basically, the KRM resource `ResourceList.FunctionConfig` and KRM resources `Res `KubeObject` objects. You can use `KubeObject` in a similar manner to [`unstructured.Unstructured`](https://pkg.go.dev/k8s.io/apimachinery/pkg/apis/meta/v1/unstructured). -The set-annotation function (see below) iterates the `ResourceList.Items`, finds out the `Deployment` resources and -adds the annotation. After the iteration, it adds some user message to the `ResourceList.Results` +The set-annotation function (see below) iterates the `ResourceList.Items`, finds the `Deployment` resources and +adds the annotation. After the iteration, it reports a user message to the `ResourceList.Results` via `results.Infof`. ```go func (r *YourFunction) Run(ctx *fn.Context, functionConfig *fn.KubeObject, items fn.KubeObjects, results *fn.Results) bool { for _, kubeObject := range items { - if kubeObject.IsGVK("apps", "v1", "Deployment") { - kubeObject.SetAnnotation("config.kubernetes.io/managed-by", "kpt") + if kubeObject.GetKind() == "Deployment" { + if err := kubeObject.SetAnnotation("config.kubernetes.io/managed-by", "kpt"); err != nil { + results.ErrorE(err) + } } } - // This result message will be displayed in the function evaluation time. - *results = append(*results, fn.GeneralResult("Add config.kubernetes.io/managed-by=kpt to all `Deployment` resources", fn.Info)) - return true + // This result message will be displayed at function evaluation time. + results.Infof("added config.kubernetes.io/managed-by=kpt to all Deployment resources") + return results.ExitCode() == 0 } ``` @@ -248,16 +279,16 @@ Learn more about the `KubeObject` from the [go documentation](https://pkg.go.dev The "get-started" package contains a `./testdata` directory. You can use this to test out your functions. ```shell -# Edit the `testdata/noop-passthrough/resources.yaml` with your KRM resources. -# resources.yaml already has a `Deployment` and `Service` as test data. -vim testdata/noop-passthrough/resources.yaml +# Edit `testdata/hello-world/resources.yaml` with your KRM resources. +# Add a `Deployment` so the set-annotation logic above has something to match. +vim testdata/hello-world/resources.yaml # Convert the KRM resources and FunctionConfig resource to `ResourceList`, and # then pipe the ResourceList as StdIn to your function kpt fn source testdata | go run main.go ``` -Verify the KRM function behavior in the StdOutput `ResourceList` by looking for the new annotation on the "nginx-deplyment": +Verify the KRM function behavior in the StdOutput `ResourceList` by looking for the new annotation on the `Deployment`: ```yaml apiVersion: apps/v1 @@ -281,10 +312,19 @@ kubeObject.SetAnnotation("config.kubernetes.io/managed-by", "kpt") to ```shell -kubeObject.SetAnnotation("config.kubernetes.io/managed-by", r.FnConfigFoo) +kubeObject.SetAnnotation("config.kubernetes.io/managed-by", r.ManagedBy) +``` + +Add a `ManagedBy` field to your struct so the value can be read from the +functionConfig: + +```go +type YourFunction struct { + ManagedBy string `json:"managedBy,omitempty"` +} ``` -The annotation value will be set from the value of the `FnConfigFoo` field. +The annotation value will be set from the value of the `managedBy` field. Create the configuration information so that we can concatenate it onto the ResourceList generated by the `kpt fn source` command. This configuration specifies that the "config.kubernetes.io/managed-by" annotation should be set to a value of "bar". @@ -298,7 +338,7 @@ functionConfig: name: test annotations: internal.kpt.dev/upstream-identifier: 'fn.kpt.dev|YourFunction|default|test' - fnConfigFoo: bar + managedBy: bar EOF ``` @@ -363,11 +403,16 @@ docker build . -t ${FN_CONTAINER_REGISTRY}/${FUNCTION_NAME}:${TAG} To verify the image using the same `./testdata` resources ```shell -kpt fn eval ./testdata/noop-passthrough/resources.yaml --image ${FN_CONTAINER_REGISTRY}/${FUNCTION_NAME}:${TAG} +kpt fn eval ./testdata/hello-world/resources.yaml --image ${FN_CONTAINER_REGISTRY}/${FUNCTION_NAME}:${TAG} ``` ### Next Steps +- Read the [KRM Function Developer Guide]({{% relref "/guides/krm-functions" %}}) + for choosing an [interface]({{% relref "/guides/krm-functions/interfaces" %}}) + (`fn.Runner` vs `fn.ResourceListProcessor`), + [testing]({{% relref "/guides/krm-functions/testing" %}}) with golden files, and + [containerizing]({{% relref "/guides/krm-functions/containerizing" %}}) your function. - See other [go documentation examples](https://pkg.go.dev/github.com/kptdev/krm-functions-sdk/go/fn/examples) to use KubeObject. - To contribute to KRM catalog functions, please follow the [contributor guide](https://github.com/kptdev/krm-functions-catalog/blob/main/CONTRIBUTING.md) - For the `metadata.yaml` schema reference (required fields, allowed tags), see the [metadata schema documentation](https://catalog.kpt.dev/metadata-schema/) diff --git a/documentation/content/en/guides/_index.md b/documentation/content/en/guides/_index.md index c77bd41329..6f8560792d 100644 --- a/documentation/content/en/guides/_index.md +++ b/documentation/content/en/guides/_index.md @@ -15,3 +15,4 @@ menu: - [Value Propagation Pattern]({{% relref "/guides/value-propagation" %}}) - [Tenant Onboarding]({{% relref "/guides/tenant-onboarding" %}}) - [Understanding 3-Way Merge in kpt]({{% relref "/guides/3-way-merge" %}}) +- [KRM Function Developer Guide]({{% relref "/guides/krm-functions" %}}) diff --git a/documentation/content/en/guides/krm-functions/_index.md b/documentation/content/en/guides/krm-functions/_index.md new file mode 100644 index 0000000000..efa109c729 --- /dev/null +++ b/documentation/content/en/guides/krm-functions/_index.md @@ -0,0 +1,26 @@ +--- +title: KRM Function Developer Guide +linkTitle: KRM Function Developer Guide +description: Write your own KRM functions with the Go SDK. +toc_hide: false +menu: + main: + parent: "Guides" +--- +This guide walks through writing KRM functions with the +[Go SDK](https://github.com/kptdev/krm-functions-sdk). Start with the tutorial, +then dig into the topic guides as needed. + +- [Tutorial]({{% relref "/guides/krm-functions/tutorial" %}}) — build a working + function end to end, with embedded documentation, golden tests, and support for + `--help`, `--doc`, and standalone file mode. +- [Interfaces]({{% relref "/guides/krm-functions/interfaces" %}}) — choose between + `fn.Runner` (transformers, validators) and `fn.ResourceListProcessor` + (generators, complex functions). +- [Testing]({{% relref "/guides/krm-functions/testing" %}}) — golden test patterns + and unit testing in depth. +- [Containerizing]({{% relref "/guides/krm-functions/containerizing" %}}) — package + your function as a container image. + +For a complete working example, see +[`go/get-started/`](https://github.com/kptdev/krm-functions-sdk/tree/main/go/get-started). diff --git a/documentation/content/en/guides/krm-functions/containerizing.md b/documentation/content/en/guides/krm-functions/containerizing.md new file mode 100644 index 0000000000..fb509d528c --- /dev/null +++ b/documentation/content/en/guides/krm-functions/containerizing.md @@ -0,0 +1,148 @@ +--- +title: Containerizing +linkTitle: Containerizing +description: Package a KRM function as a container image. +toc_hide: false +menu: + main: + parent: "KRM Function Developer Guide" + weight: 40 +--- +KRM functions are distributed as container images. This guide covers building +and running containerized functions. + +## Dockerfile + +The [krm-functions-catalog](https://github.com/kptdev/krm-functions-catalog) +provides a shared Dockerfile at `build/docker/go/Dockerfile` that all the catalog +functions use. It accepts `BUILDER_IMAGE` and `BASE_IMAGE` as build args. + +For standalone functions or local development, use a multi-stage build with a +minimal base image. The function binary should be statically linked (no CGO), so +it can run on `scratch` or `distroless`: + +```dockerfile +FROM golang:1.26-alpine AS builder +ENV CGO_ENABLED=0 +WORKDIR /go/src/ +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /usr/local/bin/function ./ + +FROM scratch +COPY --from=builder /usr/local/bin/function /usr/local/bin/function +ENTRYPOINT ["function"] +``` + +Key points: +- `CGO_ENABLED=0` produces a static binary that runs on `scratch`. +- The `scratch` base image has zero overhead — no shell, no OS packages. +- If you need TLS certificates (e.g., for network calls), use `gcr.io/distroless/static` instead of `scratch`. +- Copy only the binary to the final image to minimize size. + +### Alternative with distroless + +```dockerfile +FROM golang:1.26-alpine AS builder +ENV CGO_ENABLED=0 +WORKDIR /go/src/ +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /usr/local/bin/function ./ + +FROM gcr.io/distroless/static:nonroot +COPY --from=builder /usr/local/bin/function /usr/local/bin/function +ENTRYPOINT ["function"] +``` + +## Building + +```bash +docker build -t ghcr.io/kptdev/krm-functions-catalog/my-function:v0.1 . +``` + +### Image Naming Convention + +Follow this pattern for function images: + +``` +ghcr.io/kptdev/krm-functions-catalog/{function-name}:{version} +``` + +Examples: +- `ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.1` +- `ghcr.io/kptdev/krm-functions-catalog/enforce-namespace:v1.0` +- `ghcr.io/kptdev/krm-functions-catalog/generate-configmap:v0.3` + +Use semantic versioning for tags. Avoid `latest` in production pipelines. + +## Running + +KRM functions read from STDIN and write to STDOUT: + +```bash +docker run --rm -i ghcr.io/kptdev/krm-functions-catalog/my-function:v0.1 < input.yaml > output.yaml +``` + +### With file mode + +```bash +docker run --rm -v $(pwd):/data ghcr.io/kptdev/krm-functions-catalog/my-function:v0.1 /data/deployment.yaml +``` + +Note: file mode assembles the given files into a ResourceList with an **empty +functionConfig**. Functions that require configuration should be run via STDIN +(or a `kpt` pipeline) so the functionConfig is provided. + +### Help and doc flags + +```bash +docker run --rm ghcr.io/kptdev/krm-functions-catalog/my-function:v0.1 --help +docker run --rm ghcr.io/kptdev/krm-functions-catalog/my-function:v0.1 --doc +``` + +## Using with kpt + +In a `Kptfile` pipeline, `kpt fn render` will pull the image from the registry +and run it against your package resources: + +```yaml +apiVersion: kpt.dev/v1 +kind: Kptfile +metadata: + name: my-package +pipeline: + mutators: + - image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.1 + configMap: + app: my-app + validators: + - image: ghcr.io/kptdev/krm-functions-catalog/enforce-namespace:v1.0 + configMap: + namespace: production +``` + +Note: the image must be published and accessible from the machine running +`kpt fn render`. For local development, build the image locally first. It +will be used from the local Docker cache without pulling. + +## Tips + +- Keep images small — a typical Go KRM function image is 5–15 MB with `scratch`. +- Pin dependency versions in `go.mod` for reproducible builds. +- Use `.dockerignore` to exclude test data, docs, and other non-build files. +- Test the container locally before publishing: + ```bash + echo '{"apiVersion":"config.kubernetes.io/v1","kind":"ResourceList","items":[]}' | \ + docker run --rm -i ghcr.io/kptdev/krm-functions-catalog/my-function:v0.1 + ``` + +## Publishing + +Publishing function images to a registry is handled by the +[krm-functions-catalog](https://github.com/kptdev/krm-functions-catalog) +CI pipeline. See the catalog's +[CONTRIBUTING.md](https://github.com/kptdev/krm-functions-catalog/blob/main/CONTRIBUTING.md) +for the release workflow. \ No newline at end of file diff --git a/documentation/content/en/guides/krm-functions/interfaces.md b/documentation/content/en/guides/krm-functions/interfaces.md new file mode 100644 index 0000000000..678e418e7d --- /dev/null +++ b/documentation/content/en/guides/krm-functions/interfaces.md @@ -0,0 +1,209 @@ +--- +title: Interfaces +linkTitle: Interfaces +description: Choose between fn.Runner and fn.ResourceListProcessor. +toc_hide: false +menu: + main: + parent: "KRM Function Developer Guide" + weight: 20 +--- +The SDK provides two interfaces for implementing KRM functions. Choose according +to your function requirements. + +> The `main` functions below call `fn.AsMain` without `fn.WithDocs` to keep the +> interface examples focused. Production functions should embed documentation and +> pass `fn.WithDocs(readme, metadata)` — see the +> [tutorial]({{% relref "/guides/krm-functions/tutorial" %}}). + +## fn.Runner + +Use `fn.Runner` for **transformers** (mutators) and **validators**. This is the +recommended interface for most functions. + +```go +type Runner interface { + Run(context *Context, functionConfig *KubeObject, items KubeObjects, results *Results) bool +} +``` + +Characteristics: +- The SDK automatically parses `functionConfig` into your struct's exported fields. + A typed functionConfig (its `kind` matching your struct name) is unmarshaled via + JSON tags; alternatively, a `ConfigMap` functionConfig has its `.data` map assigned + to a `map[string]string` field on your struct. +- You can **modify** existing items, but adding or removing items is not supported. + This is a convention, not a compile-time restriction: the SDK does not read back + items appended inside `Run`, so adds and removes are effectively dropped. Use + `fn.ResourceListProcessor` when you need to add or remove items. +- Return `true` for success, `false` for failure. +- Use `results` to report structured info/warning/error messages. + +### Example: Validator + +```go +var _ fn.Runner = &EnforceNamespace{} + +type EnforceNamespace struct { + Namespace string `json:"namespace"` +} + +func (r *EnforceNamespace) Run(ctx *fn.Context, functionConfig *fn.KubeObject, items fn.KubeObjects, results *fn.Results) bool { + for _, obj := range items { + if obj.GetNamespace() != r.Namespace { + results.Errorf("resource %s/%s has namespace %q, expected %q", + obj.GetKind(), obj.GetName(), obj.GetNamespace(), r.Namespace) + } + } + return results.ExitCode() == 0 +} + +func main() { + runner := fn.WithContext(context.Background(), &EnforceNamespace{}) + if err := fn.AsMain(runner); err != nil { + os.Exit(1) + } +} +``` + +### Example: Transformer (Mutator) + +```go +var _ fn.Runner = &SetAnnotations{} + +type SetAnnotations struct { + Annotations map[string]string `json:"annotations,omitempty"` +} + +func (r *SetAnnotations) Run(ctx *fn.Context, functionConfig *fn.KubeObject, items fn.KubeObjects, results *fn.Results) bool { + for _, obj := range items { + for k, v := range r.Annotations { + if err := obj.SetAnnotation(k, v); err != nil { + results.ErrorE(err) + } + } + } + return results.ExitCode() == 0 +} +``` + +## fn.ResourceListProcessor + +Use `fn.ResourceListProcessor` for **generators** and **complex functions** that +need full control over the ResourceList. + +```go +type ResourceListProcessor interface { + Process(rl *ResourceList) (bool, error) +} +``` + +Characteristics: +- Full access to `ResourceList.Items` — you can add, remove, or modify items. +- You must parse `functionConfig` manually from `rl.FunctionConfig`. +- You can modify `rl.Results` directly. +- Return `(true, nil)` for success, `(false, err)` for failure. + +### Example: Generator + +```go +type ConfigMapGenerator struct{} + +func (g *ConfigMapGenerator) Process(rl *fn.ResourceList) (bool, error) { + // Parse functionConfig manually + name, _, _ := rl.FunctionConfig.NestedString("metadata", "name") + + // Generate a new ConfigMap + cm := fn.NewEmptyKubeObject() + if err := cm.SetAPIVersion("v1"); err != nil { + return false, err + } + if err := cm.SetKind("ConfigMap"); err != nil { + return false, err + } + if err := cm.SetName(name + "-generated"); err != nil { + return false, err + } + if err := cm.SetNamespace("default"); err != nil { + return false, err + } + + // Add to items + rl.Items = append(rl.Items, cm) + return true, nil +} + +func main() { + if err := fn.AsMain(&ConfigMapGenerator{}); err != nil { + os.Exit(1) + } +} +``` + +### ResourceListProcessorFunc + +For simple cases, use the function adapter instead of defining a struct: + +```go +type ResourceListProcessorFunc func(rl *ResourceList) (bool, error) +``` + +Example: + +```go +func main() { + processor := fn.ResourceListProcessorFunc(func(rl *fn.ResourceList) (bool, error) { + for _, obj := range rl.Items { + if err := obj.SetLabel("managed-by", "my-function"); err != nil { + return false, err + } + } + return true, nil + }) + if err := fn.AsMain(processor); err != nil { + os.Exit(1) + } +} +``` + +## Choosing Between Interfaces + +| Capability | fn.Runner | fn.ResourceListProcessor | +|---|---|---| +| Auto-parse functionConfig | ✅ | ❌ (manual) | +| Modify existing items | ✅ | ✅ | +| Add new items | ❌ | ✅ | +| Remove items | ❌ | ✅ | +| Access full ResourceList | ❌ | ✅ | +| Best for | Transformers, Validators | Generators, Complex functions | + +As a rule of thumb, pick the interface by what your function does: + +- **Transformers and validators** — use `fn.Runner`. It auto-parses the + functionConfig and keeps the function focused on modifying items. Examples: + set-labels, set-namespace. +- **Generators and functions needing full ResourceList access** (adding or + removing items, reading results from earlier functions) — use + `fn.ResourceListProcessor`. Examples: render-helm-chart, starlark. + +Both produce spec-compliant ResourceList I/O; the choice is about ergonomics, so +use the one that fits your function rather than a hard requirement. + +## Wrapping a Runner + +`fn.Runner` is wrapped into a `ResourceListProcessor` internally using +`fn.WithContext`: + +```go +runner := fn.WithContext(context.Background(), &MyFunction{}) +// runner implements ResourceListProcessor and can be passed to fn.AsMain +``` + +This wrapper handles the following: +1. Parsing `functionConfig` into your struct fields +2. Calling your `Run` method with the parsed context +3. Collecting results and determining success/failure + +--- + +Next: [Testing]({{% relref "/guides/krm-functions/testing" %}}) — golden test patterns for verifying your function. diff --git a/documentation/content/en/guides/krm-functions/testing.md b/documentation/content/en/guides/krm-functions/testing.md new file mode 100644 index 0000000000..ae27c66e4d --- /dev/null +++ b/documentation/content/en/guides/krm-functions/testing.md @@ -0,0 +1,232 @@ +--- +title: Testing +linkTitle: Testing +description: Golden tests and unit tests for KRM functions. +toc_hide: false +menu: + main: + parent: "KRM Function Developer Guide" + weight: 30 +--- +The SDK provides a golden test framework in `fn/testhelpers` for snapshot-based +testing of KRM functions. + +## Golden Test Pattern + +Golden tests compare the function output against the expected baseline files. This +approach catches regressions and makes it easy to review output changes. + +### Directory Structure + +``` +testdata/ +├── test-case-1/ +│ ├── _expected.yaml # Expected output (full ResourceList YAML) +│ ├── _fnconfig.yaml # FunctionConfig for this test case +│ └── resources.yaml # Input KRM resources +└── test-case-2/ + ├── _expected.yaml + ├── _fnconfig.yaml + └── resources.yaml +``` + +Conventions: +- Files prefixed with `_` are special — they are not included in the input items. +- `_fnconfig.yaml` contains the functionConfig passed to your function. +- `_expected.yaml` contains the expected ResourceList output. +- All other `.yaml`/`.yml` files (and a `Kptfile`, if present) are parsed as input + resources. Files with any other extension are ignored. +- You can have multiple input files (e.g., `deployments.yaml`, `services.yaml`). + Input files are read in sorted (alphabetical) order, so the assembled item + ordering is deterministic. + +### Writing a Golden Test + +```go +package main + +import ( + "context" + "testing" + + "github.com/kptdev/krm-functions-sdk/go/fn" + "github.com/kptdev/krm-functions-sdk/go/fn/testhelpers" +) + +func TestFunction(t *testing.T) { + runner := fn.WithContext(context.TODO(), &SetLabels{}) + testhelpers.RunGoldenTests(t, "testdata", runner) +} +``` + +`RunGoldenTests` will: +1. Discover all subdirectories under `testdata/`. +2. For each subdirectory, parse all non-`_` prefixed YAML files as input items. +3. Parse `_fnconfig.yaml` as the functionConfig. +4. Run your processor against the assembled ResourceList. +5. Compare the output against `_expected.yaml`. + +### Example Test Data + +The following example is illustrative — it shows what test data looks like for a +function that sets labels. The [`go/get-started/`](../go/get-started/) example +provides a minimal working skeleton you can build from. + +`testdata/add-labels/_fnconfig.yaml`: +```yaml +apiVersion: fn.kpt.dev/v1alpha1 +kind: SetLabels +metadata: + name: my-config +labels: + app: my-app +``` + +`testdata/add-labels/resources.yaml`: +```yaml +apiVersion: v1 +kind: Service +metadata: + name: my-service +spec: + selector: + app: my-app +``` + +`testdata/add-labels/_expected.yaml`: +```yaml +apiVersion: config.kubernetes.io/v1 +kind: ResourceList +items: +- apiVersion: v1 + kind: Service + metadata: + name: my-service + labels: + app: my-app + spec: + selector: + app: my-app +functionConfig: + apiVersion: fn.kpt.dev/v1alpha1 + kind: SetLabels + metadata: + name: my-config + labels: + app: my-app +results: +- message: updated labels + severity: info +``` + +## Running Tests + +From your function's module root (where `go.mod` lives): + +```bash +go test ./... +``` + +If the function output does not match `_expected.yaml`, the test fails with a +diff showing what changed. See [`go/get-started/`](../go/get-started/) for a +complete working example. + +## Updating Expected Output + +When your function's output changes intentionally, regenerate the expected files: + +```bash +WRITE_GOLDEN_OUTPUT=1 go test ./... +``` + +This overwrites any `_expected.yaml` that differs from the actual output. Any +non-empty value enables write mode (`WRITE_GOLDEN_OUTPUT=1`, `=true`, etc.). +Note that the run which writes a golden file is reported as a **test failure** +(`wrote output to ...`) — this is intentional, so a rewrite never silently +passes in CI. Re-run the tests without the env var to confirm they pass, and +review the diffs in version control before committing. + +**Caution:** `WRITE_GOLDEN_OUTPUT` accepts whatever the function currently +produces as "correct." If the function has a bug, you have just blessed buggy +output. Golden tests verify *stability* (did the output change?), not +*correctness* (is the output right?). Always review the diffs carefully. +For correctness guarantees, complement golden tests with property-based tests +that assert invariants (e.g., "all resources have the expected label"). + +Note: other kpt ecosystem projects use different env var names for the same +purpose (`KPT_E2E_UPDATE_EXPECTED` in kpt, `UPDATE_GOLDEN_FILES` in porch). +`WRITE_GOLDEN_OUTPUT` is the standard for the SDK and catalog functions. + +## Testing a ResourceListProcessor + +`RunGoldenTests` accepts any `fn.ResourceListProcessor`, so it works with both +`fn.Runner` (wrapped via `fn.WithContext`) and direct `ResourceListProcessor` +implementations: + +```go +func TestGenerator(t *testing.T) { + testhelpers.RunGoldenTests(t, "testdata", &MyGenerator{}) +} +``` + +## Unit Testing Without Golden Files + +For simpler unit tests, you can construct a ResourceList directly: + +```go +func TestSetLabels(t *testing.T) { + input := []byte(` +apiVersion: config.kubernetes.io/v1 +kind: ResourceList +items: +- apiVersion: v1 + kind: ConfigMap + metadata: + name: test +functionConfig: + apiVersion: fn.kpt.dev/v1alpha1 + kind: SetLabels + labels: + env: prod +`) + runner := fn.WithContext(context.TODO(), &SetLabels{}) + output, err := fn.Run(runner, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + rl, err := fn.ParseResourceList(output) + if err != nil { + t.Fatalf("failed to parse output: %v", err) + } + + label, _, _ := rl.Items[0].NestedString("metadata", "labels", "env") + if label != "prod" { + t.Errorf("expected label env=prod, got %q", label) + } +} +``` + +## Tips + +- Keep test cases focused — one behavior per test directory. +- Use descriptive directory names (e.g., `empty-input`, `missing-namespace`, `multiple-resources`). +- The `_fnconfig.yaml` can be empty if your function doesn't require configuration. +- Golden tests also catch unintentional formatting changes. This helps to maintain a stable output. + +## End-to-End Testing + +The SDK's `testhelpers.RunGoldenTests` tests function logic in isolation — no +container, no kpt CLI. For full integration testing (container execution, +`kpt fn eval`/`kpt fn render` pipelines), the kpt repo provides a separate e2e +test runner at +[`pkg/test/runner`](https://github.com/kptdev/kpt/tree/main/pkg/test/runner). + +The e2e runner uses a different test structure (`.expected/` directories with +`config.yaml`, `diff.patch`, `results.yaml`) and is used by the +[krm-functions-catalog](https://github.com/kptdev/krm-functions-catalog) `tests/` +directory to validate the functions running inside the containers against `kpt fn render`. + +--- + +Next: [Containerizing]({{% relref "/guides/krm-functions/containerizing" %}}) — packaging your function as a container image. diff --git a/documentation/content/en/guides/krm-functions/tutorial.md b/documentation/content/en/guides/krm-functions/tutorial.md new file mode 100644 index 0000000000..194ff5fbf9 --- /dev/null +++ b/documentation/content/en/guides/krm-functions/tutorial.md @@ -0,0 +1,237 @@ +--- +title: Tutorial +linkTitle: Tutorial +description: Build a KRM function end to end with the Go SDK. +toc_hide: false +menu: + main: + parent: "KRM Function Developer Guide" + weight: 10 +--- +This tutorial walks through the end-to-end workflow for building a KRM function +using the Go SDK. By the end, you will have a working function with embedded +documentation, golden tests, and support for `--help`, `--doc`, and standalone +file mode. + +For a complete working example, see [`go/get-started/`](https://github.com/kptdev/krm-functions-sdk/tree/main/go/get-started). + +## 1. Create Your Function + +A KRM function implements the `fn.Runner` interface: + +```go +type Runner interface { + Run(context *Context, functionConfig *KubeObject, items KubeObjects, results *Results) bool +} +``` + +Here is a minimal function that sets labels on all the resources: + +```go +package main + +import ( + "context" + _ "embed" + "os" + + "github.com/kptdev/krm-functions-sdk/go/fn" +) + +//go:embed README.md +var readme []byte + +//go:embed metadata.yaml +var metadata []byte + +var _ fn.Runner = &SetLabels{} + +type SetLabels struct { + Labels map[string]string `json:"labels,omitempty"` +} + +func (r *SetLabels) Run(ctx *fn.Context, functionConfig *fn.KubeObject, items fn.KubeObjects, results *fn.Results) bool { + for _, obj := range items { + for k, v := range r.Labels { + if err := obj.SetLabel(k, v); err != nil { + results.ErrorE(err) + } + } + } + return results.ExitCode() == 0 +} + +func main() { + runner := fn.WithContext(context.Background(), &SetLabels{}) + if err := fn.AsMain(runner, fn.WithDocs(readme, metadata)); err != nil { + os.Exit(1) + } +} +``` + +Key points: +- Your struct fields are automatically populated from `functionConfig` (JSON unmarshaling). +- Return `true` for success, `false` for failure. +- Use `results` to report structured messages (info, warning, error). + +## 2. Embed Documentation with `//go:embed` + +The SDK uses Go's embed directive to bundle documentation into the binary. +Two files are needed: + +### README.md + +Use `` markers to define sections that `--help` and `--doc` extract: + + # set-labels + + + Set labels on all resources in the package. + + + + ## Usage + + The `set-labels` function adds or updates labels on all KRM resources. + It accepts a `SetLabels` functionConfig with a `labels` map. + + ### FunctionConfig + + ```yaml + apiVersion: fn.kpt.dev/v1alpha1 + kind: SetLabels + metadata: + name: my-config + labels: + app: my-app + env: production + ``` + + + + + + Set a single label on all resources: + + ```yaml + apiVersion: fn.kpt.dev/v1alpha1 + kind: SetLabels + labels: + team: platform + ``` + + + +### metadata.yaml + +```yaml +image: ghcr.io/kptdev/krm-functions-catalog/set-labels:v0.1 +description: Set labels on all resources +tags: + - mutator + - labels +sourceURL: https://github.com/kptdev/krm-functions-catalog/tree/main/functions/go/set-labels +examplePackageURLs: + - https://github.com/kptdev/krm-functions-catalog/tree/main/examples/set-labels-simple +license: Apache-2.0 +hidden: false +``` + +### Wire it up + +In your `main.go`: + +```go +//go:embed README.md +var readme []byte + +//go:embed metadata.yaml +var metadata []byte + +func main() { + runner := fn.WithContext(context.Background(), &SetLabels{}) + if err := fn.AsMain(runner, fn.WithDocs(readme, metadata)); err != nil { + os.Exit(1) + } +} +``` + +## 3. Running Your Function + +### Standard mode (STDIN/STDOUT) + +Pipe a ResourceList through your function: + +```bash +cat input.yaml | go run . > output.yaml +``` + +### Help mode + +View human-readable documentation: + +```bash +go run . --help +``` + +This prints the Short, Long, and Examples sections extracted from your README markers. + +### Doc mode + +Get machine-readable JSON documentation (consumed by `kpt fn doc` and catalog pipelines): + +```bash +go run . --doc +``` + +### File mode + +Process KRM files directly without constructing a ResourceList: + +```bash +go run . deployment.yaml service.yaml +``` + +This reads the YAML files, assembles them into a ResourceList with an empty +functionConfig, processes them, and writes the result to STDOUT. + +## 4. Testing with Golden Tests + +The SDK provides `testhelpers.RunGoldenTests` for snapshot-based testing. + +Create a test directory structure: + +``` +testdata/ +├── test-case-1/ +│ ├── _expected.yaml # Expected output (ResourceList YAML) +│ ├── _fnconfig.yaml # FunctionConfig for this test case +│ └── resources.yaml # Input resources +└── test-case-2/ + ├── _expected.yaml + ├── _fnconfig.yaml + └── resources.yaml +``` + +Write your test: + +```go +func TestFunction(t *testing.T) { + runner := fn.WithContext(context.TODO(), &SetLabels{}) + testhelpers.RunGoldenTests(t, "testdata", runner) +} +``` + +Update expected output after changes: + +```bash +WRITE_GOLDEN_OUTPUT=1 go test ./... +``` + +See [Testing]({{% relref "/guides/krm-functions/testing" %}}) for more details. + +## 5. Next Steps + +- [Interfaces]({{% relref "/guides/krm-functions/interfaces" %}}) — when to use `fn.Runner` vs `fn.ResourceListProcessor` +- [Testing]({{% relref "/guides/krm-functions/testing" %}}) — golden test patterns in depth +- [Containerizing]({{% relref "/guides/krm-functions/containerizing" %}}) — packaging your function as a container image