framework

package
v0.10.10 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 8, 2026 License: Apache-2.0 Imports: 59 Imported by: 0

README

testing/framework — deckhouse-style hook test harness

Functional, end-to-end testing for module hooks without a real Kubernetes cluster, addon-operator, or shell-operator.

The framework spins up a fake dynamic Kubernetes client, generates snapshots from your hook's KubernetesConfig bindings, runs the handler with a real *pkg.HookInput, and replays every recorded patch operation against the fake cluster — so you can assert on cluster state directly.

It mirrors the flow of deckhouse/testing/hooks, but has no dependency on addon-operator or shell-operator.

When to use it

Use the framework for functional tests of a hook:

  • the hook reads multiple kinds of resources via KubernetesConfig bindings;
  • the hook produces a sequence of Create / Delete / Patch operations and you want to assert on the resulting cluster state;
  • the hook uses input.DC.GetK8sClient() to interact with the API server directly;
  • you want a single test to walk through several state transitions (KubeStateSetRunHook → assert → KubeStateSetRunHook → assert).

For small unit tests where you only care about a single path, prefer testing/helpers.

Quick start

package myhook_test

import (
    "context"
    "testing"

    "github.com/stretchr/testify/require"

    "github.com/deckhouse/module-sdk/pkg"
    "github.com/deckhouse/module-sdk/testing/framework"
)

func TestMyHook(t *testing.T) {
    cfg := &pkg.HookConfig{
        Kubernetes: []pkg.KubernetesConfig{{
            Name:       "nodes",
            APIVersion: "v1",
            Kind:       "Node",
            JqFilter:   `{name: .metadata.name}`,
        }},
    }

    handler := func(_ context.Context, in *pkg.HookInput) error {
        in.Values.Set("count", len(in.Snapshots.Get("nodes")))
        return nil
    }

    f := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`)

    f.KubeStateSet(`
---
apiVersion: v1
kind: Node
metadata:
  name: kube-worker-1
---
apiVersion: v1
kind: Node
metadata:
  name: kube-worker-2
`)

    f.RunHook()

    require.NoError(t, f.HookError())
    require.Equal(t, int64(2), f.ValuesGet("count").Int())
}

API reference

Construction
Function Purpose
HookExecutionConfigInit(t, cfg, handler, initValues, initConfigValues) Deckhouse-compatible constructor. initValues / initConfigValues accept JSON or YAML; pass "{}" if not needed.
NewHookExecutionConfig(t, cfg, handler, opts...) Same, but with explicit Options. Accepts WithInitialValues, WithInitialConfigValues, WithSchemeBuilder, WithCRD, WithOpenAPIDir, WithValuesSchema, WithConfigValuesSchema.

t is a testing.TB, so *testing.T, sub-tests, and GinkgoT() all work.

Cluster state
Method Purpose
KubeStateSet(yaml) Replace all objects in the fake cluster with the resources defined in the multi-document YAML. Each RunHook after this regenerates snapshots from the new state.
AddKubeObject(yaml) Add objects without resetting state.
KubernetesResource(kind, namespace, name) Fetch a current object as *unstructured.Unstructured (or nil if not found).
KubernetesGlobalResource(kind, name) Same, for cluster-scoped resources.
KubeClient() Raw dynamic.Interface — escape hatch when you need to seed something the YAML loader cannot express.
Custom resources
f := framework.NewHookExecutionConfig(t, cfg, handler,
    framework.WithSchemeBuilder(myapis.SchemeBuilder), // for typed CRDs
    framework.WithCRD("acme.io", "v1", "Widget", true), // for ad-hoc CRs
)

// or, after construction:
f.RegisterCRD("acme.io", "v1", "Widget", true)

WithSchemeBuilder is preferred when the CRD has Go types you can import; WithCRD / RegisterCRD is used to teach the GVR resolver about a kind that lives only in YAML.

OpenAPI defaults

In production, addon-operator applies defaults from the module's OpenAPI schemas (openapi/values.yaml and openapi/config-values.yaml) before invoking a hook. The framework can do the same so tests don't drift from real-world behaviour:

f := framework.NewHookExecutionConfig(t, cfg, handler,
    framework.WithOpenAPIDir("../openapi"),
    framework.WithInitialValues(`{"https": {"mode": "CertManager"}}`),
)

Behaviour:

  • WithOpenAPIDir(dir) looks for <dir>/values.yaml and <dir>/config-values.yaml. Whichever ones are present are loaded.
  • For each schema, the framework extracts every default: declared in it and uses the result as a baseline values document.
  • Anything passed via WithInitialValues / WithInitialConfigValues is then deep-merged on top — your test's values always override schema defaults.
  • The x-extend extension is honoured. If values.yaml declares x-extend.schema: config-values.yaml, the values store inherits all defaults from config-values.yaml plus its own.

For more granular control use WithValuesSchema(path) / WithConfigValuesSchema(path) instead — they fail the test if the file is missing.

The lower-level helpers LoadOpenAPISchema, SchemaDefaults, and MergeValues are also exported, which is handy when you want to assemble a full values document outside NewHookExecutionConfig:

schema, err := framework.LoadOpenAPISchema("../openapi/values.yaml")
require.NoError(t, err)
defaults := framework.SchemaDefaults(schema)
merged := framework.MergeValues(defaults, map[string]any{
    "replicas": 5,
})
Values
Method Purpose
ValuesGet(path) gjson.Result Read current values at a dotted path.
ConfigValuesGet(path) gjson.Result Same, for ConfigValues.
ValuesSet(path, any) / ConfigValuesSet(path, any) Set a value (persists across RunHook calls).
ValuesSetFromYaml(path, []byte) / ConfigValuesSetFromYaml(path, []byte) Same, but parses YAML.
ValuesDelete(path) / ConfigValuesDelete(path) Remove a path.
ValuesJSON() / ConfigValuesJSON() Whole-document JSON for snapshot-style assertions.
Running and inspecting
Method Purpose
RunHook() / RunHookCtx(ctx) Generate snapshots, build HookInput, invoke the handler, apply values patches, replay cluster patches.
HookError() error Error returned by the handler from the most recent RunHook.
Snapshots() pkg.Snapshots Snapshots that were passed to the hook.
PatchedOperations() []RecordedPatch Typed view of every Create/Delete/Patch issued by the hook.
PatchOperations() []pkg.PatchCollectorOperation The same, but cast to the pkg.PatchCollectorOperation interface.
CollectedMetrics() []MetricOperation Metric operations emitted via input.MetricsCollector.
Logger() *log.Logger / LoggerOutput() *bytes.Buffer Test logger and its captured output.
DependencyContainer() The framework's DC. Use SetHTTPClient, SetRegistryClient, SetClock to inject mocks before RunHook.

How it works

RunHook runs the same five-step pipeline every time:

  1. Generate snapshots. For each KubernetesConfig binding, the framework lists matching resources from the fake cluster (honouring NameSelector, NamespaceSelector, LabelSelector, FieldSelector), then runs JqFilter on each match.
  2. Build a real HookInput. Values and config values are wrapped in pkg/patchable-values.PatchableValues, the patch collector is a recordingPatchCollector, and the metrics collector is a real internal/metric.Collector.
  3. Invoke the handler. Errors are captured in HookError().
  4. Apply values patches. The framework merges the patches the hook produced via input.Values.Set/Remove back into its values store (and same for config values).
  5. Replay cluster patches. Each recorded Create / Delete / MergePatch / JSONPatch / JQFilter is applied to the fake dynamic client, so KubernetesResource(...) returns the post-hook state.

If the handler returned an error, step 5 is skipped — error-path tests can still assert on values patches and the recorded operations the hook intended to issue.

Pitfalls and tips

  • The fake client uses meta.UnsafeGuessKindToResource for GVR mapping. Standard Kubernetes kinds (Pod, Node, StatefulSet, …) work out of the box; custom kinds need WithCRD or WithSchemeBuilder.
  • KubeStateSet rebuilds the fake client; if you keep references to objects fetched before, refresh them with KubernetesResource.
  • The DependencyContainer's HTTP and registry clients return errors by default. If your hook calls input.DC.GetHTTPClient() you must override them via f.DependencyContainer().SetHTTPClient(...) before RunHook.
  • LoggerOutput() captures everything the hook logs, including the framework's own diagnostic messages — use strings.Contains rather than line-by-line equality.

Real-world examples in this repo

Documentation

Overview

Package framework provides a deckhouse-style testing framework for module-sdk hooks.

It is inspired by deckhouse/testing/hooks but does not depend on addon-operator or shell-operator. Internally it uses a fake Kubernetes client (k8s.io/client-go/dynamic/fake) to simulate cluster state.

Typical usage:

func TestMyHook(t *testing.T) {
    f := framework.HookExecutionConfigInit(t, hookConfig, MyHookHandler, `{}`, `{}`)

    f.KubeStateSet(`
---
apiVersion: v1
kind: Node
metadata:
  name: kube-worker-1
`)

    f.RunHook()

    require.NoError(t, f.HookError())
    require.Len(t, f.Snapshots().Get("nodes"), 1)
    require.Equal(t, "value", f.ValuesGet("my.field").String())
}

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func LoadOpenAPISchema added in v0.10.10

func LoadOpenAPISchema(path string) (map[string]any, error)

LoadOpenAPISchema reads an OpenAPI v3 schema from a YAML or JSON file and returns the parsed document.

If the schema document declares the addon-operator x-extend extension, e.g.:

x-extend:
  schema: config-values.yaml

LoadOpenAPISchema also loads the referenced schema (resolved relative to the current schema's directory) and merges it as a parent: the parent's `properties`, `patternProperties`, `definitions`, `required` and extensions are folded into the current schema, with the current schema winning on conflicts. This mirrors the behaviour of addon-operator's ExtendTransformer.

LoadOpenAPISchema does not resolve `$ref`s.

func MergeValues added in v0.10.10

func MergeValues(base, override map[string]any) map[string]any

MergeValues deep-merges override into base and returns the result.

Object-typed values are merged property-by-property (recursing). For arrays and scalar values the override replaces the base entirely.

Neither input is modified.

MergeValues is the natural counterpart to SchemaDefaults: combine defaults extracted from a schema with values supplied by the test, letting the test's values win on every conflict.

func SchemaDefaults added in v0.10.10

func SchemaDefaults(schema map[string]any) map[string]any

SchemaDefaults walks an OpenAPI schema (as returned by LoadOpenAPISchema) and produces a values document populated with the schema's `default:` values.

The algorithm mirrors addon-operator's defaulting:

  • A property's `default:` (if any) is used as the starting value.
  • For object-typed properties, sub-properties' defaults are then applied for any keys the explicit `default:` left empty.
  • For arrays, items' defaults are applied to each existing item of the explicit `default:` list (item entries themselves are never synthesised from `items.default`).

SchemaDefaults always returns a non-nil map (possibly empty).

Types

type HookExecutionConfig

type HookExecutionConfig struct {
	// contains filtered or unexported fields
}

HookExecutionConfig is the main entry point for hook tests. It encapsulates the hook under test, a fake Kubernetes cluster, values stores, and collectors for patch operations and metrics.

A HookExecutionConfig is created with HookExecutionConfigInit (deckhouse-style) or NewHookExecutionConfig (with options).

func HookExecutionConfigInit

func HookExecutionConfigInit(t testing.TB, config *pkg.HookConfig, handler HookFunc, initValues, initConfigValues string) *HookExecutionConfig

HookExecutionConfigInit creates a deckhouse-style execution config.

initValues and initConfigValues are JSON or YAML strings representing the initial Helm values and module config values. Pass "{}" or "" if not needed.

func NewHookExecutionConfig

func NewHookExecutionConfig(t testing.TB, config *pkg.HookConfig, handler HookFunc, opts ...Option) *HookExecutionConfig

NewHookExecutionConfig creates an execution config with options.

func (*HookExecutionConfig) AddKubeObject

func (h *HookExecutionConfig) AddKubeObject(yamlObject string)

AddKubeObject appends one or more objects (multi-document YAML) to the fake cluster without resetting existing state.

func (*HookExecutionConfig) CollectedMetrics

func (h *HookExecutionConfig) CollectedMetrics() []MetricOperation

CollectedMetrics returns the metric operations recorded by the hook during the most recent RunHook call.

func (*HookExecutionConfig) ConfigValuesDelete

func (h *HookExecutionConfig) ConfigValuesDelete(path string)

ConfigValuesDelete removes a config value at path.

func (*HookExecutionConfig) ConfigValuesGet

func (h *HookExecutionConfig) ConfigValuesGet(path string) gjson.Result

ConfigValuesGet returns the current config value at path.

func (*HookExecutionConfig) ConfigValuesJSON

func (h *HookExecutionConfig) ConfigValuesJSON() []byte

ConfigValuesJSON returns the current config values as a JSON string.

func (*HookExecutionConfig) ConfigValuesSet

func (h *HookExecutionConfig) ConfigValuesSet(path string, value any)

ConfigValuesSet sets a config value at path.

func (*HookExecutionConfig) ConfigValuesSetFromYaml

func (h *HookExecutionConfig) ConfigValuesSetFromYaml(path string, raw []byte)

ConfigValuesSetFromYaml parses YAML and sets the result at path.

func (*HookExecutionConfig) DependencyContainer

func (h *HookExecutionConfig) DependencyContainer() *frameworkDC

DependencyContainer returns the framework's dependency container so that tests can override its HTTP / registry / clock components before RunHook.

Example:

hec.DependencyContainer().SetHTTPClient(myFakeHTTP)

func (*HookExecutionConfig) HookError

func (h *HookExecutionConfig) HookError() error

HookError returns the error returned by the hook handler from the most recent RunHook call.

func (*HookExecutionConfig) KubeClient

func (h *HookExecutionConfig) KubeClient() dynamic.Interface

KubeClient returns the underlying fake dynamic client. Use it to inspect or seed cluster state directly.

func (*HookExecutionConfig) KubeStateSet

func (h *HookExecutionConfig) KubeStateSet(yamlState string)

KubeStateSet replaces the fake cluster state with the resources defined in the provided multi-document YAML manifest.

Documents may be separated by '---'. Each document must include apiVersion, kind, metadata.name, and (for namespaced resources) metadata.namespace.

All previously-stored objects are removed before the new state is applied, so a single test can call KubeStateSet multiple times to simulate state transitions.

Snapshots used by RunHook are regenerated from the new cluster state and the hook's KubernetesConfig bindings.

func (*HookExecutionConfig) KubernetesGlobalResource

func (h *HookExecutionConfig) KubernetesGlobalResource(kind, name string) *unstructured.Unstructured

KubernetesGlobalResource returns a cluster-scoped resource by kind and name. Returns nil if not found.

func (*HookExecutionConfig) KubernetesResource

func (h *HookExecutionConfig) KubernetesResource(kind, namespace, name string) *unstructured.Unstructured

KubernetesResource returns a fake-cluster resource by kind, namespace, and name. Namespace can be empty for cluster-scoped resources. Returns nil if the resource is not found.

func (*HookExecutionConfig) Logger

func (h *HookExecutionConfig) Logger() *log.Logger

Logger returns the test logger (its output is captured in LoggerOutput).

func (*HookExecutionConfig) LoggerOutput

func (h *HookExecutionConfig) LoggerOutput() *bytes.Buffer

LoggerOutput returns the buffer of captured log output, useful for assertions.

func (*HookExecutionConfig) PatchOperations

func (h *HookExecutionConfig) PatchOperations() []pkg.PatchCollectorOperation

PatchOperations returns the patch operations recorded by the hook during the most recent RunHook call.

func (*HookExecutionConfig) PatchedOperations

func (h *HookExecutionConfig) PatchedOperations() []RecordedPatch

PatchedOperations returns the typed slice of recorded patch operations (one entry per Create/Delete/Patch/... call). This is more convenient for assertions than PatchOperations.

func (*HookExecutionConfig) RegisterCRD

func (h *HookExecutionConfig) RegisterCRD(group, version, kind string, namespaced bool)

RegisterCRD makes a custom resource known to the fake cluster. After this call, objects of this kind can be supplied via KubeStateSet, listed via KubernetesResource, and used in KubernetesConfig snapshot bindings.

Use it for CRDs that are not registered through a typed runtime.SchemeBuilder.

Example:

hec.RegisterCRD("example.com", "v1alpha1", "Widget", true)

func (*HookExecutionConfig) RunHook

func (h *HookExecutionConfig) RunHook()

RunHook executes the registered hook handler against the current state.

The framework:

  1. Generates snapshots from the fake cluster according to the hook's KubernetesConfig bindings.
  2. Builds a real pkg.HookInput backed by working values stores, a recording PatchCollector, and a Collector for metrics.
  3. Invokes the hook handler with that input.
  4. Applies the values patches produced by the hook to the values store.
  5. Replays the recorded patch operations against the fake cluster.

After RunHook, use HookError, ValuesGet, ConfigValuesGet, KubernetesResource, PatchedOperations and CollectedMetrics to assert behaviour.

func (*HookExecutionConfig) RunHookCtx

func (h *HookExecutionConfig) RunHookCtx(ctx context.Context)

RunHookCtx is like RunHook but accepts an explicit context.

func (*HookExecutionConfig) Snapshots

func (h *HookExecutionConfig) Snapshots() pkg.Snapshots

Snapshots returns the snapshots that were passed to the hook on the most recent RunHook call.

func (*HookExecutionConfig) ValuesDelete

func (h *HookExecutionConfig) ValuesDelete(path string)

ValuesDelete removes a value at path.

func (*HookExecutionConfig) ValuesGet

func (h *HookExecutionConfig) ValuesGet(path string) gjson.Result

ValuesGet returns the current value at path (gjson dotted path).

func (*HookExecutionConfig) ValuesJSON

func (h *HookExecutionConfig) ValuesJSON() []byte

ValuesJSON returns the current values as a JSON string. Mostly useful for debugging or asserting full document state.

func (*HookExecutionConfig) ValuesSet

func (h *HookExecutionConfig) ValuesSet(path string, value any)

ValuesSet sets a value at path. The value is written directly into the values store; it persists across RunHook calls.

func (*HookExecutionConfig) ValuesSetFromYaml

func (h *HookExecutionConfig) ValuesSetFromYaml(path string, raw []byte)

ValuesSetFromYaml parses YAML and sets the result at path.

type HookFunc

type HookFunc = pkg.HookFunc[*pkg.HookInput]

HookFunc is the type of hook handler functions tested by the framework.

type MetricOperation

type MetricOperation struct {
	Name   string
	Group  string
	Action string
	Value  *float64
	Labels map[string]string
}

MetricOperation is a stable, framework-friendly view of a metric operation.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option configures a HookExecutionConfig at construction time.

func WithCRD

func WithCRD(group, version, kind string, namespaced bool) Option

WithCRD registers a custom resource definition with the fake cluster so that resources of this kind can be created/listed via the dynamic client.

Use this when your hook reads or writes CRs not registered through a runtime.SchemeBuilder.

func WithConfigValuesSchema added in v0.10.10

func WithConfigValuesSchema(path string) Option

WithConfigValuesSchema does the same as WithValuesSchema for the module's config values schema (typically `openapi/config-values.yaml`).

func WithInitialConfigValues

func WithInitialConfigValues(v string) Option

WithInitialConfigValues sets the initial module config values JSON or YAML.

func WithInitialValues

func WithInitialValues(v string) Option

WithInitialValues sets the initial Helm values JSON or YAML.

func WithOpenAPIDir added in v0.10.10

func WithOpenAPIDir(dir string) Option

WithOpenAPIDir is a convenience wrapper that points the framework at a directory containing the standard module OpenAPI files:

<dir>/values.yaml
<dir>/config-values.yaml

Either file may be absent. Whichever ones are present are loaded and their defaults are merged under any test-supplied values.

Example:

hec := framework.NewHookExecutionConfig(t, cfg, handler,
    framework.WithOpenAPIDir("../openapi"),
    framework.WithInitialValues(`{"https": {"mode": "CertManager"}}`),
)

func WithSchemeBuilder

func WithSchemeBuilder(builder runtime.SchemeBuilder) Option

WithSchemeBuilder registers an additional runtime.SchemeBuilder so that typed CRDs from your module can be used in YAML state and assertions.

func WithValuesSchema added in v0.10.10

func WithValuesSchema(path string) Option

WithValuesSchema reads an OpenAPI v3 schema from the given file path, extracts a values document populated with all `default:` values, and uses it as the baseline for the framework's `Values`. Anything passed via WithInitialValues (or HookExecutionConfigInit's initValues) is then deep-merged on top, so test-supplied values override schema defaults.

The schema may use the addon-operator `x-extend` extension to inherit `properties` / `required` from a sibling schema (typically config-values.yaml). See LoadOpenAPISchema for details.

Construction fails the test (via testing.TB.Fatalf) if the file is missing or malformed. Use WithOpenAPIDir if you want missing files to be silently ignored.

type PatchType

type PatchType string

PatchType identifies a recorded patch operation kind.

const (
	PatchTypeCreate             PatchType = "Create"
	PatchTypeCreateOrUpdate     PatchType = "CreateOrUpdate"
	PatchTypeCreateIfNotExists  PatchType = "CreateIfNotExists"
	PatchTypeDelete             PatchType = "Delete"
	PatchTypeDeleteInBackground PatchType = "DeleteInBackground"
	PatchTypeDeleteNonCascading PatchType = "DeleteNonCascading"
	PatchTypeJSONPatch          PatchType = "JSONPatch"
	PatchTypeMergePatch         PatchType = "MergePatch"
	PatchTypeJQFilter           PatchType = "JQFilter"
)

type RecordedPatch

type RecordedPatch struct {
	Type PatchType

	// For Create*: holds the runtime.Object / map / Unstructured.
	Object any

	// For Delete* / Patch* operations.
	APIVersion string
	Kind       string
	Namespace  string
	Name       string

	// For Patch operations.
	JSONPatch  any
	MergePatch any
	JQFilter   string

	// Original options as passed by the hook.
	Options []pkg.PatchCollectorOption
}

RecordedPatch is a structured copy of a single patch operation issued by a hook. It captures the operation type and all parameters so that tests can assert on the hook's intent.

func (*RecordedPatch) Description

func (r *RecordedPatch) Description() string

pkg.PatchCollectorOperation implementation.

func (*RecordedPatch) SetObjectPrefix

func (r *RecordedPatch) SetObjectPrefix(prefix string)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL