Skip to content

Commit beeec32

Browse files
committed
add docs
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
1 parent 004da15 commit beeec32

12 files changed

Lines changed: 1203 additions & 10 deletions

File tree

README.md

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,16 @@ func main() {
7676
}
7777
```
7878

79-
More examples you can find [here](./examples)
79+
More examples you can find [here](./examples).
80+
81+
### Reusable building blocks
82+
83+
| Area | What you get | Read more |
84+
| --- | --- | --- |
85+
| Common hooks | Battery-included hooks for TLS, custom certificates, storage-class changes, external auth, CRD installation. | [`common-hooks/`](./common-hooks) |
86+
| Testing — unit tests | `InputBuilder`, `StaticSnapshots`, `RecordingPatchCollector`, `JQRunOn*` and friends. | [`testing/helpers/`](./testing/helpers) |
87+
| Testing — functional tests | Deckhouse-style harness with a fake K8s cluster, snapshot generator, and patch replayer. | [`testing/framework/`](./testing/framework) |
88+
| Testing — strategy | Picking the right test layer, project-wide conventions. | [`TESTING.md`](./TESTING.md) |
8089

8190
## Adding Readiness Probes
8291

@@ -261,7 +270,52 @@ Settings validation allows you to validate module configuration values before th
261270

262271
## Testing
263272

264-
If you want to test your JQ filter, you can use JQ helper like in example [here](./pkg/jq/jq_test.go)
273+
The SDK ships with a layered testing toolkit that lets you test hooks at three levels of fidelity:
274+
275+
- **Unit tests** — quick handler-level tests using [`testing/helpers`](./testing/helpers): `InputBuilder`, real values store, `RecordingPatchCollector`, JQ helpers.
276+
- **Functional tests** — deckhouse-style end-to-end tests using [`testing/framework`](./testing/framework): a fake Kubernetes cluster, real snapshot generation, replayed patches.
277+
- **Mocks** — minimock-generated mocks for every `pkg.*` interface ([`testing/mock`](./testing/mock)) when you need precise control over a single collaborator.
278+
279+
Quick hook unit test:
280+
281+
```go
282+
import "github.com/deckhouse/module-sdk/testing/helpers"
283+
284+
func TestMyHook(t *testing.T) {
285+
in := helpers.NewInputBuilder(t).
286+
WithSnapshot("nodes", helpers.SnapshotJSON(`{"name":"n1"}`)).
287+
WithValuesJSON(`{}`).
288+
Build()
289+
290+
require.NoError(t, MyHook(context.Background(), in))
291+
require.Len(t, in.Values.GetPatches(), 1)
292+
}
293+
```
294+
295+
Quick hook functional test:
296+
297+
```go
298+
import "github.com/deckhouse/module-sdk/testing/framework"
299+
300+
func TestMyHook_Functional(t *testing.T) {
301+
f := framework.HookExecutionConfigInit(t, cfg, MyHook, `{}`, `{}`)
302+
f.KubeStateSet(`apiVersion: v1
303+
kind: Node
304+
metadata: {name: n1}`)
305+
f.RunHook()
306+
307+
require.NoError(t, f.HookError())
308+
require.Len(t, f.Snapshots().Get("nodes"), 1)
309+
}
310+
```
311+
312+
Pure JQ filter test:
313+
314+
```go
315+
helpers.JQRunOnString(ctx, ".metadata.name", `{"metadata":{"name":"x"}}`, &out)
316+
```
317+
318+
For the project-wide testing strategy and conventions, see [`TESTING.md`](./TESTING.md).
265319

266320
## For deckhouse developers
267321

TESTING.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Testing Module SDK hooks
2+
3+
This document describes how we test hooks built on top of the Module SDK.
4+
5+
The goal is simple: **let module developers test hook logic with the same speed and confidence as plain Go code**, without standing up a real Kubernetes cluster, addon-operator, or shell-operator.
6+
7+
## TL;DR
8+
9+
There are three layers, picked by the size of the test you want to write:
10+
11+
| Layer | Use it for | Speed | Lives at |
12+
| --- | --- | --- | --- |
13+
| **Mocks** | Tests that need precise control over a single dependency | µs | [`testing/mock`](./testing/mock) |
14+
| **Helpers** | Unit tests of a single hook handler | µs | [`testing/helpers`](./testing/helpers) |
15+
| **Framework** | Functional tests driving the whole hook pipeline against a fake K8s cluster | ms | [`testing/framework`](./testing/framework) |
16+
17+
Pick the smallest layer that lets you write the assertion you actually care about.
18+
19+
## Test pyramid
20+
21+
```text
22+
┌──────────────────────┐
23+
│ framework (slow) │ end-to-end behaviour
24+
├──────────────────────┤
25+
│ helpers (fast) │ handler-level units
26+
├──────────────────────┤
27+
│ mock (fastest) │ single-collaborator
28+
└──────────────────────┘
29+
```
30+
31+
Most modules end up with a wide base of `helpers`-based unit tests and a thin layer of `framework` functional tests for the trickiest paths.
32+
33+
## Layer 1 — `testing/mock`
34+
35+
`testing/mock` is generated with [`minimock`](https://github.com/gojuno/minimock) from the interfaces in [`pkg`](./pkg). It is the lowest-level layer: you compose mocks yourself and assemble a `*pkg.HookInput` by hand.
36+
37+
Use it when you need **precise control** over a single dependency:
38+
39+
```go
40+
values := mock.NewOutputPatchableValuesCollectorMock(t)
41+
values.GetMock.When("global.discovery.clusterDomain").
42+
Then(gjson.Result{Type: gjson.String, Str: "cluster.local"})
43+
44+
input := &pkg.HookInput{
45+
Values: values,
46+
Logger: log.NewNop(),
47+
}
48+
require.NoError(t, MyHook(ctx, input))
49+
```
50+
51+
This layer is the right call when:
52+
53+
- you want to assert on the **exact sequence** of calls a hook makes against a collaborator;
54+
- you need to inject an **error from a specific call** (e.g. `ArrayCount → error`);
55+
- the hook is small enough that a real values store is overkill.
56+
57+
## Layer 2 — `testing/helpers`
58+
59+
`testing/helpers` is the **default** unit-test layer. It bundles a set of small, hand-written building blocks on top of the real implementations from `pkg/*`:
60+
61+
- `InputBuilder` — fluent assembly of `*pkg.HookInput`.
62+
- `StaticSnapshots` — in-memory `pkg.Snapshots` backed by JSON / YAML / Go values.
63+
- `RecordingPatchCollector` — a `pkg.PatchCollector` that records every call.
64+
- `NewValuesFromJSON/YAML/Map` — a real `pkg.PatchableValuesCollector` seeded from your input.
65+
- `JQRunOnString/Object` — apply a JQ filter and decode the result in one call.
66+
67+
Typical test:
68+
69+
```go
70+
in := helpers.NewInputBuilder(t).
71+
WithSnapshot("nodes", helpers.SnapshotJSON(`{"name":"n1"}`)).
72+
WithValuesJSON(`{"my":{"existing":"value"}}`).
73+
WithRecordingPatchCollector().
74+
Build()
75+
76+
require.NoError(t, MyHook(context.Background(), in))
77+
78+
// Real values store: actual patches were recorded.
79+
require.Len(t, in.Values.GetPatches(), 1)
80+
```
81+
82+
Use this layer when:
83+
84+
- you know exactly what the hook should see and do;
85+
- you want to test the **happy path and a few error paths** of a single handler;
86+
- you're writing **a JQ filter test** with `helpers.JQRunOn*`.
87+
88+
A complete reference is at [`testing/helpers/README.md`](./testing/helpers/README.md).
89+
90+
## Layer 3 — `testing/framework`
91+
92+
`testing/framework` is the **functional-test** layer, mirroring [`deckhouse/testing/hooks`](https://github.com/deckhouse/deckhouse/tree/main/testing/hooks). The framework:
93+
94+
1. owns a **fake dynamic Kubernetes client** seeded from YAML;
95+
2. **generates snapshots** from the hook's `KubernetesConfig` bindings (selectors + JQ);
96+
3. runs the handler with a real `*pkg.HookInput`;
97+
4. **applies values patches** back to the values store;
98+
5. **replays cluster patches** (`Create` / `Delete` / `Patch`) against the fake cluster.
99+
100+
After `RunHook`, you assert on:
101+
- snapshots passed in,
102+
- final values & config values,
103+
- recorded patch operations,
104+
- post-hook cluster state via `KubernetesResource(...)`,
105+
- collected metrics,
106+
- captured logs.
107+
108+
Typical test:
109+
110+
```go
111+
f := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`)
112+
f.KubeStateSet(`
113+
---
114+
apiVersion: v1
115+
kind: Pod
116+
metadata: {name: app, namespace: default}
117+
status: {phase: Running}
118+
`)
119+
f.RunHook()
120+
121+
require.NoError(t, f.HookError())
122+
require.NotNil(t, f.KubernetesResource("ConfigMap", "default", "app-status"))
123+
```
124+
125+
Use this layer when:
126+
127+
- the hook reads multiple bindings or relies on label / namespace selectors;
128+
- the hook ends up issuing several patch operations and you want to verify the **resulting cluster state**;
129+
- the hook talks to the API server through `input.DC.GetK8sClient()`;
130+
- you want one test to walk through several state transitions.
131+
132+
A complete reference is at [`testing/framework/README.md`](./testing/framework/README.md).
133+
134+
## Picking a layer in practice
135+
136+
A small flowchart:
137+
138+
```text
139+
Are you only testing a JQ filter?
140+
└─► helpers.JQRunOn{String,Object}
141+
142+
Are you testing a single handler in isolation,
143+
with snapshots and values you can describe inline?
144+
└─► helpers.NewInputBuilder + RecordingPatchCollector
145+
146+
Do you need to assert on the resulting Kubernetes objects
147+
(Create + Delete chains, Patch results, JQ mutations)?
148+
└─► testing/framework
149+
150+
Do you need to inject a very specific failure from one
151+
collaborator (e.g. ArrayCount returning an error)?
152+
└─► testing/mock + a hand-built *pkg.HookInput
153+
```
154+
155+
It's normal — and expected — to mix layers in the same package. See e.g. [`examples/example-module/hooks/subfolder`](./examples/example-module/hooks/subfolder), where:
156+
157+
- `*_test.go` files use `helpers.NewInputBuilder` for the bulk of unit tests;
158+
- `*_framework_test.go` files use `testing/framework` for end-to-end coverage;
159+
- a handful of error-path tests fall back to `testing/mock` for a specific failure.
160+
161+
## Project-wide testing conventions
162+
163+
- **No global state.** Tests should not rely on `registry.Registry()` having a particular content; build a `*pkg.HookConfig` locally in the test or share it via a small helper in the package.
164+
- **Ginkgo is being phased out.** New tests should use plain `*testing.T` + `testify`. Existing Ginkgo suites are migrated when their package is touched.
165+
- **Real values stores beat value mocks.** If you can use `helpers.NewValuesFromJSON`, do — the assertions become "did the hook produce the right `add`/`remove` operations?", which is more honest than "did the hook call `Set` with these arguments?".
166+
- **JQ filters are tested in isolation** via `helpers.JQRunOn{String,Object}`. This keeps the filter expression visible in the test source and decouples it from the hook handler.
167+
- **Functional tests are sparse but high-value.** Aim for a handful of `framework` tests per hook, not one per code path.
168+
- **Lint and vet are required.** `go vet ./...` and `golangci-lint run ./...` must stay green; this is enforced by `make test` and `make lint`.
169+
170+
## Running the tests
171+
172+
```sh
173+
# Module SDK and common-hooks
174+
make test
175+
make lint
176+
177+
# Each example module is a standalone Go module under examples/.
178+
make examples
179+
```
180+
181+
Each example module has its own `go.mod` and its own test suite — they are deliberately self-contained so they double as documentation.
182+
183+
## See also
184+
185+
- [`testing/README.md`](./testing/README.md) — overview of the testing tree.
186+
- [`testing/framework/README.md`](./testing/framework/README.md) — functional-test harness reference.
187+
- [`testing/helpers/README.md`](./testing/helpers/README.md) — unit-test helper reference.
188+
- [`pkg/jq`](./pkg/jq) — JQ engine, useful when you want to debug a snapshot filter expression.

common-hooks/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# `common-hooks/` — reusable Module SDK hooks
2+
3+
`common-hooks/` ships a curated set of hooks that solve recurring needs across Deckhouse modules. Importing one of them is usually a one-liner: each package exports a `RegisterHook(...)` (or similar) function that takes module-specific arguments and registers a fully-configured hook with the SDK's global registry.
4+
5+
## Available hooks
6+
7+
| Package | What it does |
8+
| --- | --- |
9+
| [`copy-custom-certificate`](./copy-custom-certificate) | Copies user-provided TLS certificates from `d8-system` Secrets into the module's internal values, gated by the module's HTTPS mode. |
10+
| [`ensure_crds`](./ensure_crds) | Installs (or updates) all CRD YAMLs matched by a glob, on module startup. |
11+
| [`external_auth`](./external_auth) | Wires up Dex-based or user-provided external authentication settings into the module values. |
12+
| [`storage-class-change`](./storage-class-change) | Tracks storage-class changes, evicts pods on stale PVCs, and re-creates StatefulSets / Deployments / Prometheuses when the effective storage class changes. |
13+
| [`tls-certificate`](./tls-certificate) | Generates or refreshes self-signed TLS certificates and orders Kubernetes-signed certificates via the `certificates.k8s.io` API. |
14+
15+
## Usage shape
16+
17+
Most common hooks follow the same shape:
18+
19+
```go
20+
package hooks
21+
22+
import (
23+
sccc "github.com/deckhouse/module-sdk/common-hooks/storage-class-change"
24+
)
25+
26+
var _ = sccc.RegisterHook(sccc.Args{
27+
ModuleName: "myModule",
28+
Namespace: "d8-my-module",
29+
LabelSelectorKey: "app",
30+
LabelSelectorValue: "data",
31+
ObjectKind: "StatefulSet",
32+
ObjectName: "data-set",
33+
})
34+
```
35+
36+
A few notes:
37+
38+
- The `_ = ...RegisterHook(...)` idiom registers the hook at package init via the SDK registry. Importing the package is enough to enable the hook.
39+
- Each hook embeds its own `pkg.HookConfig` (binding contexts, schedules, JQ filters, …); you only supply module-level parameters.
40+
- For application hooks use the `…Application` variants where they exist; module hooks use the `pkg.HookInput` shape.
41+
42+
## Testing
43+
44+
Each common hook ships with both **unit** and (where applicable) **functional** tests:
45+
46+
- Unit tests live next to the hook (`*_test.go`) and rely on [`testing/helpers`](../testing/helpers).
47+
- Functional tests live in `*_framework_test.go` and use [`testing/framework`](../testing/framework) to drive the hook against a fake Kubernetes cluster.
48+
49+
See [`TESTING.md`](../TESTING.md) for the bigger picture.
50+
51+
## Examples
52+
53+
Working examples that consume these common hooks live under [`examples/common-hooks`](../examples/common-hooks).
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# `copy-custom-certificate`
2+
3+
Module hook that copies a user-supplied TLS certificate from a Secret in `d8-system` into the module's internal values, **only** when the module is configured to use the `CustomCertificate` HTTPS mode.
4+
5+
## What it does
6+
7+
1. Watches Secrets in the `d8-system` namespace, ignoring the helm-owned ones (selector `owner notin (helm)`).
8+
2. Filters each matched Secret with the JQ expression `JQFilterCustomCertificate`, which extracts `name`, `key`, `crt`, and `ca` from `.data`.
9+
3. On every run:
10+
- If no certificates are seen, the hook logs and exits.
11+
- If the module's effective HTTPS mode is **not** `CustomCertificate`, it removes the previously-set internal value.
12+
- If the configured `secretName` matches one of the discovered Secrets, the hook writes the cert payload to `<moduleName>.internal.customCertificateData`.
13+
- If the configured `secretName` is set but no Secret with that name exists, the hook returns an error.
14+
15+
## Resulting values
16+
17+
The hook writes the certificate at `<moduleName>.internal.customCertificateData`:
18+
19+
```yaml
20+
<moduleName>:
21+
internal:
22+
customCertificateData:
23+
ca.crt: |
24+
...
25+
tls.crt: |
26+
...
27+
tls.key: |
28+
...
29+
```
30+
31+
## Configuration paths the hook reads
32+
33+
| Path | Meaning |
34+
| --- | --- |
35+
| `<moduleName>.https.customCertificate.secretName` (config) | Module-level override of the secret name. |
36+
| `global.modules.https.customCertificate.secretName` (config) | Cluster-wide fallback. |
37+
| `<moduleName>` HTTPS mode | Computed by `pkg/utils/patchable-values.GetHTTPSMode(input, moduleName)`. |
38+
39+
The hook uses `GetValuesFirstDefined`, so the module-level path wins over the global one.
40+
41+
## Usage
42+
43+
```go
44+
package hooks
45+
46+
import (
47+
cc "github.com/deckhouse/module-sdk/common-hooks/copy-custom-certificate"
48+
)
49+
50+
// Registers a hook that copies CustomCertificate Secrets into
51+
// "myModule.internal.customCertificateData" when HTTPS mode is enabled.
52+
var _ = cc.RegisterHook("myModule")
53+
```
54+
55+
## Hook configuration
56+
57+
The hook registers with:
58+
59+
- **Order:** `OnBeforeHelm.Order = 10`
60+
- **Bindings:** Secrets in `d8-system` whose `owner` label is anything but `helm`.
61+
62+
## JQ filter
63+
64+
```jq
65+
{
66+
"name": .metadata.name,
67+
"key": .data."tls.key",
68+
"crt": .data."tls.crt",
69+
"ca": .data."ca.crt"
70+
}
71+
```
72+
73+
The filter is exported as `JQFilterCustomCertificate` so other hooks (or tests) can reuse it.
74+
75+
## Testing
76+
77+
Tests live in [`hook_test.go`](./hook_test.go) and use the JQ helper from [`testing/helpers`](../../testing/helpers) to validate the filter against a canonical `kubernetes.io/tls` Secret.

0 commit comments

Comments
 (0)