diff --git a/README.md b/README.md index 04723fa4..9a032fcb 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,16 @@ func main() { } ``` -More examples you can find [here](./examples) +More examples you can find [here](./examples). + +### Reusable building blocks + +| Area | What you get | Read more | +| --- | --- | --- | +| Common hooks | Battery-included hooks for TLS, custom certificates, storage-class changes, external auth, CRD installation. | [`common-hooks/`](./common-hooks) | +| Testing — unit tests | `InputBuilder`, `StaticSnapshots`, `RecordingPatchCollector`, `JQRunOn*` and friends. | [`testing/helpers/`](./testing/helpers) | +| Testing — functional tests | Deckhouse-style harness with a fake K8s cluster, snapshot generator, and patch replayer. | [`testing/framework/`](./testing/framework) | +| Testing — strategy | Picking the right test layer, project-wide conventions. | [`TESTING.md`](./TESTING.md) | ## Adding Readiness Probes @@ -261,7 +270,52 @@ Settings validation allows you to validate module configuration values before th ## Testing -If you want to test your JQ filter, you can use JQ helper like in example [here](./pkg/jq/jq_test.go) +The SDK ships with a layered testing toolkit that lets you test hooks at three levels of fidelity: + +- **Unit tests** — quick handler-level tests using [`testing/helpers`](./testing/helpers): `InputBuilder`, real values store, `RecordingPatchCollector`, JQ helpers. +- **Functional tests** — deckhouse-style end-to-end tests using [`testing/framework`](./testing/framework): a fake Kubernetes cluster, real snapshot generation, replayed patches. +- **Mocks** — minimock-generated mocks for every `pkg.*` interface ([`testing/mock`](./testing/mock)) when you need precise control over a single collaborator. + +Quick hook unit test: + +```go +import "github.com/deckhouse/module-sdk/testing/helpers" + +func TestMyHook(t *testing.T) { + in := helpers.NewInputBuilder(t). + WithSnapshot("nodes", helpers.SnapshotJSON(`{"name":"n1"}`)). + WithValuesJSON(`{}`). + Build() + + require.NoError(t, MyHook(context.Background(), in)) + require.Len(t, in.Values.GetPatches(), 1) +} +``` + +Quick hook functional test: + +```go +import "github.com/deckhouse/module-sdk/testing/framework" + +func TestMyHook_Functional(t *testing.T) { + f := framework.HookExecutionConfigInit(t, cfg, MyHook, `{}`, `{}`) + f.KubeStateSet(`apiVersion: v1 +kind: Node +metadata: {name: n1}`) + f.RunHook() + + require.NoError(t, f.HookError()) + require.Len(t, f.Snapshots().Get("nodes"), 1) +} +``` + +Pure JQ filter test: + +```go +helpers.JQRunOnString(ctx, ".metadata.name", `{"metadata":{"name":"x"}}`, &out) +``` + +For the project-wide testing strategy and conventions, see [`TESTING.md`](./TESTING.md). ## For deckhouse developers diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..a372f754 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,188 @@ +# Testing Module SDK hooks + +This document describes how we test hooks built on top of the Module SDK. + +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. + +## TL;DR + +There are three layers, picked by the size of the test you want to write: + +| Layer | Use it for | Speed | Lives at | +| --- | --- | --- | --- | +| **Mocks** | Tests that need precise control over a single dependency | µs | [`testing/mock`](./testing/mock) | +| **Helpers** | Unit tests of a single hook handler | µs | [`testing/helpers`](./testing/helpers) | +| **Framework** | Functional tests driving the whole hook pipeline against a fake K8s cluster | ms | [`testing/framework`](./testing/framework) | + +Pick the smallest layer that lets you write the assertion you actually care about. + +## Test pyramid + +```text + ┌──────────────────────┐ + │ framework (slow) │ end-to-end behaviour + ├──────────────────────┤ + │ helpers (fast) │ handler-level units + ├──────────────────────┤ + │ mock (fastest) │ single-collaborator + └──────────────────────┘ +``` + +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. + +## Layer 1 — `testing/mock` + +`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. + +Use it when you need **precise control** over a single dependency: + +```go +values := mock.NewOutputPatchableValuesCollectorMock(t) +values.GetMock.When("global.discovery.clusterDomain"). + Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) + +input := &pkg.HookInput{ + Values: values, + Logger: log.NewNop(), +} +require.NoError(t, MyHook(ctx, input)) +``` + +This layer is the right call when: + +- you want to assert on the **exact sequence** of calls a hook makes against a collaborator; +- you need to inject an **error from a specific call** (e.g. `ArrayCount → error`); +- the hook is small enough that a real values store is overkill. + +## Layer 2 — `testing/helpers` + +`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/*`: + +- `InputBuilder` — fluent assembly of `*pkg.HookInput`. +- `StaticSnapshots` — in-memory `pkg.Snapshots` backed by JSON / YAML / Go values. +- `RecordingPatchCollector` — a `pkg.PatchCollector` that records every call. +- `NewValuesFromJSON/YAML/Map` — a real `pkg.PatchableValuesCollector` seeded from your input. +- `JQRunOnString/Object` — apply a JQ filter and decode the result in one call. + +Typical test: + +```go +in := helpers.NewInputBuilder(t). + WithSnapshot("nodes", helpers.SnapshotJSON(`{"name":"n1"}`)). + WithValuesJSON(`{"my":{"existing":"value"}}`). + WithRecordingPatchCollector(). + Build() + +require.NoError(t, MyHook(context.Background(), in)) + +// Real values store: actual patches were recorded. +require.Len(t, in.Values.GetPatches(), 1) +``` + +Use this layer when: + +- you know exactly what the hook should see and do; +- you want to test the **happy path and a few error paths** of a single handler; +- you're writing **a JQ filter test** with `helpers.JQRunOn*`. + +A complete reference is at [`testing/helpers/README.md`](./testing/helpers/README.md). + +## Layer 3 — `testing/framework` + +`testing/framework` is the **functional-test** layer, mirroring [`deckhouse/testing/hooks`](https://github.com/deckhouse/deckhouse/tree/main/testing/hooks). The framework: + +1. owns a **fake dynamic Kubernetes client** seeded from YAML; +2. **generates snapshots** from the hook's `KubernetesConfig` bindings (selectors + JQ); +3. runs the handler with a real `*pkg.HookInput`; +4. **applies values patches** back to the values store; +5. **replays cluster patches** (`Create` / `Delete` / `Patch`) against the fake cluster. + +After `RunHook`, you assert on: +- snapshots passed in, +- final values & config values, +- recorded patch operations, +- post-hook cluster state via `KubernetesResource(...)`, +- collected metrics, +- captured logs. + +Typical test: + +```go +f := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`) +f.KubeStateSet(` +--- +apiVersion: v1 +kind: Pod +metadata: {name: app, namespace: default} +status: {phase: Running} +`) +f.RunHook() + +require.NoError(t, f.HookError()) +require.NotNil(t, f.KubernetesResource("ConfigMap", "default", "app-status")) +``` + +Use this layer when: + +- the hook reads multiple bindings or relies on label / namespace selectors; +- the hook ends up issuing several patch operations and you want to verify the **resulting cluster state**; +- the hook talks to the API server through `input.DC.GetK8sClient()`; +- you want one test to walk through several state transitions. + +A complete reference is at [`testing/framework/README.md`](./testing/framework/README.md). + +## Picking a layer in practice + +A small flowchart: + +```text +Are you only testing a JQ filter? + └─► helpers.JQRunOn{String,Object} + +Are you testing a single handler in isolation, +with snapshots and values you can describe inline? + └─► helpers.NewInputBuilder + RecordingPatchCollector + +Do you need to assert on the resulting Kubernetes objects +(Create + Delete chains, Patch results, JQ mutations)? + └─► testing/framework + +Do you need to inject a very specific failure from one +collaborator (e.g. ArrayCount returning an error)? + └─► testing/mock + a hand-built *pkg.HookInput +``` + +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: + +- `*_test.go` files use `helpers.NewInputBuilder` for the bulk of unit tests; +- `*_framework_test.go` files use `testing/framework` for end-to-end coverage; +- a handful of error-path tests fall back to `testing/mock` for a specific failure. + +## Project-wide testing conventions + +- **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. +- **Ginkgo is being phased out.** New tests should use plain `*testing.T` + `testify`. Existing Ginkgo suites are migrated when their package is touched. +- **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?". +- **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. +- **Functional tests are sparse but high-value.** Aim for a handful of `framework` tests per hook, not one per code path. +- **Lint and vet are required.** `go vet ./...` and `golangci-lint run ./...` must stay green; this is enforced by `make test` and `make lint`. + +## Running the tests + +```sh +# Module SDK and common-hooks +make test +make lint + +# Each example module is a standalone Go module under examples/. +make examples +``` + +Each example module has its own `go.mod` and its own test suite — they are deliberately self-contained so they double as documentation. + +## See also + +- [`testing/README.md`](./testing/README.md) — overview of the testing tree. +- [`testing/framework/README.md`](./testing/framework/README.md) — functional-test harness reference. +- [`testing/helpers/README.md`](./testing/helpers/README.md) — unit-test helper reference. +- [`pkg/jq`](./pkg/jq) — JQ engine, useful when you want to debug a snapshot filter expression. diff --git a/common-hooks/README.md b/common-hooks/README.md new file mode 100644 index 00000000..60c7bbfe --- /dev/null +++ b/common-hooks/README.md @@ -0,0 +1,53 @@ +# `common-hooks/` — reusable Module SDK hooks + +`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. + +## Available hooks + +| Package | What it does | +| --- | --- | +| [`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. | +| [`ensure_crds`](./ensure_crds) | Installs (or updates) all CRD YAMLs matched by a glob, on module startup. | +| [`external_auth`](./external_auth) | Wires up Dex-based or user-provided external authentication settings into the module values. | +| [`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. | +| [`tls-certificate`](./tls-certificate) | Generates or refreshes self-signed TLS certificates and orders Kubernetes-signed certificates via the `certificates.k8s.io` API. | + +## Usage shape + +Most common hooks follow the same shape: + +```go +package hooks + +import ( + sccc "github.com/deckhouse/module-sdk/common-hooks/storage-class-change" +) + +var _ = sccc.RegisterHook(sccc.Args{ + ModuleName: "myModule", + Namespace: "d8-my-module", + LabelSelectorKey: "app", + LabelSelectorValue: "data", + ObjectKind: "StatefulSet", + ObjectName: "data-set", +}) +``` + +A few notes: + +- The `_ = ...RegisterHook(...)` idiom registers the hook at package init via the SDK registry. Importing the package is enough to enable the hook. +- Each hook embeds its own `pkg.HookConfig` (binding contexts, schedules, JQ filters, …); you only supply module-level parameters. +- For application hooks use the `…Application` variants where they exist; module hooks use the `pkg.HookInput` shape. + +## Testing + +Each common hook ships with both **unit** and (where applicable) **functional** tests: + +- Unit tests live next to the hook (`*_test.go`) and rely on [`testing/helpers`](../testing/helpers). +- Functional tests live in `*_framework_test.go` and use [`testing/framework`](../testing/framework) to drive the hook against a fake Kubernetes cluster. + +See [`TESTING.md`](../TESTING.md) for the bigger picture. + +## Examples + +Working examples that consume these common hooks live under [`examples/common-hooks`](../examples/common-hooks). diff --git a/common-hooks/copy-custom-certificate/README.md b/common-hooks/copy-custom-certificate/README.md new file mode 100644 index 00000000..a4bedade --- /dev/null +++ b/common-hooks/copy-custom-certificate/README.md @@ -0,0 +1,77 @@ +# `copy-custom-certificate` + +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. + +## What it does + +1. Watches Secrets in the `d8-system` namespace, ignoring the helm-owned ones (selector `owner notin (helm)`). +2. Filters each matched Secret with the JQ expression `JQFilterCustomCertificate`, which extracts `name`, `key`, `crt`, and `ca` from `.data`. +3. On every run: + - If no certificates are seen, the hook logs and exits. + - If the module's effective HTTPS mode is **not** `CustomCertificate`, it removes the previously-set internal value. + - If the configured `secretName` matches one of the discovered Secrets, the hook writes the cert payload to `.internal.customCertificateData`. + - If the configured `secretName` is set but no Secret with that name exists, the hook returns an error. + +## Resulting values + +The hook writes the certificate at `.internal.customCertificateData`: + +```yaml +: + internal: + customCertificateData: + ca.crt: | + ... + tls.crt: | + ... + tls.key: | + ... +``` + +## Configuration paths the hook reads + +| Path | Meaning | +| --- | --- | +| `.https.customCertificate.secretName` (config) | Module-level override of the secret name. | +| `global.modules.https.customCertificate.secretName` (config) | Cluster-wide fallback. | +| `` HTTPS mode | Computed by `pkg/utils/patchable-values.GetHTTPSMode(input, moduleName)`. | + +The hook uses `GetValuesFirstDefined`, so the module-level path wins over the global one. + +## Usage + +```go +package hooks + +import ( + cc "github.com/deckhouse/module-sdk/common-hooks/copy-custom-certificate" +) + +// Registers a hook that copies CustomCertificate Secrets into +// "myModule.internal.customCertificateData" when HTTPS mode is enabled. +var _ = cc.RegisterHook("myModule") +``` + +## Hook configuration + +The hook registers with: + +- **Order:** `OnBeforeHelm.Order = 10` +- **Bindings:** Secrets in `d8-system` whose `owner` label is anything but `helm`. + +## JQ filter + +```jq +{ + "name": .metadata.name, + "key": .data."tls.key", + "crt": .data."tls.crt", + "ca": .data."ca.crt" +} +``` + +The filter is exported as `JQFilterCustomCertificate` so other hooks (or tests) can reuse it. + +## Testing + +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. diff --git a/common-hooks/copy-custom-certificate/hook_test.go b/common-hooks/copy-custom-certificate/hook_test.go index 53a3e9e3..105b2fa6 100644 --- a/common-hooks/copy-custom-certificate/hook_test.go +++ b/common-hooks/copy-custom-certificate/hook_test.go @@ -17,82 +17,78 @@ limitations under the License. package copycustomcertificate_test import ( - "bytes" "context" - "encoding/json" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" copycustomcertificate "github.com/deckhouse/module-sdk/common-hooks/copy-custom-certificate" tlscertificate "github.com/deckhouse/module-sdk/common-hooks/tls-certificate" "github.com/deckhouse/module-sdk/pkg/certificate" - "github.com/deckhouse/module-sdk/pkg/jq" + "github.com/deckhouse/module-sdk/testing/helpers" ) -func Test_JQFilterApplyCertificateSecret(t *testing.T) { - t.Run("apply tls", func(t *testing.T) { - const rawSecret = ` - { - "apiVersion": "v1", - "data": { - "ca.crt": "c29tZS1jYQ==", - "tls.crt": "c29tZS1jcnQ=", - "tls.key": "c29tZS1rZXk=" - }, - "kind": "Secret", - "metadata": { - "name": "some-cert", - "namespace": "some-ns" - }, - "type": "kubernetes.io/tls" - }` - - q, err := jq.NewQuery(copycustomcertificate.JQFilterCustomCertificate) - assert.NoError(t, err) - - res, err := q.FilterStringObject(context.Background(), rawSecret) - assert.NoError(t, err) - - cert := new(certificate.Certificate) - err = json.NewDecoder(bytes.NewBufferString(res.String())).Decode(cert) - assert.NoError(t, err) - - assert.Equal(t, "some-key", string(cert.Key)) - assert.Equal(t, "some-crt", string(cert.Cert)) - assert.Equal(t, "some-ca", string(cert.CA)) - assert.Equal(t, "some-cert", cert.Name) - }) - - t.Run("apply tls from client", func(t *testing.T) { - const rawSecret = ` - { - "apiVersion": "v1", - "data": { - "ca.crt": "c29tZS1jYQ==", - "client.crt": "c29tZS1jcnQ=", - "client.key": "c29tZS1rZXk=" - }, - "kind": "Secret", - "metadata": { - "name": "some-cert", - "namespace": "some-ns" - }, - "type": "kubernetes.io/tls" - }` - - q, err := jq.NewQuery(tlscertificate.JQFilterApplyCertificateSecret) - assert.NoError(t, err) +// tlsSecret is the canonical TLS-typed kubernetes Secret payload used by +// the JQ filters. The values are base64-encoded so the filter can decode +// them back into "some-key", "some-crt", etc. +const tlsSecret = `{ + "apiVersion": "v1", + "data": { + "ca.crt": "c29tZS1jYQ==", + "tls.crt": "c29tZS1jcnQ=", + "tls.key": "c29tZS1rZXk=" + }, + "kind": "Secret", + "metadata": { + "name": "some-cert", + "namespace": "some-ns" + }, + "type": "kubernetes.io/tls" +}` + +const clientSecret = `{ + "apiVersion": "v1", + "data": { + "ca.crt": "c29tZS1jYQ==", + "client.crt": "c29tZS1jcnQ=", + "client.key": "c29tZS1rZXk=" + }, + "kind": "Secret", + "metadata": { + "name": "some-cert", + "namespace": "some-ns" + }, + "type": "kubernetes.io/tls" +}` + +func TestJQFilterCustomCertificate_ParsesTLSSecret(t *testing.T) { + cert := new(certificate.Certificate) + + require.NoError(t, helpers.JQRunOnString( + context.Background(), + copycustomcertificate.JQFilterCustomCertificate, + tlsSecret, + cert, + )) + + assert.Equal(t, "some-cert", cert.Name) + assert.Equal(t, "some-key", string(cert.Key)) + assert.Equal(t, "some-crt", string(cert.Cert)) + assert.Equal(t, "some-ca", string(cert.CA)) +} - res, err := q.FilterStringObject(context.Background(), rawSecret) - assert.NoError(t, err) +func TestJQFilterApplyCertificateSecret_ParsesClientCertificate(t *testing.T) { + auth := new(certificate.Certificate) - auth := new(certificate.Certificate) - err = json.NewDecoder(bytes.NewBufferString(res.String())).Decode(auth) - assert.NoError(t, err) + require.NoError(t, helpers.JQRunOnString( + context.Background(), + tlscertificate.JQFilterApplyCertificateSecret, + clientSecret, + auth, + )) - assert.Equal(t, "some-key", string(auth.Key)) - assert.Equal(t, "some-crt", string(auth.Cert)) - assert.Equal(t, "some-cert", auth.Name) - }) + assert.Equal(t, "some-cert", auth.Name) + assert.Equal(t, "some-key", string(auth.Key)) + assert.Equal(t, "some-crt", string(auth.Cert)) } diff --git a/common-hooks/ensure_crds/README.md b/common-hooks/ensure_crds/README.md new file mode 100644 index 00000000..a4c06034 --- /dev/null +++ b/common-hooks/ensure_crds/README.md @@ -0,0 +1,63 @@ +# `ensure_crds` + +Module hook that installs (or updates) the CustomResourceDefinitions shipped with your module on **every module startup**. + +It is a thin wrapper around [`pkg/crd-installer`](../../pkg/crd-installer): the hook gets a Kubernetes client from the SDK's `DependencyContainer`, expands a glob to a list of CRD YAML files, and lets the installer apply them. + +## What it does + +On `OnStartup` (order `5`) the hook: + +1. Calls `input.DC.GetK8sClient()` to obtain a real Kubernetes client. +2. Expands the configured CRD glob (e.g. `/deckhouse/modules/002-deckhouse/crds/*.yaml`) into a list of files. +3. Skips files whose basename starts with `doc-` (these are documentation-only manifests). +4. Applies all remaining CRDs via the installer, labelling them `heritage=deckhouse`. + +If anything fails, the error is logged and bubbles up so addon-operator can surface it. + +## Usage + +```go +package hooks + +import ( + ensure_crds "github.com/deckhouse/module-sdk/common-hooks/ensure_crds" +) + +// Match all CRDs shipped with the module, except doc-*.yaml. +var _ = ensure_crds.RegisterEnsureCRDsHookEM( + "/deckhouse/modules/002-mymodule/crds/*.yaml", +) +``` + +The exported public surface is small: + +| Function | Purpose | +| --- | --- | +| `RegisterEnsureCRDsHookEM(crdsGlob string) bool` | Registers the hook in the SDK's global registry. **Recommended for external modules.** | +| `EnsureCRDsHandler(crdsGlob string) func(ctx, *pkg.HookInput) error` | Returns the bare handler without registering anything; useful for composing custom hooks. | +| `EnsureCRDs(ctx, input, crdsGlob)` | Lower-level entry point that expects a ready-built `*pkg.HookInput`. | + +## File filter + +The default filter excludes manifests whose basename starts with `doc-`: + +```go +crdinstaller.WithFileFilter(func(crdFilePath string) bool { + return !strings.HasPrefix(filepath.Base(crdFilePath), "doc-") +}) +``` + +If you ship CRD YAMLs with extra non-installable files (release notes, examples, …), prefix them with `doc-`. + +## Hook configuration + +- **Trigger:** `OnStartup` with `Order: 5`. +- **No Kubernetes bindings** — the hook talks to the API server directly via `input.DC.GetK8sClient()`. +- **No values output** — the side effect is purely on the cluster. + +## Testing + +The CRD-installer logic itself is exercised by [`pkg/crd-installer/installer_test.go`](../../pkg/crd-installer/installer_test.go). Module-level tests should focus on supplying the right glob and rely on those installer tests for the rest. + +For functional coverage, the hook can be driven through [`testing/framework`](../../testing/framework) — register CRDs from a temp directory and assert that the framework's fake cluster contains the resulting `CustomResourceDefinition` objects after `RunHook()`. diff --git a/common-hooks/external_auth/README.md b/common-hooks/external_auth/README.md new file mode 100644 index 00000000..324a1f29 --- /dev/null +++ b/common-hooks/external_auth/README.md @@ -0,0 +1,83 @@ +# `external_auth` + +Module hook that wires external authentication settings into the module's values, taking into account whether the cluster has the `user-authn` module enabled and whether the user supplied an explicit configuration. + +## Decision matrix + +The hook runs `OnBeforeHelm` (order `9`) and computes the final value at the configured `Settings.ExternalAuthPath`: + +| `user-authn` enabled? | User provided `ExternalAuthPath` in config? | Resulting values | +| --- | --- | --- | +| no | no | `ExternalAuthPath` removed from values; `DexAuthenticatorEnabledPath` removed. | +| no | yes | `ExternalAuthPath` set to user-provided value; `DexAuthenticatorEnabledPath` removed. | +| yes | no | `ExternalAuthPath` set to the Dex template (`Settings.DexExternalAuth`, with `%CLUSTER_DOMAIN%` substituted); `DexAuthenticatorEnabledPath` set to `true`. | +| yes | yes | `ExternalAuthPath` set to user-provided value; `DexAuthenticatorEnabledPath` removed (the user opted out of the in-module authenticator). | + +In other words: **the user always wins**, and the in-module Dex authenticator is only enabled when (a) `user-authn` is enabled and (b) the user did not supply their own auth. + +## Settings + +```go +type Settings struct { + // Where in values the resulting auth block lives. + ExternalAuthPath string + + // Where to set the boolean flag that gates the in-module DexAuthenticator. + DexAuthenticatorEnabledPath string + + // What to write to ExternalAuthPath when Dex is the source of truth. + DexExternalAuth ExternalAuth +} + +type ExternalAuth struct { + AuthURL string // may contain "%CLUSTER_DOMAIN%" + AuthSignInURL string + UseBearerTokens *bool +} +``` + +`ExternalAuth.AuthURLWithClusterDomain(input)` substitutes `%CLUSTER_DOMAIN%` with `global.discovery.clusterDomain`. + +## Usage + +```go +package hooks + +import ( + extauth "github.com/deckhouse/module-sdk/common-hooks/external_auth" +) + +var useBearer = true + +var _ = extauth.RegisterHook(extauth.Settings{ + ExternalAuthPath: "myModule.auth.externalAuthentication", + DexAuthenticatorEnabledPath: "myModule.internal.dexAuthenticatorEnabled", + DexExternalAuth: extauth.ExternalAuth{ + AuthURL: "https://dex.%CLUSTER_DOMAIN%/auth", + AuthSignInURL: "https://signin.%CLUSTER_DOMAIN%/", + UseBearerTokens: &useBearer, + }, +}) +``` + +## Hook configuration + +- **Trigger:** `OnBeforeHelm` with `Order: 9`. +- **No Kubernetes bindings:** the hook only inspects values. +- **Reads:** + - `global.enabledModules` (uses `pkg/utils/set` to check for `user-authn`), + - `global.discovery.clusterDomain` (for `%CLUSTER_DOMAIN%` expansion), + - `` from `ConfigValues` (the user override, if any). + +## Testing + +The hook is a pure values-mutating function, which makes it ideal for unit tests with [`testing/helpers`](../../testing/helpers): + +```go +in := helpers.NewInputBuilder(t). + WithValuesJSON(`{"global":{"enabledModules":["user-authn"]}}`). + WithConfigValuesJSON(`{}`). + Build() +``` + +Then call the hook (export the handler from your test code or use `RegisterHook` with a custom registry) and assert on `in.Values.GetPatches()`. diff --git a/common-hooks/storage-class-change/README.md b/common-hooks/storage-class-change/README.md new file mode 100644 index 00000000..cfd27f06 --- /dev/null +++ b/common-hooks/storage-class-change/README.md @@ -0,0 +1,89 @@ +# `storage-class-change` + +Module hook that watches PVCs, Pods, and StorageClasses for a single workload and: + +1. Computes the **effective** storage class for the workload, layering cluster default → global config → module config → in-cluster PVC. +2. Stores the result at a well-known internal values path so Helm templates can branch on it. +3. **Evicts** pods whose PVCs have been deleted out of band. +4. **Deletes** the workload's PVCs and the workload itself (StatefulSet / Deployment / Prometheus) when the storage class actually changed, so the controller recreates everything with the new class. +5. Exports a `d8_emptydir_usage` Prometheus metric that is `1` when the module falls back to `emptyDir` (no storage class). + +## What it watches + +| Snapshot | Kind | Filter | +| --- | --- | --- | +| `pvcs` | `PersistentVolumeClaim` in `args.Namespace` | label `args.LabelSelectorKey=args.LabelSelectorValue` | +| `pods` | `Pod` in `args.Namespace` | same label selector | +| `storageClasses` | `StorageClass` (cluster-scoped) | none | + +Each snapshot is JQ-filtered into a small struct with just the fields the hook needs (`name`, `namespace`, `storageClassName`, `isDeleted`, …). + +## Effective storage class lookup + +For a hook initialised with `args`, the effective storage class is computed in this order (later wins): + +1. The cluster's **default** StorageClass (annotation `storageclass.kubernetes.io/is-default-class=true` or its beta variant). +2. `global.modules.storageClass` from **config values**, if set. +3. The class **currently bound to the workload's PVCs** (if any). +4. `.` from **config values**, if set. + +The result is written to `.internal..effectiveStorageClass`. If the effective class is empty or the literal string `"false"`, the hook stores the boolean `false` instead and bumps the `d8_emptydir_usage` metric. + +## Side effects + +When the effective class differs from the currently-bound one: + +- For each existing PVC the hook **deletes** the PVC (so the controller recreates it). +- The hook **deletes** the workload itself, dispatched on `args.ObjectKind`: + +| `args.ObjectKind` | Effect | +| --- | --- | +| `StatefulSet` | `appsv1.StatefulSet` deleted via the K8s client. | +| `Deployment` | `appsv1.Deployment` deleted via the K8s client. | +| `Prometheus` | `monitoring.coreos.com/v1/prometheuses` deleted via the dynamic client. | +| anything else | The hook returns `unknown object kind `. | + +When a PVC has a `deletionTimestamp` but a Pod still references it, the hook issues a `policy/v1 Eviction` against the Pod. + +## Usage + +```go +package hooks + +import ( + sccc "github.com/deckhouse/module-sdk/common-hooks/storage-class-change" +) + +var _ = sccc.RegisterHook(sccc.Args{ + ModuleName: "myModule", + Namespace: "d8-my-module", + LabelSelectorKey: "app", + LabelSelectorValue: "data", + ObjectKind: "StatefulSet", + ObjectName: "data-set", + + // Optional knobs + InternalValuesSubPath: "data", // → myModule.internal.data.effectiveStorageClass + D8ConfigStorageClassParamName: "dataStorageClass", // → myModule.dataStorageClass instead of .storageClass + BeforeHookCheck: func(input *pkg.HookInput) bool { + // Skip the hook entirely when, e.g., the module is disabled. + return input.Values.Get("myModule.enabled").Bool() + }, +}) +``` + +## Hook configuration + +- **Trigger:** `OnBeforeHelm` with `Order: 1`. +- **Snapshots:** `pvcs`, `pods`, `storageClasses` as described above. + +## Testing + +This hook has both unit and functional coverage: + +- [`hook_test.go`](./hook_test.go) — table-driven JQ-filter tests using [`testing/helpers`](../../testing/helpers). +- [`hook_framework_test.go`](./hook_framework_test.go) — end-to-end scenarios driven through [`testing/framework`](../../testing/framework): + - default StorageClass writes the right effective value; + - explicit `global.modules.storageClass` overrides the default; + - label selector scopes which PVCs participate; + - `BeforeHookCheck` short-circuits the hook. diff --git a/common-hooks/storage-class-change/hook_framework_test.go b/common-hooks/storage-class-change/hook_framework_test.go new file mode 100644 index 00000000..ee87a087 --- /dev/null +++ b/common-hooks/storage-class-change/hook_framework_test.go @@ -0,0 +1,212 @@ +/* +Copyright 2025 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package storageclasschange + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/framework" +) + +// hookConfigFor mirrors what RegisterHook builds, but without registering +// anything in the global registry. Functional tests use this to feed the +// framework a complete *pkg.HookConfig. +func hookConfigFor(args Args) *pkg.HookConfig { + return &pkg.HookConfig{ + OnBeforeHelm: &pkg.OrderedConfig{Order: 1}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: "pvcs", + APIVersion: "v1", + Kind: "PersistentVolumeClaim", + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{MatchNames: []string{args.Namespace}}, + }, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{args.LabelSelectorKey: args.LabelSelectorValue}, + }, + JqFilter: pvcFilter, + }, + { + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{MatchNames: []string{args.Namespace}}, + }, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{args.LabelSelectorKey: args.LabelSelectorValue}, + }, + JqFilter: podFilter, + }, + { + Name: "storageClasses", + APIVersion: "storage.k8s.io/v1", + Kind: "StorageClass", + JqFilter: storageClassFilter, + }, + }, + } +} + +func newArgs() Args { + return Args{ + ModuleName: "myModule", + Namespace: "test-ns", + LabelSelectorKey: "app", + LabelSelectorValue: "data", + ObjectKind: "StatefulSet", + ObjectName: "data-set", + } +} + +// TestStorageClassChange_DefaultStorageClassWritesEffectiveValue exercises +// the snapshot pipeline end-to-end: storage classes from the cluster, +// PVCs filtered by the label selector, and the resulting internal value. +func TestStorageClassChange_DefaultStorageClassWritesEffectiveValue(t *testing.T) { + args := newArgs() + + const state = ` +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: standard + annotations: + storageclass.kubernetes.io/is-default-class: "true" +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: fast +` + + f := framework.HookExecutionConfigInit(t, hookConfigFor(args), func(ctx context.Context, in *pkg.HookInput) error { + return storageClassChange(ctx, in, args) + }, `{}`, `{}`) + f.KubeStateSet(state) + f.RunHook() + + require.NoError(t, f.HookError()) + + // The hook discovers the default SC and writes it to the internal path. + val := f.ValuesGet("myModule.internal.effectiveStorageClass").String() + assert.Equal(t, "standard", val) +} + +// TestStorageClassChange_ConfigOverridesDefault asserts that an explicit +// global.modules.storageClass override beats the cluster default. +func TestStorageClassChange_ConfigOverridesDefault(t *testing.T) { + args := newArgs() + + const state = ` +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: standard + annotations: + storageclass.kubernetes.io/is-default-class: "true" +` + + const config = `{"global":{"modules":{"storageClass":"premium"}}}` + + f := framework.HookExecutionConfigInit(t, hookConfigFor(args), func(ctx context.Context, in *pkg.HookInput) error { + return storageClassChange(ctx, in, args) + }, `{}`, config) + f.KubeStateSet(state) + f.RunHook() + + require.NoError(t, f.HookError()) + assert.Equal(t, "premium", f.ValuesGet("myModule.internal.effectiveStorageClass").String()) +} + +// TestStorageClassChange_LabelSelectorScopesPVCs ensures the hook only +// considers PVCs whose labels match the configured selector. +func TestStorageClassChange_LabelSelectorScopesPVCs(t *testing.T) { + args := newArgs() + + const state = ` +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: standard + annotations: + storageclass.kubernetes.io/is-default-class: "true" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: matching + namespace: test-ns + labels: + app: data +spec: + storageClassName: legacy +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: irrelevant + namespace: test-ns + labels: + app: other +spec: + storageClassName: ignored +` + + f := framework.HookExecutionConfigInit(t, hookConfigFor(args), func(ctx context.Context, in *pkg.HookInput) error { + return storageClassChange(ctx, in, args) + }, `{}`, `{}`) + f.KubeStateSet(state) + f.RunHook() + + require.NoError(t, f.HookError()) + // The current PVC's storageClassName ("legacy") wins over the default. + assert.Equal(t, "legacy", f.ValuesGet("myModule.internal.effectiveStorageClass").String()) +} + +// TestStorageClassChange_BeforeHookCheckGate validates that returning false +// from BeforeHookCheck short-circuits the hook (no values, no errors). +func TestStorageClassChange_BeforeHookCheckGate(t *testing.T) { + args := newArgs() + args.BeforeHookCheck = func(_ *pkg.HookInput) bool { return false } + + f := framework.HookExecutionConfigInit(t, hookConfigFor(args), func(ctx context.Context, in *pkg.HookInput) error { + return storageClassChange(ctx, in, args) + }, `{}`, `{}`) + f.KubeStateSet(` +--- +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: standard + annotations: + storageclass.kubernetes.io/is-default-class: "true" +`) + f.RunHook() + + require.NoError(t, f.HookError()) + assert.False(t, f.ValuesGet("myModule.internal.effectiveStorageClass").Exists()) +} diff --git a/common-hooks/storage-class-change/hook_test.go b/common-hooks/storage-class-change/hook_test.go index b72abf4c..bc0d4b26 100644 --- a/common-hooks/storage-class-change/hook_test.go +++ b/common-hooks/storage-class-change/hook_test.go @@ -17,131 +17,118 @@ limitations under the License. package storageclasschange import ( - "bytes" "context" - "encoding/json" - "reflect" "testing" "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" storagev1 "k8s.io/api/storage/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/deckhouse/module-sdk/pkg/jq" "github.com/deckhouse/module-sdk/pkg/utils/ptr" + "github.com/deckhouse/module-sdk/testing/helpers" ) -type testCase struct { - name string - filter string - object any - expected any +// runFilter applies one of the package-private JQ filters and decodes the +// result into the provided destination. Keeping this as a single helper +// makes the tests below trivial table-driven cases. +func runFilter[T any](t *testing.T, filter string, input any) T { + t.Helper() + var got T + require.NoError(t, helpers.JQRunOnObject(context.Background(), filter, input, &got)) + return got } -func TestStorageClassChangeFilter(t *testing.T) { - cases := []testCase{ - { - name: "filter pvc", - filter: pvcFilter, - object: corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "test1", - DeletionTimestamp: ptr.To(metav1.NewTime(time.Now())), - }, - Spec: corev1.PersistentVolumeClaimSpec{ - StorageClassName: ptr.To("class"), - }, - }, - expected: filteredPvc{ - Name: "test", - Namespace: "test1", - StorageClassName: "class", - IsDeleted: true, - }, +func TestPVCFilter_DeletedPVCMarksAsDeleted(t *testing.T) { + now := metav1.NewTime(time.Now()) + pvc := corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pvc-1", + Namespace: "ns-1", + DeletionTimestamp: ptr.To(now), }, - { - name: "filter pod", - filter: podFilter, - object: corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "test1", - DeletionTimestamp: ptr.To(metav1.NewTime(time.Now())), - }, - Spec: corev1.PodSpec{ - Volumes: []corev1.Volume{ - { - Name: "test", - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: "testpvc", - }, - }, - }, + Spec: corev1.PersistentVolumeClaimSpec{ + StorageClassName: ptr.To("class"), + }, + } + + got := runFilter[filteredPvc](t, pvcFilter, pvc) + + assert.Equal(t, filteredPvc{ + Name: "pvc-1", + Namespace: "ns-1", + StorageClassName: "class", + IsDeleted: true, + }, got) +} + +func TestPodFilter_ExtractsPVCAndPhase(t *testing.T) { + pod := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod-1", + Namespace: "ns-1", + }, + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{{ + Name: "data", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: "pvc-1", }, }, - Status: corev1.PodStatus{ - Phase: corev1.PodRunning, - }, - }, - expected: filteredPod{ - Name: "test", - Namespace: "test1", - Pvc: "testpvc", - Phase: corev1.PodRunning, - }, + }}, }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + + got := runFilter[filteredPod](t, podFilter, pod) + + assert.Equal(t, filteredPod{ + Name: "pod-1", + Namespace: "ns-1", + Pvc: "pvc-1", + Phase: corev1.PodRunning, + }, got) +} + +func TestStorageClassFilter_DetectsDefaultByAnnotation(t *testing.T) { + cases := []struct { + name string + annotation map[string]string + want bool + }{ { - name: "filter storage class", - filter: storageClassFilter, - object: storagev1.StorageClass{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Annotations: map[string]string{ - "storageclass.kubernetes.io/is-default-class": "true", - }, - }, - }, - expected: filteredStorageClass{ - Name: "test", - IsDefault: true, - }, + name: "default class", + annotation: map[string]string{"storageclass.kubernetes.io/is-default-class": "true"}, + want: true, + }, + { + name: "default class beta", + annotation: map[string]string{"storageclass.beta.kubernetes.io/is-default-class": "true"}, + want: true, + }, + { + name: "non-default", + annotation: map[string]string{"foo": "bar"}, + want: false, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - q, err := jq.NewQuery(tc.filter) - assert.NoError(t, err) - - res, err := q.FilterObject(context.Background(), tc.object) - assert.NoError(t, err) - - switch reflect.TypeOf(tc.expected) { - case reflect.TypeOf(filteredPvc{}): - obj := new(filteredPvc) - err = json.NewDecoder(bytes.NewBufferString(res.String())).Decode(obj) - assert.NoError(t, err) - - assert.Equal(t, *obj, tc.expected) - case reflect.TypeOf(filteredPod{}): - obj := new(filteredPod) - err = json.NewDecoder(bytes.NewBufferString(res.String())).Decode(obj) - assert.NoError(t, err) - - assert.Equal(t, *obj, tc.expected) - case reflect.TypeOf(filteredStorageClass{}): - obj := new(filteredStorageClass) - err = json.NewDecoder(bytes.NewBufferString(res.String())).Decode(obj) - assert.NoError(t, err) - - assert.Equal(t, *obj, tc.expected) - default: - assert.Fail(t, "unhandled jq query") + sc := storagev1.StorageClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sc-test", + Annotations: tc.annotation, + }, } + + got := runFilter[filteredStorageClass](t, storageClassFilter, sc) + + assert.Equal(t, "sc-test", got.Name) + assert.Equal(t, tc.want, got.IsDefault) }) } } diff --git a/common-hooks/tls-certificate/README.md b/common-hooks/tls-certificate/README.md new file mode 100644 index 00000000..b9e808a1 --- /dev/null +++ b/common-hooks/tls-certificate/README.md @@ -0,0 +1,160 @@ +# `tls-certificate` + +Two complementary hooks for managing TLS material inside a Deckhouse module: + +| Hook | Use it when… | +| --- | --- | +| **Self-signed (`internal_tls.go`)** | The module needs an in-cluster TLS pair for its own services (typically a webhook). The module signs the cert itself. | +| **Order from cluster CA (`order_certificate.go`)** | The module needs a certificate signed by the Kubernetes cluster CA via the `certificates.k8s.io` API. | + +Both hooks store the resulting `ca.crt` / `tls.crt` / `tls.key` in module values, so Helm templates can render them into Secrets directly. + +--- + +## Self-signed TLS — `RegisterInternalTLSHookEM` + +Run on `OnBeforeHelm` (order `5`) and on a daily schedule (`42 4 * * *`). + +The hook reads a TLS-typed Secret from the cluster (named `TLSSecretName` in `Namespace`) and: + +- if **the Secret is missing**, generates a fresh CA + cert and writes them to values; +- if **the Secret is present and still valid**, copies it into values verbatim; +- if **the certificate or its CA is outdated** (within `CAOutdatedDuration` / `CertOutdatedDuration` of expiry), regenerates the matching half (CA only, cert only, or both); +- if **the certificate's CA expiry no longer matches the configured `CAExpiryDuration`**, regenerates everything; +- supports a **shared CA** via `CommonCAValuesPath` so multiple webhooks can sign with the same CA (with custom field names via `CommonCACertField` / `CommonCAKeyField`). + +### Quick start + +```go +package hooks + +import ( + tlscert "github.com/deckhouse/module-sdk/common-hooks/tls-certificate" +) + +var _ = tlscert.RegisterInternalTLSHookEM(tlscert.GenSelfSignedTLSHookConf{ + CN: "example-webhook", + TLSSecretName: "secret-webhook-cert", + Namespace: "d8-example-module", + + SANs: tlscert.DefaultSANs([]string{ + "example-webhook", + "example-webhook.d8-example-module", + "example-webhook.d8-example-module.svc", + "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", + "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", + }), + + FullValuesPathPrefix: "exampleModule.internal.webhookCert", +}) +``` + +### Resulting values + +The hook writes to `FullValuesPathPrefix`: + +```yaml +exampleModule: + internal: + webhookCert: + ca: ...PEM... + crt: ...PEM... + key: ...PEM... +``` + +In Helm templates, run these through `b64enc` before putting them into a Secret. + +### `GenSelfSignedTLSHookConf` — the most useful fields + +| Field | Purpose | +| --- | --- | +| `CN` | Common Name of the certificate; usually the module name. | +| `Namespace` / `TLSSecretName` | Where the persisted Secret lives. | +| `SANs` | Function returning the SAN list. Use `DefaultSANs([]string{...})` for `%CLUSTER_DOMAIN%` / `%PUBLIC_DOMAIN%` substitution. | +| `KeyAlgorithm` / `KeySize` | Defaults to `ecdsa` / `256`. | +| `Usages` | Defaults to `signing`, `key encipherment`, `requestheader-client`. | +| `FullValuesPathPrefix` | Where to store CA / Crt / Key in values. | +| `CommonCAValuesPath` | If set, share an existing CA stored at this path. | +| `CommonCACertField` / `CommonCAKeyField` | Field names within the shared-CA object (default `crt` / `key`). | +| `CommonCACanonicalName` | CN used when generating the shared CA, falls back to `CN`. | +| `CAExpiryDuration` / `CAOutdatedDuration` | Default 10y / 6mo. | +| `CertExpiryDuration` / `CertOutdatedDuration` | Default 10y / 6mo. | +| `BeforeHookCheck` | Optional gate; the hook is a no-op when this returns `false`. | + +--- + +## Order a certificate from the cluster CA — `RegisterOrderCertificateHookEM` + +Hooks for modules whose components must present certificates **signed by the cluster CA**, going through the `certificates.k8s.io/v1` `CertificateSigningRequest` (CSR) API. + +The hook is registered with one or more `OrderCertificateRequest` entries. For each request it: + +1. Reads the existing TLS Secret (snapshot `certificateSecrets`). +2. Compares the SAN list of the existing cert with the desired one. +3. If the cert is missing or its SANs no longer match, posts a CSR, **waits for it to be approved**, and stores the resulting `tls.crt` / `tls.key` (and `ca.crt`) in module values. + +### Quick start + +```go +package hooks + +import ( + certificatesv1 "k8s.io/api/certificates/v1" + + tlscert "github.com/deckhouse/module-sdk/common-hooks/tls-certificate" +) + +var _ = tlscert.RegisterOrderCertificateHookEM([]tlscert.OrderCertificateRequest{ + { + Namespace: "d8-example-module", + SecretName: "myservice-client-tls", + CommonName: "system:myservice", + SignerName: "kubernetes.io/kube-apiserver-client", + Groups: []string{"system:masters"}, + Usages: []certificatesv1.KeyUsage{ + certificatesv1.UsageDigitalSignature, + certificatesv1.UsageKeyEncipherment, + certificatesv1.UsageClientAuth, + }, + + SANs: []string{ + "myservice.d8-example-module", + "%CLUSTER_DOMAIN%://myservice.d8-example-module", + }, + + ModuleName: "exampleModule", + ValueName: "internal.myService.cert", + }, +}) +``` + +### `OrderCertificateRequest` + +| Field | Purpose | +| --- | --- | +| `Namespace` / `SecretName` | Existing Secret to inspect (the one your chart will render). | +| `CommonName`, `Groups`, `SANs`, `Usages`, `SignerName` | CSR contents. `%CLUSTER_DOMAIN%` and `%PUBLIC_DOMAIN%` placeholders in `SANs` are expanded from `global.discovery.clusterDomain` / `global.modules.publicDomainTemplate`. | +| `ModuleName` / `ValueName` | Where to write the resulting cert in values: `.` ⇒ `{ ca, crt, key }`. | +| `WaitTimeout` | How long to wait for CSR approval (default `1m`, overridable per request). | +| `ExpirationSeconds` | Optional. Pass through to the CSR. | + +### Hook configuration + +- **Trigger:** `OnBeforeHelm` (order `5`) + daily schedule `42 4 * * *`. +- **Snapshots:** `certificateSecrets` — Secrets in the listed namespaces with the listed names, JQ-filtered with `JQFilterApplyCertificateSecret` to extract `name`, `key`, `crt`. + +--- + +## JQ filters + +This package exports two JQ filters that are useful in tests: + +- `JQFilterTLS` — `{ key: .data."tls.key", crt: .data."tls.crt", ca: .data."ca.crt" }` +- `JQFilterApplyCertificateSecret` — like the above but accepts both `tls.*` and `client.*` keys, and includes the secret name. + +## Testing + +- [`internal_tls_test.go`](./internal_tls_test.go) — covers every code path of the self-signed hook (no cert, valid cert, outdated cert, outdated CA, mismatched expiry, shared CA with custom field names) using a real values store from [`testing/helpers`](../../testing/helpers). +- [`order_certificate_test.go`](./order_certificate_test.go) — JQ filter tests and a minimal config validation test. + +For a functional end-to-end test, drive either hook through [`testing/framework`](../../testing/framework): seed the Secret in `KubeStateSet`, run the hook, and assert on the resulting values + cluster state. diff --git a/common-hooks/tls-certificate/internal_tls_test.go b/common-hooks/tls-certificate/internal_tls_test.go index a8d9622f..3913f380 100644 --- a/common-hooks/tls-certificate/internal_tls_test.go +++ b/common-hooks/tls-certificate/internal_tls_test.go @@ -17,692 +17,348 @@ limitations under the License. package tlscertificate_test import ( - "bytes" "context" "encoding/json" "testing" "time" "github.com/stretchr/testify/assert" - "github.com/tidwall/gjson" - - "github.com/deckhouse/deckhouse/pkg/log" + "github.com/stretchr/testify/require" tlscertificate "github.com/deckhouse/module-sdk/common-hooks/tls-certificate" "github.com/deckhouse/module-sdk/pkg" "github.com/deckhouse/module-sdk/pkg/certificate" - "github.com/deckhouse/module-sdk/pkg/jq" - mock "github.com/deckhouse/module-sdk/testing/mock" + "github.com/deckhouse/module-sdk/testing/helpers" ) const tenYears = (24 * time.Hour) * 365 * 10 -func Test_JQFilterTLS(t *testing.T) { - t.Run("apply tls", func(t *testing.T) { - const rawSecret = ` - { - "apiVersion": "v1", - "data": { - "ca.crt": "c29tZS1jYQ==", - "tls.crt": "c29tZS1jcnQ=", - "tls.key": "c29tZS1rZXk=" - }, - "kind": "Secret", - "metadata": { - "name": "some-cert", - "namespace": "some-ns" - }, - "type": "kubernetes.io/tls" - }` - - q, err := jq.NewQuery(tlscertificate.JQFilterTLS) - assert.NoError(t, err) - - res, err := q.FilterStringObject(context.Background(), rawSecret) - assert.NoError(t, err) - - cert := new(certificate.Certificate) - err = json.NewDecoder(bytes.NewBufferString(res.String())).Decode(cert) - assert.NoError(t, err) - - assert.Equal(t, "some-key", string(cert.Key)) - assert.Equal(t, "some-crt", string(cert.Cert)) - assert.Equal(t, "some-ca", string(cert.CA)) - }) +// commonSANs is the canonical list of SAN entries used by most tests. +// It is rendered into the actual cert via DefaultSANs and the discovery +// values set up by setupDiscoveryValues. +var commonSANs = []string{ + "example-webhook", + "example-webhook.d8-example-module", + "example-webhook.d8-example-module.svc", + "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", + "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", } -func Test_InternalTLSConfig(t *testing.T) { - t.Run("config is valid", func(t *testing.T) { - assert.NoError(t, tlscertificate.GenSelfSignedTLSConfig(tlscertificate.GenSelfSignedTLSHookConf{}).Validate()) - }) +// expectedSANs is what we expect the hook to render the SANs into once +// the cluster domain ("cluster.local") and public domain template +// ("%s.127.0.0.1.sslip.io") are substituted. +var expectedSANs = []string{ + "example-webhook", + "example-webhook.d8-example-module", + "example-webhook.d8-example-module.svc", + "example-webhook.d8-example-module.svc.cluster.local", + "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", } -func Test_GenSelfSignedTLS(t *testing.T) { - t.Run("no certificate in snapshot", func(t *testing.T) { - dc := mock.NewDependencyContainerMock(t) - - snapshots := mock.NewSnapshotsMock(t) - snapshots.GetMock.When(tlscertificate.InternalTLSSnapshotKey).Then( - []pkg.Snapshot{}, - ) - - values := mock.NewOutputPatchableValuesCollectorMock(t) - - values.GetMock.When("global.discovery.clusterDomain").Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) - values.GetMock.When("global.modules.publicDomainTemplate").Then(gjson.Result{Type: gjson.String, Str: "%s.127.0.0.1.sslip.io"}) - - values.SetMock.Set(func(path string, v any) { - assert.Equal(t, "d8-example-module.internal.webhookCert", path) - - values, ok := v.(tlscertificate.CertValues) - assert.True(t, ok) - - assert.NotEmpty(t, values.CA) - assert.NotEmpty(t, values.Crt) - assert.NotEmpty(t, values.Key) - - ca, err := certificate.ParseCertificate([]byte(values.CA)) - assert.NoError(t, err) - - assert.Equal(t, ca.IsCA, true) - assert.Equal(t, ca.NotAfter.Sub(ca.NotBefore), time.Hour*24*365*5) - - cert, err := certificate.ParseCertificate([]byte(values.Crt)) - assert.NoError(t, err) - - assert.Equal(t, ca.Subject, cert.Issuer) - - assert.Equal(t, []string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }, cert.DNSNames) - - assert.Equal(t, cert.NotAfter.Sub(cert.NotBefore), time.Hour*24*365) - }) - - var input = &pkg.HookInput{ - Snapshots: snapshots, - Values: values, - DC: dc, - Logger: log.NewNop(), - } - - config := tlscertificate.GenSelfSignedTLSHookConf{ - CN: "cert-name", - TLSSecretName: "secret-webhook-cert", - Namespace: "some-namespace", - BeforeHookCheck: func(_ *pkg.HookInput) bool { - return true - }, - SANs: tlscertificate.DefaultSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", - "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", - }), - FullValuesPathPrefix: "d8-example-module.internal.webhookCert", - CAExpiryDuration: time.Hour * 24 * 365 * 5, - CertExpiryDuration: time.Hour * 24 * 365, - } - - err := tlscertificate.GenSelfSignedTLS(config)(context.Background(), input) - assert.NoError(t, err) - }) - - t.Run("actual certificate in snapshot", func(t *testing.T) { - dc := mock.NewDependencyContainerMock(t) - - snapshots := mock.NewSnapshotsMock(t) - snapshots.GetMock.When(tlscertificate.InternalTLSSnapshotKey).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(t).UnmarshalToMock.Set(func(v any) error { - ca, err := certificate.GenerateCA( - "cert-name", - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithCAExpiry(tenYears)) - - assert.NoError(t, err) - - cert, err := certificate.GenerateSelfSignedCert( - "cert-name", - ca, - certificate.WithSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }...), - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithSigningDefaultExpiry((24*time.Hour)*365*10), - certificate.WithSigningDefaultUsage([]string{ - "signing", - "key encipherment", - "requestheader-client", - }), - ) - - assert.NoError(t, err) - - value := v.(*certificate.Certificate) - *value = *cert - - return nil - }), - }, - ) - - values := mock.NewOutputPatchableValuesCollectorMock(t) - - values.GetMock.When("global.discovery.clusterDomain").Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) - values.GetMock.When("global.modules.publicDomainTemplate").Then(gjson.Result{Type: gjson.String, Str: "%s.127.0.0.1.sslip.io"}) - - values.SetMock.Set(func(path string, v any) { - assert.Equal(t, "d8-example-module.internal.webhookCert", path) - - values, ok := v.(tlscertificate.CertValues) - assert.True(t, ok) - - assert.NotEmpty(t, values.CA) - assert.NotEmpty(t, values.Crt) - assert.NotEmpty(t, values.Key) - - cert, err := certificate.ParseCertificate([]byte(values.Crt)) - assert.NoError(t, err) - - assert.Equal(t, []string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }, cert.DNSNames) - }) +// discoveryValues holds the bits of `global` that the TLS hook reads. +// Encoded as JSON so we can hand it directly to helpers.NewValuesFromJSON. +const discoveryValues = `{ + "global": { + "discovery": {"clusterDomain": "cluster.local"}, + "modules": {"publicDomainTemplate": "%s.127.0.0.1.sslip.io"} + } +}` + +// generateCertSnapshot generates a CA + cert pair using the provided +// expiries and returns it as a single Snapshot, ready for +// helpers.NewSnapshots().Add(InternalTLSSnapshotKey, ...). +func generateCertSnapshot(t *testing.T, caExpiry, certExpiry time.Duration) pkg.Snapshot { + t.Helper() + + ca, err := certificate.GenerateCA("cert-name", + certificate.WithKeyAlgo("ecdsa"), + certificate.WithKeySize(256), + certificate.WithCAExpiry(caExpiry)) + require.NoError(t, err) + + cert, err := certificate.GenerateSelfSignedCert("cert-name", ca, + certificate.WithSANs(expectedSANs...), + certificate.WithKeyAlgo("ecdsa"), + certificate.WithKeySize(256), + certificate.WithSigningDefaultExpiry(certExpiry), + certificate.WithSigningDefaultUsage([]string{ + "signing", + "key encipherment", + "requestheader-client", + }), + ) + require.NoError(t, err) + + return helpers.SnapshotFromObject(cert) +} - var input = &pkg.HookInput{ - Snapshots: snapshots, - Values: values, - DC: dc, - Logger: log.NewNop(), +// extractCertValues returns the CertValues that the hook wrote to the +// values store at the configured prefix. It fails the test if the value +// was not set. +func extractCertValues(t *testing.T, v pkg.PatchableValuesCollector, path string) tlscertificate.CertValues { + t.Helper() + patches := v.GetPatches() + require.NotEmpty(t, patches, "expected the hook to write certificate values") + + want := "/" + replaceDots(path) + for _, op := range patches { + if op.Path != want { + continue } + var cv tlscertificate.CertValues + require.NoError(t, json.Unmarshal(op.Value, &cv)) + return cv + } + t.Fatalf("no patch operation at %q (got %+v)", want, patches) + return tlscertificate.CertValues{} +} - config := tlscertificate.GenSelfSignedTLSHookConf{ - CN: "cert-name", - TLSSecretName: "secret-webhook-cert", - Namespace: "some-namespace", - SANs: tlscertificate.DefaultSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", - "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", - }), - FullValuesPathPrefix: "d8-example-module.internal.webhookCert", +// replaceDots converts a dotted path into a JSON-Pointer style segmentation. +func replaceDots(p string) string { + out := []byte(p) + for i := range out { + if out[i] == '.' { + out[i] = '/' } + } + return string(out) +} - err := tlscertificate.GenSelfSignedTLS(config)(context.Background(), input) - assert.NoError(t, err) - }) - - t.Run("outdated certificate in snapshot", func(t *testing.T) { - dc := mock.NewDependencyContainerMock(t) - - snapshots := mock.NewSnapshotsMock(t) - snapshots.GetMock.When(tlscertificate.InternalTLSSnapshotKey).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(t).UnmarshalToMock.Set(func(v any) error { - ca, err := certificate.GenerateCA( - "cert-name", - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithCAExpiry(tenYears)) - - assert.NoError(t, err) - - cert, err := certificate.GenerateSelfSignedCert( - "cert-name", - ca, - certificate.WithSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }...), - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithSigningDefaultExpiry(1*time.Hour), - certificate.WithSigningDefaultUsage([]string{ - "signing", - "key encipherment", - "requestheader-client", - }), - ) - - assert.NoError(t, err) - - value := v.(*certificate.Certificate) - *value = *cert - - return nil - }), - }, - ) - - values := mock.NewOutputPatchableValuesCollectorMock(t) - - values.GetMock.When("global.discovery.clusterDomain").Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) - values.GetMock.When("global.modules.publicDomainTemplate").Then(gjson.Result{Type: gjson.String, Str: "%s.127.0.0.1.sslip.io"}) - - values.SetMock.Set(func(path string, v any) { - assert.Equal(t, "d8-example-module.internal.webhookCert", path) +// runTLSHook invokes the hook with the given config against an input +// constructed from the provided snapshot and seeded discovery values. +// It returns the values collector so the caller can assert on patches. +func runTLSHook(t *testing.T, conf tlscertificate.GenSelfSignedTLSHookConf, snap pkg.Snapshot, extraValues string) pkg.PatchableValuesCollector { + t.Helper() - values, ok := v.(tlscertificate.CertValues) - assert.True(t, ok) + values := mergeValuesJSON(t, discoveryValues, extraValues) - assert.NotEmpty(t, values.CA) - assert.NotEmpty(t, values.Crt) - assert.NotEmpty(t, values.Key) + snaps := helpers.NewSnapshots() + if snap != nil { + snaps.Add(tlscertificate.InternalTLSSnapshotKey, snap) + } - cert, err := certificate.ParseCertificate([]byte(values.Crt)) - assert.NoError(t, err) + in := helpers.NewInputBuilder(t). + WithSnapshots(snaps). + WithValues(values). + Build() - assert.Equal(t, []string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }, cert.DNSNames) - }) + require.NoError(t, tlscertificate.GenSelfSignedTLS(conf)(context.Background(), in)) + return values +} - var input = &pkg.HookInput{ - Snapshots: snapshots, - Values: values, - DC: dc, - Logger: log.NewNop(), - } +func mergeValuesJSON(t *testing.T, base, overlay string) pkg.PatchableValuesCollector { + t.Helper() + if overlay == "" { + return helpers.NewValuesFromJSON(base) + } + var merged map[string]any + require.NoError(t, json.Unmarshal([]byte(base), &merged)) + var ovr map[string]any + require.NoError(t, json.Unmarshal([]byte(overlay), &ovr)) + deepMerge(merged, ovr) + return helpers.NewValues(merged) +} - config := tlscertificate.GenSelfSignedTLSHookConf{ - CN: "cert-name", - TLSSecretName: "secret-webhook-cert", - Namespace: "some-namespace", - SANs: tlscertificate.DefaultSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", - "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", - }), - FullValuesPathPrefix: "d8-example-module.internal.webhookCert", - CertOutdatedDuration: 2 * time.Hour, +func deepMerge(dst, src map[string]any) { + for k, v := range src { + if cur, ok := dst[k].(map[string]any); ok { + if next, ok := v.(map[string]any); ok { + deepMerge(cur, next) + continue + } } + dst[k] = v + } +} - err := tlscertificate.GenSelfSignedTLS(config)(context.Background(), input) - assert.NoError(t, err) - }) - - t.Run("outdated ca in snapshot", func(t *testing.T) { - dc := mock.NewDependencyContainerMock(t) - - snapshots := mock.NewSnapshotsMock(t) - snapshots.GetMock.When(tlscertificate.InternalTLSSnapshotKey).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(t).UnmarshalToMock.Set(func(v any) error { - ca, err := certificate.GenerateCA( - "cert-name", - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithCAExpiry(time.Hour)) - - assert.NoError(t, err) - - cert, err := certificate.GenerateSelfSignedCert( - "cert-name", - ca, - certificate.WithSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }...), - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithSigningDefaultExpiry((24*time.Hour)*365*10), - certificate.WithSigningDefaultUsage([]string{ - "signing", - "key encipherment", - "requestheader-client", - }), - ) - - assert.NoError(t, err) - - value := v.(*certificate.Certificate) - *value = *cert - - return nil - }), - }, - ) - - values := mock.NewOutputPatchableValuesCollectorMock(t) - - values.GetMock.When("global.discovery.clusterDomain").Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) - values.GetMock.When("global.modules.publicDomainTemplate").Then(gjson.Result{Type: gjson.String, Str: "%s.127.0.0.1.sslip.io"}) - - values.SetMock.Set(func(path string, v any) { - assert.Equal(t, "d8-example-module.internal.webhookCert", path) - - values, ok := v.(tlscertificate.CertValues) - assert.True(t, ok) - - assert.NotEmpty(t, values.CA) - assert.NotEmpty(t, values.Crt) - assert.NotEmpty(t, values.Key) - - cert, err := certificate.ParseCertificate([]byte(values.Crt)) - assert.NoError(t, err) +// defaultConf returns a baseline GenSelfSignedTLSHookConf that produces a +// cert at d8-example-module.internal.webhookCert. Each test starts from +// this and tweaks the fields under test. +func defaultConf() tlscertificate.GenSelfSignedTLSHookConf { + return tlscertificate.GenSelfSignedTLSHookConf{ + CN: "cert-name", + TLSSecretName: "secret-webhook-cert", + Namespace: "some-namespace", + SANs: tlscertificate.DefaultSANs(commonSANs), + FullValuesPathPrefix: "d8-example-module.internal.webhookCert", + } +} - assert.Equal(t, []string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }, cert.DNSNames) - }) +// assertCertHasExpectedSANs checks that the resulting CertValues contain +// a non-empty CA / Crt / Key and that the cert renders the expected SANs. +func assertCertHasExpectedSANs(t *testing.T, cv tlscertificate.CertValues) { + t.Helper() + assert.NotEmpty(t, cv.CA) + assert.NotEmpty(t, cv.Crt) + assert.NotEmpty(t, cv.Key) + + cert, err := certificate.ParseCertificate([]byte(cv.Crt)) + require.NoError(t, err) + assert.Equal(t, expectedSANs, cert.DNSNames) +} - var input = &pkg.HookInput{ - Snapshots: snapshots, - Values: values, - DC: dc, - Logger: log.NewNop(), - } +// ============================================================================= +// Tests +// ============================================================================= + +func TestJQFilterTLS(t *testing.T) { + const rawSecret = `{ + "apiVersion": "v1", + "data": { + "ca.crt": "c29tZS1jYQ==", + "tls.crt": "c29tZS1jcnQ=", + "tls.key": "c29tZS1rZXk=" + }, + "kind": "Secret", + "metadata": {"name": "some-cert", "namespace": "some-ns"}, + "type": "kubernetes.io/tls" +}` + + cert := new(certificate.Certificate) + require.NoError(t, helpers.JQRunOnString( + context.Background(), + tlscertificate.JQFilterTLS, + rawSecret, + cert, + )) + + assert.Equal(t, "some-key", string(cert.Key)) + assert.Equal(t, "some-crt", string(cert.Cert)) + assert.Equal(t, "some-ca", string(cert.CA)) +} - config := tlscertificate.GenSelfSignedTLSHookConf{ - CN: "cert-name", - TLSSecretName: "secret-webhook-cert", - Namespace: "some-namespace", - SANs: tlscertificate.DefaultSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", - "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", - }), - FullValuesPathPrefix: "d8-example-module.internal.webhookCert", - CAOutdatedDuration: 2 * time.Hour, - } +func TestInternalTLSConfig_Valid(t *testing.T) { + require.NoError(t, tlscertificate.GenSelfSignedTLSConfig(tlscertificate.GenSelfSignedTLSHookConf{}).Validate()) +} - err := tlscertificate.GenSelfSignedTLS(config)(context.Background(), input) - assert.NoError(t, err) - }) +func TestGenSelfSignedTLS_NoCertificateInSnapshot(t *testing.T) { + conf := defaultConf() + conf.BeforeHookCheck = func(_ *pkg.HookInput) bool { return true } + conf.CAExpiryDuration = 5 * 365 * 24 * time.Hour + conf.CertExpiryDuration = 365 * 24 * time.Hour - t.Run("wrong ca expiry duration in snapshot", func(t *testing.T) { - dc := mock.NewDependencyContainerMock(t) - - snapshots := mock.NewSnapshotsMock(t) - snapshots.GetMock.When(tlscertificate.InternalTLSSnapshotKey).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(t).UnmarshalToMock.Set(func(v any) error { - ca, err := certificate.GenerateCA( - "cert-name", - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithCAExpiry((24*time.Hour)*365*10)) - - assert.NoError(t, err) - - cert, err := certificate.GenerateSelfSignedCert( - "cert-name", - ca, - certificate.WithSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }...), - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithSigningDefaultExpiry((24*time.Hour)*365*10), - certificate.WithSigningDefaultUsage([]string{ - "signing", - "key encipherment", - "requestheader-client", - }), - ) - - assert.NoError(t, err) - - value := v.(*certificate.Certificate) - *value = *cert - - return nil - }), - }, - ) + values := runTLSHook(t, conf, nil, "") - values := mock.NewOutputPatchableValuesCollectorMock(t) + cv := extractCertValues(t, values, conf.FullValuesPathPrefix) + assertCertHasExpectedSANs(t, cv) - values.GetMock.When("global.discovery.clusterDomain").Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) - values.GetMock.When("global.modules.publicDomainTemplate").Then(gjson.Result{Type: gjson.String, Str: "%s.127.0.0.1.sslip.io"}) + ca, err := certificate.ParseCertificate([]byte(cv.CA)) + require.NoError(t, err) + assert.True(t, ca.IsCA) + assert.Equal(t, 5*365*24*time.Hour, ca.NotAfter.Sub(ca.NotBefore)) - values.SetMock.Set(func(path string, v any) { - assert.Equal(t, "d8-example-module.internal.webhookCert", path) + cert, err := certificate.ParseCertificate([]byte(cv.Crt)) + require.NoError(t, err) + assert.Equal(t, ca.Subject, cert.Issuer) + assert.Equal(t, 365*24*time.Hour, cert.NotAfter.Sub(cert.NotBefore)) +} - values, ok := v.(tlscertificate.CertValues) - assert.True(t, ok) +func TestGenSelfSignedTLS_ActualCertificateInSnapshot(t *testing.T) { + snap := generateCertSnapshot(t, tenYears, tenYears) - assert.NotEmpty(t, values.CA) - assert.NotEmpty(t, values.Crt) - assert.NotEmpty(t, values.Key) + values := runTLSHook(t, defaultConf(), snap, "") - ca, err := certificate.ParseCertificate([]byte(values.CA)) - assert.NoError(t, err) - assert.Equal(t, ca.NotAfter.Sub(ca.NotBefore), (24*time.Hour)*365) + cv := extractCertValues(t, values, defaultConf().FullValuesPathPrefix) + assertCertHasExpectedSANs(t, cv) +} - cert, err := certificate.ParseCertificate([]byte(values.Crt)) - assert.NoError(t, err) +func TestGenSelfSignedTLS_OutdatedCertificateInSnapshot(t *testing.T) { + // Generate cert with 1h validity; outdated threshold is 2h, so it's "outdated". + snap := generateCertSnapshot(t, tenYears, time.Hour) - assert.Equal(t, []string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }, cert.DNSNames) - }) + conf := defaultConf() + conf.CertOutdatedDuration = 2 * time.Hour - var input = &pkg.HookInput{ - Snapshots: snapshots, - Values: values, - DC: dc, - Logger: log.NewNop(), - } + values := runTLSHook(t, conf, snap, "") - config := tlscertificate.GenSelfSignedTLSHookConf{ - CN: "cert-name", - TLSSecretName: "secret-webhook-cert", - Namespace: "some-namespace", - SANs: tlscertificate.DefaultSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", - "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", - }), - FullValuesPathPrefix: "d8-example-module.internal.webhookCert", - CAExpiryDuration: (24 * time.Hour) * 365, - } + cv := extractCertValues(t, values, conf.FullValuesPathPrefix) + assertCertHasExpectedSANs(t, cv) +} - err := tlscertificate.GenSelfSignedTLS(config)(context.Background(), input) - assert.NoError(t, err) - }) +func TestGenSelfSignedTLS_OutdatedCAInSnapshot(t *testing.T) { + snap := generateCertSnapshot(t, time.Hour, tenYears) - t.Run("common ca with custom cert field name", func(t *testing.T) { - dc := mock.NewDependencyContainerMock(t) + conf := defaultConf() + conf.CAOutdatedDuration = 2 * time.Hour - snapshots := mock.NewSnapshotsMock(t) - snapshots.GetMock.When(tlscertificate.InternalTLSSnapshotKey).Then([]pkg.Snapshot{}) + values := runTLSHook(t, conf, snap, "") - ca, err := certificate.GenerateCA( - "cert-name", - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithCAExpiry(tenYears)) - assert.NoError(t, err) + cv := extractCertValues(t, values, conf.FullValuesPathPrefix) + assertCertHasExpectedSANs(t, cv) +} - values := mock.NewOutputPatchableValuesCollectorMock(t) +func TestGenSelfSignedTLS_WrongCAExpiry(t *testing.T) { + snap := generateCertSnapshot(t, tenYears, tenYears) - values.GetMock.When("global.discovery.clusterDomain").Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) - values.GetMock.When("global.modules.publicDomainTemplate").Then(gjson.Result{Type: gjson.String, Str: "%s.127.0.0.1.sslip.io"}) + conf := defaultConf() + conf.CAExpiryDuration = 365 * 24 * time.Hour // 1 year - // CA is stored at custom field name "cert" instead of default "crt" - values.GetOkMock.When("global.internal.modules.kubeRBACProxyCA.cert").Then(gjson.Result{Type: gjson.String, Str: string(ca.Cert)}, true) - values.GetOkMock.When("global.internal.modules.kubeRBACProxyCA.key").Then(gjson.Result{Type: gjson.String, Str: string(ca.Key)}, true) + values := runTLSHook(t, conf, snap, "") - values.SetMock.Set(func(path string, v any) { - assert.Equal(t, "d8-example-module.internal.kubeRBACProxyCert", path) + cv := extractCertValues(t, values, conf.FullValuesPathPrefix) + assertCertHasExpectedSANs(t, cv) - vals, ok := v.(tlscertificate.CertValues) - assert.True(t, ok) + ca, err := certificate.ParseCertificate([]byte(cv.CA)) + require.NoError(t, err) + assert.Equal(t, 365*24*time.Hour, ca.NotAfter.Sub(ca.NotBefore)) +} - assert.NotEmpty(t, vals.CA) - assert.NotEmpty(t, vals.Crt) - assert.NotEmpty(t, vals.Key) +func TestGenSelfSignedTLS_WrongCertExpiry(t *testing.T) { + snap := generateCertSnapshot(t, tenYears, tenYears) - parsedCert, err := certificate.ParseCertificate([]byte(vals.Crt)) - assert.NoError(t, err) - assert.Equal(t, parsedCert.Issuer.CommonName, "cert-name") - }) + conf := defaultConf() + conf.CertExpiryDuration = 365 * 24 * time.Hour - var input = &pkg.HookInput{ - Snapshots: snapshots, - Values: values, - DC: dc, - Logger: log.NewNop(), - } + values := runTLSHook(t, conf, snap, "") - config := tlscertificate.GenSelfSignedTLSHookConf{ - CN: "cert-name", - TLSSecretName: "secret-kube-rbac-proxy-cert", - Namespace: "some-namespace", - SANs: tlscertificate.DefaultSANs([]string{"example-svc"}), - FullValuesPathPrefix: "d8-example-module.internal.kubeRBACProxyCert", - CommonCAValuesPath: "global.internal.modules.kubeRBACProxyCA", - CommonCACertField: "cert", - } + cv := extractCertValues(t, values, conf.FullValuesPathPrefix) + assertCertHasExpectedSANs(t, cv) - err = tlscertificate.GenSelfSignedTLS(config)(context.Background(), input) - assert.NoError(t, err) - }) + cert, err := certificate.ParseCertificate([]byte(cv.Crt)) + require.NoError(t, err) + assert.Equal(t, 365*24*time.Hour, cert.NotAfter.Sub(cert.NotBefore)) +} - t.Run("wrong certificate expiry duration in snapshot", func(t *testing.T) { - dc := mock.NewDependencyContainerMock(t) - - snapshots := mock.NewSnapshotsMock(t) - snapshots.GetMock.When(tlscertificate.InternalTLSSnapshotKey).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(t).UnmarshalToMock.Set(func(v any) error { - ca, err := certificate.GenerateCA( - "cert-name", - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithCAExpiry((24*time.Hour)*365*10)) - - assert.NoError(t, err) - - cert, err := certificate.GenerateSelfSignedCert( - "cert-name", - ca, - certificate.WithSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }...), - certificate.WithKeyAlgo("ecdsa"), - certificate.WithKeySize(256), - certificate.WithSigningDefaultExpiry((24*time.Hour)*365*10), - certificate.WithSigningDefaultUsage([]string{ - "signing", - "key encipherment", - "requestheader-client", - }), - ) - - assert.NoError(t, err) - - value := v.(*certificate.Certificate) - *value = *cert - - return nil - }), +func TestGenSelfSignedTLS_CommonCAWithCustomFieldName(t *testing.T) { + ca, err := certificate.GenerateCA("cert-name", + certificate.WithKeyAlgo("ecdsa"), + certificate.WithKeySize(256), + certificate.WithCAExpiry(tenYears)) + require.NoError(t, err) + + // CA is stored at custom field name "cert" instead of default "crt". + overlay, err := json.Marshal(map[string]any{ + "global": map[string]any{ + "internal": map[string]any{ + "modules": map[string]any{ + "kubeRBACProxyCA": map[string]any{ + "cert": string(ca.Cert), + "key": string(ca.Key), + }, + }, }, - ) - - values := mock.NewOutputPatchableValuesCollectorMock(t) - - values.GetMock.When("global.discovery.clusterDomain").Then(gjson.Result{Type: gjson.String, Str: "cluster.local"}) - values.GetMock.When("global.modules.publicDomainTemplate").Then(gjson.Result{Type: gjson.String, Str: "%s.127.0.0.1.sslip.io"}) - - values.SetMock.Set(func(path string, v any) { - assert.Equal(t, "d8-example-module.internal.webhookCert", path) - - values, ok := v.(tlscertificate.CertValues) - assert.True(t, ok) - - assert.NotEmpty(t, values.CA) - assert.NotEmpty(t, values.Crt) - assert.NotEmpty(t, values.Key) - - cert, err := certificate.ParseCertificate([]byte(values.Crt)) - assert.NoError(t, err) - - assert.Equal(t, []string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "example-webhook.d8-example-module.svc.cluster.local", - "example-webhook.d8-example-module.svc.127.0.0.1.sslip.io", - }, cert.DNSNames) - - assert.Equal(t, cert.NotAfter.Sub(cert.NotBefore), (24*time.Hour)*365) - }) - - var input = &pkg.HookInput{ - Snapshots: snapshots, - Values: values, - DC: dc, - Logger: log.NewNop(), - } - - config := tlscertificate.GenSelfSignedTLSHookConf{ - CN: "cert-name", - TLSSecretName: "secret-webhook-cert", - Namespace: "some-namespace", - SANs: tlscertificate.DefaultSANs([]string{ - "example-webhook", - "example-webhook.d8-example-module", - "example-webhook.d8-example-module.svc", - "%CLUSTER_DOMAIN%://example-webhook.d8-example-module.svc", - "%PUBLIC_DOMAIN%://example-webhook.d8-example-module.svc", - }), - FullValuesPathPrefix: "d8-example-module.internal.webhookCert", - CertExpiryDuration: (24 * time.Hour) * 365, - } - - err := tlscertificate.GenSelfSignedTLS(config)(context.Background(), input) - assert.NoError(t, err) + }, }) + require.NoError(t, err) + + conf := tlscertificate.GenSelfSignedTLSHookConf{ + CN: "cert-name", + TLSSecretName: "secret-kube-rbac-proxy-cert", + Namespace: "some-namespace", + SANs: tlscertificate.DefaultSANs([]string{"example-svc"}), + FullValuesPathPrefix: "d8-example-module.internal.kubeRBACProxyCert", + CommonCAValuesPath: "global.internal.modules.kubeRBACProxyCA", + CommonCACertField: "cert", + } + + values := runTLSHook(t, conf, nil, string(overlay)) + + cv := extractCertValues(t, values, conf.FullValuesPathPrefix) + assert.NotEmpty(t, cv.CA) + assert.NotEmpty(t, cv.Crt) + assert.NotEmpty(t, cv.Key) + + parsedCert, err := certificate.ParseCertificate([]byte(cv.Crt)) + require.NoError(t, err) + assert.Equal(t, "cert-name", parsedCert.Issuer.CommonName) } diff --git a/common-hooks/tls-certificate/order_certificate_test.go b/common-hooks/tls-certificate/order_certificate_test.go index 3f117244..3234fcf6 100644 --- a/common-hooks/tls-certificate/order_certificate_test.go +++ b/common-hooks/tls-certificate/order_certificate_test.go @@ -17,86 +17,77 @@ limitations under the License. package tlscertificate_test import ( - "bytes" "context" - "encoding/json" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" tlscertificate "github.com/deckhouse/module-sdk/common-hooks/tls-certificate" "github.com/deckhouse/module-sdk/pkg/certificate" - "github.com/deckhouse/module-sdk/pkg/jq" + "github.com/deckhouse/module-sdk/testing/helpers" ) -func Test_JQFilterApplyCertificateSecret(t *testing.T) { - t.Run("apply tls", func(t *testing.T) { - const rawSecret = ` - { - "apiVersion": "v1", - "data": { - "ca.crt": "c29tZS1jYQ==", - "tls.crt": "c29tZS1jcnQ=", - "tls.key": "c29tZS1rZXk=" - }, - "kind": "Secret", - "metadata": { - "name": "some-cert", - "namespace": "some-ns" - }, - "type": "kubernetes.io/tls" - }` - - q, err := jq.NewQuery(tlscertificate.JQFilterApplyCertificateSecret) - assert.NoError(t, err) - - res, err := q.FilterStringObject(context.Background(), rawSecret) - assert.NoError(t, err) - - auth := new(certificate.Certificate) - err = json.NewDecoder(bytes.NewBufferString(res.String())).Decode(auth) - assert.NoError(t, err) - - assert.Equal(t, "some-key", string(auth.Key)) - assert.Equal(t, "some-crt", string(auth.Cert)) - assert.Equal(t, "some-cert", auth.Name) - }) - - t.Run("apply tls from client", func(t *testing.T) { - const rawSecret = ` - { - "apiVersion": "v1", - "data": { - "ca.crt": "c29tZS1jYQ==", - "client.crt": "c29tZS1jcnQ=", - "client.key": "c29tZS1rZXk=" - }, - "kind": "Secret", - "metadata": { - "name": "some-cert", - "namespace": "some-ns" - }, - "type": "kubernetes.io/tls" - }` - - q, err := jq.NewQuery(tlscertificate.JQFilterApplyCertificateSecret) - assert.NoError(t, err) - - res, err := q.FilterStringObject(context.Background(), rawSecret) - assert.NoError(t, err) - - cert := new(certificate.Certificate) - err = json.NewDecoder(bytes.NewBufferString(res.String())).Decode(cert) - assert.NoError(t, err) +// secretWithKeys is a small helper that returns a JSON-encoded TLS Secret +// whose `data..crt`, `data..key`, and `data.ca.crt` +// fields are pre-populated with base64 of the expected plaintext values. +func secretWithKeys(keyPrefix string) string { + switch keyPrefix { + case "tls": + return `{ + "apiVersion": "v1", + "data": { + "ca.crt": "c29tZS1jYQ==", + "tls.crt": "c29tZS1jcnQ=", + "tls.key": "c29tZS1rZXk=" + }, + "kind": "Secret", + "metadata": {"name": "some-cert", "namespace": "some-ns"}, + "type": "kubernetes.io/tls" +}` + case "client": + return `{ + "apiVersion": "v1", + "data": { + "ca.crt": "c29tZS1jYQ==", + "client.crt": "c29tZS1jcnQ=", + "client.key": "c29tZS1rZXk=" + }, + "kind": "Secret", + "metadata": {"name": "some-cert", "namespace": "some-ns"}, + "type": "kubernetes.io/tls" +}` + } + panic("unsupported prefix") +} - assert.Equal(t, "some-key", string(cert.Key)) - assert.Equal(t, "some-crt", string(cert.Cert)) - assert.Equal(t, "some-cert", cert.Name) - }) +func TestJQFilterApplyCertificateSecret(t *testing.T) { + cases := []struct { + name string + keyPrefix string + }{ + {name: "tls keys", keyPrefix: "tls"}, + {name: "client keys", keyPrefix: "client"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cert := new(certificate.Certificate) + + require.NoError(t, helpers.JQRunOnString( + context.Background(), + tlscertificate.JQFilterApplyCertificateSecret, + secretWithKeys(tc.keyPrefix), + cert, + )) + + assert.Equal(t, "some-cert", cert.Name) + assert.Equal(t, "some-key", string(cert.Key)) + assert.Equal(t, "some-crt", string(cert.Cert)) + }) + } } -func Test_CertificateHandlerConfig(t *testing.T) { - t.Run("config is valid", func(t *testing.T) { - assert.NoError(t, tlscertificate.CertificateHandlerConfig([]string{}, []string{}).Validate()) - }) +func TestCertificateHandlerConfig_IsValid(t *testing.T) { + require.NoError(t, tlscertificate.CertificateHandlerConfig([]string{}, []string{}).Validate()) } diff --git a/examples/README.md b/examples/README.md index b998f8b5..7c2ad517 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,17 +1,49 @@ # Examples -[Module hook basic example](https://github.com/deckhouse/module-sdk/tree/main/examples/basic-example-module) +Each example is a standalone Go module with its own `go.mod` and tests, so it doubles as documentation for one specific feature of the Module SDK. -[Module hook single file example](https://github.com/deckhouse/module-sdk/tree/main/examples/single-file-example) +## Module hooks -[Module hook example](https://github.com/deckhouse/module-sdk/tree/main/examples/example-module) +| Example | What it shows | +| --- | --- | +| [`basic-example-module`](./basic-example-module) | The simplest possible layout: a `hooks/` folder, a single hook, a single binary. | +| [`single-file-example`](./single-file-example) | Single-file module hook plus tests using both [`testing/helpers`](../testing/helpers) and [`testing/framework`](../testing/framework). | +| [`example-module`](./example-module) | A richer module with several hooks (snapshots, patches, values, metrics) and tests for each. | +| [`dependency-example-module`](./dependency-example-module) | Hooks that use the `DependencyContainer` (HTTP client, K8s client, registry client), with mock-based and framework-level tests. | +| [`common-hooks`](./common-hooks) | Real-world usage of the reusable hooks under [`common-hooks/`](../common-hooks) (e.g. `tls-certificate`). | -[Module hook example with dependency container](https://github.com/deckhouse/module-sdk/tree/main/examples/dependency-example-module) +## Application hooks -[Module hook examples with common hooks](https://github.com/deckhouse/module-sdk/tree/main/examples/common-hooks) +| Example | What it shows | +| --- | --- | +| [`single-file-app-example`](./single-file-app-example) | A minimal `pkg.ApplicationHookInput` hook, including a settings gate and a snapshot binding. | +| [`settings-check`](./settings-check) | Validating module configuration values via `pkg/settingscheck`. | -[Dockerfile and Makefile for building](https://github.com/deckhouse/module-sdk/tree/main/examples/scripts) +## Build & deploy -[Settings check example](https://github.com/deckhouse/module-sdk/tree/main/examples/settings-check) +| Example | What it shows | +| --- | --- | +| [`scripts`](./scripts) | Reference `Dockerfile` and `Makefile` for building and shipping a hook binary. | -[Application hook single file example](https://github.com/deckhouse/module-sdk/tree/main/examples/single-file-app-example) \ No newline at end of file +--- + +## Running the examples + +Every example is a self-contained module with a `replace` directive pointing back at this repo, so you can do: + +```sh +cd examples/example-module/hooks +go test ./... +``` + +The repository-wide `make examples` target iterates over every example module. + +## Test layering + +The example suites intentionally mix testing styles, so you can copy whichever fits your situation: + +- **Unit tests** with `testing/helpers.InputBuilder` + real values stores; +- **Mock-based tests** for error-injection edge cases; +- **Functional tests** (`*_framework_test.go`) driving the hook against a fake Kubernetes cluster via `testing/framework`. + +For the strategy behind that mix, see [`TESTING.md`](../TESTING.md). diff --git a/examples/dependency-example-module/hooks/go.mod b/examples/dependency-example-module/hooks/go.mod index 66c825d3..53132a77 100644 --- a/examples/dependency-example-module/hooks/go.mod +++ b/examples/dependency-example-module/hooks/go.mod @@ -3,12 +3,10 @@ module dependency-example-module go 1.25.8 require ( - github.com/deckhouse/deckhouse/pkg/log v0.2.0 github.com/deckhouse/module-sdk v0.0.0 github.com/gojuno/minimock/v3 v3.4.7 github.com/google/go-containerregistry v0.20.6 - github.com/onsi/ginkgo v1.16.5 - github.com/onsi/gomega v1.36.1 + github.com/stretchr/testify v1.10.0 k8s.io/api v0.33.8 k8s.io/apimachinery v0.33.8 sigs.k8s.io/controller-runtime v0.20.4 @@ -21,6 +19,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deckhouse/deckhouse/pkg/log v0.2.0 // indirect github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect @@ -39,6 +38,8 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.17 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -48,7 +49,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nxadm/tail v1.4.8 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -77,7 +77,6 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.33.8 // indirect k8s.io/client-go v0.33.8 // indirect diff --git a/examples/dependency-example-module/hooks/go.sum b/examples/dependency-example-module/hooks/go.sum index 1bab21e3..1e5c0cc3 100644 --- a/examples/dependency-example-module/hooks/go.sum +++ b/examples/dependency-example-module/hooks/go.sum @@ -30,8 +30,6 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -48,28 +46,16 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gojuno/minimock/v3 v3.4.7 h1:vhE5zpniyPDRT0DXd5s3DbtZJVlcbmC5k80izYtj9lY= github.com/gojuno/minimock/v3 v3.4.7/go.mod h1:QxJk4mdPrVyYUmEZGc2yD2NONpqM/j4dWhsy9twjFHg= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -82,9 +68,12 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -115,17 +104,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -164,7 +144,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -198,31 +177,22 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -237,7 +207,6 @@ golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= @@ -247,12 +216,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -260,14 +223,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/dependency-example-module/hooks/subfolder/config_test.go b/examples/dependency-example-module/hooks/subfolder/config_test.go index 7afbdb13..2a672c1b 100644 --- a/examples/dependency-example-module/hooks/subfolder/config_test.go +++ b/examples/dependency-example-module/hooks/subfolder/config_test.go @@ -1,17 +1,15 @@ package hookinfolder_test import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "testing" + + "github.com/stretchr/testify/assert" "github.com/deckhouse/module-sdk/pkg/registry" ) -var _ = Describe("validate hooks config", func() { - It("hook configs must be valid", func() { - hooks := registry.Registry().ModuleHooks() - for _, hook := range hooks { - Expect(hook.Config.Validate()).ShouldNot(HaveOccurred()) - } - }) -}) +func TestRegisteredHookConfigs_AreValid(t *testing.T) { + for _, hook := range registry.Registry().ModuleHooks() { + assert.NoError(t, hook.Config.Validate(), "hook config must be valid") + } +} diff --git a/examples/dependency-example-module/hooks/subfolder/http_client_framework_test.go b/examples/dependency-example-module/hooks/subfolder/http_client_framework_test.go new file mode 100644 index 00000000..7589a6e9 --- /dev/null +++ b/examples/dependency-example-module/hooks/subfolder/http_client_framework_test.go @@ -0,0 +1,45 @@ +package hookinfolder_test + +import ( + "net/http" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/framework" + "github.com/deckhouse/module-sdk/testing/mock" + + subfolder "dependency-example-module/subfolder" +) + +// TestHandlerHTTPClient_FrameworkLevel exercises the HTTP-client hook +// through testing/framework. The framework's DependencyContainer can be +// reconfigured before RunHook to inject a custom HTTPClient, which is +// what we do here. +func TestHandlerHTTPClient_FrameworkLevel(t *testing.T) { + calls := atomic.Int32{} + + httpClient := mock.NewHTTPClientMock(t) + httpClient.DoMock.Set(func(req *http.Request) (*http.Response, error) { + calls.Add(1) + assert.Equal(t, "http://127.0.0.1", req.URL.String()) + return &http.Response{}, nil + }) + + f := framework.HookExecutionConfigInit(t, + &pkg.HookConfig{}, + subfolder.HandlerHTTPClient, + `{}`, `{}`, + ) + + // Override the default error-returning HTTP client with our mock. + f.DependencyContainer().SetHTTPClient(httpClient) + + f.RunHook() + + require.NoError(t, f.HookError()) + assert.Equal(t, int32(1), calls.Load(), "expected the hook to issue exactly one HTTP request") +} diff --git a/examples/dependency-example-module/hooks/subfolder/http_client_hook_test.go b/examples/dependency-example-module/hooks/subfolder/http_client_hook_test.go index cdc9baf3..14de43f5 100644 --- a/examples/dependency-example-module/hooks/subfolder/http_client_hook_test.go +++ b/examples/dependency-example-module/hooks/subfolder/http_client_hook_test.go @@ -3,80 +3,68 @@ package hookinfolder_test import ( "context" "errors" - "fmt" "net/http" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - - "github.com/deckhouse/deckhouse/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/helpers" "github.com/deckhouse/module-sdk/testing/mock" subfolder "dependency-example-module/subfolder" ) -var _ = Describe("http client hook example", func() { - Context("refoncile func", func() { - When("all services works correctly", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - dc.GetHTTPClientMock.Set(func(_ ...pkg.HTTPOption) pkg.HTTPClient { - return mock.NewHTTPClientMock(GinkgoT()).DoMock.Set(func(req *http.Request) (*http.Response, error) { - Expect(req.Method).Should(Equal(http.MethodGet)) - Expect(req.URL.String()).Should(Equal("http://127.0.0.1")) - - return &http.Response{}, nil - }) - }) +// httpDC builds a DependencyContainerMock whose GetHTTPClient returns a +// minimock-controlled HTTPClient with the supplied Do behaviour. +func httpDC(t *testing.T, doFn func(*http.Request) (*http.Response, error)) pkg.DependencyContainer { + t.Helper() + dc := mock.NewDependencyContainerMock(t) + dc.GetHTTPClientMock.Set(func(_ ...pkg.HTTPOption) pkg.HTTPClient { + c := mock.NewHTTPClientMock(t) + if doFn != nil { + c.DoMock.Set(doFn) + } + return c + }) + return dc +} - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } +func TestHandlerHTTPClient_HappyPath(t *testing.T) { + dc := httpDC(t, func(req *http.Request) (*http.Response, error) { + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "http://127.0.0.1", req.URL.String()) + return &http.Response{}, nil + }) - It("reconcile func executed correctly", func() { - err := subfolder.HandlerHTTPClient(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) + in := helpers.NewInputBuilder(t).WithDependencyContainer(dc).Build() + require.NoError(t, subfolder.HandlerHTTPClient(context.Background(), in)) +} - When("http client receive error", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - dc.GetHTTPClientMock.Set(func(_ ...pkg.HTTPOption) pkg.HTTPClient { - return mock.NewHTTPClientMock(GinkgoT()) - }) +func TestHandlerHTTPClient_NilContextRejected(t *testing.T) { + // We never expect Do to be called; httpDC builds a mock with no Do + // expectation, so a stray call would fail the test automatically. + dc := httpDC(t, nil) - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } + in := helpers.NewInputBuilder(t).WithDependencyContainer(dc).Build() - It("error has occurred", func() { - err := subfolder.HandlerHTTPClient(nil, input) //nolint:staticcheck - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("new request: %w", errors.New("net/http: nil Context")))) - }) - }) + //nolint:staticcheck // intentionally passing nil context to exercise net/http error + err := subfolder.HandlerHTTPClient(nil, in) + require.Error(t, err) + assert.ErrorContains(t, err, "new request:") +} - When("http client receive error", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - dc.GetHTTPClientMock.Set(func(_ ...pkg.HTTPOption) pkg.HTTPClient { - return mock.NewHTTPClientMock(GinkgoT()).DoMock.Set(func(_ *http.Request) (*http.Response, error) { - return &http.Response{}, errors.New("error") - }) - }) +func TestHandlerHTTPClient_ClientReturnsError(t *testing.T) { + wantErr := errors.New("boom") + dc := httpDC(t, func(_ *http.Request) (*http.Response, error) { + return &http.Response{}, wantErr + }) - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } + in := helpers.NewInputBuilder(t).WithDependencyContainer(dc).Build() - It("error has occurred", func() { - err := subfolder.HandlerHTTPClient(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("do request: %w", errors.New("error")))) - }) - }) - }) -}) + err := subfolder.HandlerHTTPClient(context.Background(), in) + require.Error(t, err) + assert.ErrorIs(t, err, wantErr) + assert.ErrorContains(t, err, "do request:") +} diff --git a/examples/dependency-example-module/hooks/subfolder/k8s_client_hook_test.go b/examples/dependency-example-module/hooks/subfolder/k8s_client_hook_test.go index a9c020a0..9f9945e7 100644 --- a/examples/dependency-example-module/hooks/subfolder/k8s_client_hook_test.go +++ b/examples/dependency-example-module/hooks/subfolder/k8s_client_hook_test.go @@ -3,65 +3,53 @@ package hookinfolder_test import ( "context" "errors" - "fmt" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/deckhouse/deckhouse/pkg/log" - "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/helpers" "github.com/deckhouse/module-sdk/testing/mock" subfolder "dependency-example-module/subfolder" ) -var _ = Describe("k8s client hook example", func() { - Context("refoncile func", func() { - When("all services works correctly", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - dc.MustGetK8sClientMock.Set(func(_ ...pkg.KubernetesOption) pkg.KubernetesClient { - return mock.NewKubernetesClientMock(GinkgoT()).GetMock.Set(func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { - pod := obj.(*corev1.Pod) - - pod.Name = "found-pod" - pod.Namespace = "found-ns" - - return nil - }) - }) - - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } +// k8sDC builds a DependencyContainerMock whose MustGetK8sClient returns +// a KubernetesClient with the provided Get behaviour. +func k8sDC(t *testing.T, getFn func(context.Context, client.ObjectKey, client.Object, ...client.GetOption) error) pkg.DependencyContainer { + t.Helper() + dc := mock.NewDependencyContainerMock(t) + dc.MustGetK8sClientMock.Set(func(_ ...pkg.KubernetesOption) pkg.KubernetesClient { + return mock.NewKubernetesClientMock(t).GetMock.Set(getFn) + }) + return dc +} + +func TestHandlerKubernetesClient_HappyPath(t *testing.T) { + dc := k8sDC(t, func(_ context.Context, _ client.ObjectKey, obj client.Object, _ ...client.GetOption) error { + pod := obj.(*corev1.Pod) + pod.Name = "found-pod" + pod.Namespace = "found-ns" + return nil + }) - It("error has occurred", func() { - err := subfolder.HandlerKubernetesClient(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) + in := helpers.NewInputBuilder(t).WithDependencyContainer(dc).Build() + require.NoError(t, subfolder.HandlerKubernetesClient(context.Background(), in)) +} - When("kubernetes client has an error", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - dc.MustGetK8sClientMock.Set(func(_ ...pkg.KubernetesOption) pkg.KubernetesClient { - return mock.NewKubernetesClientMock(GinkgoT()).GetMock.Set(func(_ context.Context, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error { - return errors.New("error") - }) - }) +func TestHandlerKubernetesClient_ReturnsWrappedError(t *testing.T) { + wantErr := errors.New("boom") + dc := k8sDC(t, func(_ context.Context, _ client.ObjectKey, _ client.Object, _ ...client.GetOption) error { + return wantErr + }) - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } + in := helpers.NewInputBuilder(t).WithDependencyContainer(dc).Build() - It("error has occurred", func() { - err := subfolder.HandlerKubernetesClient(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("get pod: %w", errors.New("error")))) - }) - }) - }) -}) + err := subfolder.HandlerKubernetesClient(context.Background(), in) + require.Error(t, err) + assert.ErrorIs(t, err, wantErr) + assert.ErrorContains(t, err, "get pod:") +} diff --git a/examples/dependency-example-module/hooks/subfolder/registry_client_hook_test.go b/examples/dependency-example-module/hooks/subfolder/registry_client_hook_test.go index ca8def15..77018414 100644 --- a/examples/dependency-example-module/hooks/subfolder/registry_client_hook_test.go +++ b/examples/dependency-example-module/hooks/subfolder/registry_client_hook_test.go @@ -3,16 +3,15 @@ package hookinfolder_test import ( "context" "errors" - "fmt" + "testing" "github.com/gojuno/minimock/v3" v1 "github.com/google/go-containerregistry/pkg/v1" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - - "github.com/deckhouse/deckhouse/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/helpers" "github.com/deckhouse/module-sdk/testing/mock" subfolder "dependency-example-module/subfolder" @@ -23,175 +22,104 @@ const ( secondTag = "v2.0.0" ) -var _ = Describe("registry client hook example", func() { - Context("refoncile func", func() { - When("all services works correctly", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - - regClient := mock.NewRegistryClientMock(GinkgoT()) - regClient.ListTagsMock.Set(func(_ context.Context) ([]string, error) { - return []string{ - firstTag, secondTag, - }, nil - }) - - regClient.ImageMock.When(minimock.AnyContext, firstTag). - Then(mock.NewRegistryImageMock(GinkgoT()).ConfigNameMock.Expect(). - Return(v1.Hash{Algorithm: "sha256", Hex: "abcdef1"}, nil), nil) - regClient.DigestMock.When(minimock.AnyContext, firstTag). - Then("first digest", nil) - - regClient.ImageMock.When(minimock.AnyContext, secondTag). - Then(mock.NewRegistryImageMock(GinkgoT()).ConfigNameMock.Expect(). - Return(v1.Hash{Algorithm: "sha256", Hex: "abcdef2"}, nil), nil) - regClient.DigestMock.When(minimock.AnyContext, secondTag). - Then("second digest", nil) - - dc.MustGetRegistryClientMock.When(subfolder.RegistryAddress). - Then(regClient) - - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } - - It("reconcile func executed correctly", func() { - err := subfolder.HandlerRegistryClient(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) - - When("no tags listed", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - - regClient := mock.NewRegistryClientMock(GinkgoT()) - regClient.ListTagsMock.Set(func(_ context.Context) ([]string, error) { - return []string{}, nil - }) - - dc.MustGetRegistryClientMock.When(subfolder.RegistryAddress). - Then(regClient) - - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } - - It("reconcile func executed correctly", func() { - err := subfolder.HandlerRegistryClient(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) - - When("list tags error", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - - regClient := mock.NewRegistryClientMock(GinkgoT()) - regClient.ListTagsMock.Set(func(_ context.Context) ([]string, error) { - return nil, errors.New("error") - }) - - dc.MustGetRegistryClientMock.When(subfolder.RegistryAddress). - Then(regClient) - - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } - - It("error has occurred", func() { - err := subfolder.HandlerRegistryClient(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("list tags: %w", errors.New("error")))) - }) - }) - - When("getting image errror", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - - regClient := mock.NewRegistryClientMock(GinkgoT()) - regClient.ListTagsMock.Set(func(_ context.Context) ([]string, error) { - return []string{ - firstTag, secondTag, - }, nil - }) - - regClient.ImageMock.When(minimock.AnyContext, firstTag). - Then(nil, errors.New("error")) - - dc.MustGetRegistryClientMock.When(subfolder.RegistryAddress). - Then(regClient) - - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } - - It("error has occurred", func() { - err := subfolder.HandlerRegistryClient(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("image: %w", errors.New("error")))) - }) - }) - - When("config name error", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - - regClient := mock.NewRegistryClientMock(GinkgoT()) - regClient.ListTagsMock.Set(func(_ context.Context) ([]string, error) { - return []string{ - firstTag, secondTag, - }, nil - }) - - regClient.ImageMock.When(minimock.AnyContext, firstTag). - Then(mock.NewRegistryImageMock(GinkgoT()).ConfigNameMock.Expect(). - Return(v1.Hash{}, errors.New("error")), nil) - - dc.MustGetRegistryClientMock.When(subfolder.RegistryAddress). - Then(regClient) - - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } - - It("error has occurred", func() { - err := subfolder.HandlerRegistryClient(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("config name: %w", errors.New("error")))) - }) - }) - - When("get digest error", func() { - dc := mock.NewDependencyContainerMock(GinkgoT()) - - regClient := mock.NewRegistryClientMock(GinkgoT()) - regClient.ListTagsMock.Set(func(_ context.Context) ([]string, error) { - return []string{ - firstTag, secondTag, - }, nil - }) - - regClient.ImageMock.When(minimock.AnyContext, firstTag). - Then(mock.NewRegistryImageMock(GinkgoT()).ConfigNameMock.Expect(). - Return(v1.Hash{Algorithm: "sha256", Hex: "abcdef1"}, nil), nil) - regClient.DigestMock.When(minimock.AnyContext, firstTag). - Then("", errors.New("error")) - - dc.MustGetRegistryClientMock.When(subfolder.RegistryAddress). - Then(regClient) - - var input = &pkg.HookInput{ - DC: dc, - Logger: log.NewNop(), - } - - It("error has occurred", func() { - err := subfolder.HandlerRegistryClient(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("digest: %w", errors.New("error")))) - }) +// registryDC wires a DependencyContainerMock whose MustGetRegistryClient +// returns the provided RegistryClientMock for the configured registry +// address. The caller fully owns the mock (set up tags, image, digest, …). +func registryDC(t *testing.T, regClient *mock.RegistryClientMock) pkg.DependencyContainer { + t.Helper() + dc := mock.NewDependencyContainerMock(t) + dc.MustGetRegistryClientMock.When(subfolder.RegistryAddress).Then(regClient) + return dc +} + +func TestHandlerRegistryClient_AllOK(t *testing.T) { + rc := mock.NewRegistryClientMock(t) + rc.ListTagsMock.Return([]string{firstTag, secondTag}, nil) + + rc.ImageMock.When(minimock.AnyContext, firstTag). + Then(mock.NewRegistryImageMock(t).ConfigNameMock.Expect(). + Return(v1.Hash{Algorithm: "sha256", Hex: "abcdef1"}, nil), nil) + rc.ImageMock.When(minimock.AnyContext, secondTag). + Then(mock.NewRegistryImageMock(t).ConfigNameMock.Expect(). + Return(v1.Hash{Algorithm: "sha256", Hex: "abcdef2"}, nil), nil) + + rc.DigestMock.When(minimock.AnyContext, firstTag).Then("first digest", nil) + rc.DigestMock.When(minimock.AnyContext, secondTag).Then("second digest", nil) + + in := helpers.NewInputBuilder(t). + WithDependencyContainer(registryDC(t, rc)). + Build() + + require.NoError(t, subfolder.HandlerRegistryClient(context.Background(), in)) +} + +func TestHandlerRegistryClient_NoTagsOK(t *testing.T) { + rc := mock.NewRegistryClientMock(t) + rc.ListTagsMock.Return([]string{}, nil) + + in := helpers.NewInputBuilder(t). + WithDependencyContainer(registryDC(t, rc)). + Build() + + require.NoError(t, subfolder.HandlerRegistryClient(context.Background(), in)) +} + +func TestHandlerRegistryClient_FailureCases(t *testing.T) { + cases := []struct { + name string + setup func(*mock.RegistryClientMock) + wantInMsg string + }{ + { + name: "list tags error", + setup: func(rc *mock.RegistryClientMock) { + rc.ListTagsMock.Return(nil, errors.New("boom")) + }, + wantInMsg: "list tags:", + }, + { + name: "image error", + setup: func(rc *mock.RegistryClientMock) { + rc.ListTagsMock.Return([]string{firstTag, secondTag}, nil) + rc.ImageMock.When(minimock.AnyContext, firstTag).Then(nil, errors.New("boom")) + }, + wantInMsg: "image:", + }, + { + name: "config name error", + setup: func(rc *mock.RegistryClientMock) { + rc.ListTagsMock.Return([]string{firstTag, secondTag}, nil) + rc.ImageMock.When(minimock.AnyContext, firstTag). + Then(mock.NewRegistryImageMock(t).ConfigNameMock.Expect(). + Return(v1.Hash{}, errors.New("boom")), nil) + }, + wantInMsg: "config name:", + }, + { + name: "digest error", + setup: func(rc *mock.RegistryClientMock) { + rc.ListTagsMock.Return([]string{firstTag, secondTag}, nil) + rc.ImageMock.When(minimock.AnyContext, firstTag). + Then(mock.NewRegistryImageMock(t).ConfigNameMock.Expect(). + Return(v1.Hash{Algorithm: "sha256", Hex: "abcdef1"}, nil), nil) + rc.DigestMock.When(minimock.AnyContext, firstTag).Then("", errors.New("boom")) + }, + wantInMsg: "digest:", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rc := mock.NewRegistryClientMock(t) + tc.setup(rc) + + in := helpers.NewInputBuilder(t). + WithDependencyContainer(registryDC(t, rc)). + Build() + + err := subfolder.HandlerRegistryClient(context.Background(), in) + require.Error(t, err) + assert.ErrorContains(t, err, tc.wantInMsg) }) - }) -}) + } +} diff --git a/examples/dependency-example-module/hooks/subfolder/suite_test.go b/examples/dependency-example-module/hooks/subfolder/suite_test.go deleted file mode 100644 index fff2f8de..00000000 --- a/examples/dependency-example-module/hooks/subfolder/suite_test.go +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2021 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package hookinfolder_test - -import ( - "testing" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -func Test_Suite(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "") -} diff --git a/examples/example-module/hooks/go.mod b/examples/example-module/hooks/go.mod index 59dbcfed..e64474f3 100644 --- a/examples/example-module/hooks/go.mod +++ b/examples/example-module/hooks/go.mod @@ -5,8 +5,7 @@ go 1.25.8 require ( github.com/deckhouse/deckhouse/pkg/log v0.2.0 github.com/deckhouse/module-sdk v0.0.0 - github.com/onsi/ginkgo v1.16.5 - github.com/onsi/gomega v1.36.1 + github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 k8s.io/api v0.33.8 k8s.io/apimachinery v0.33.8 @@ -39,6 +38,8 @@ require ( github.com/google/go-containerregistry v0.20.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.17 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -48,7 +49,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nxadm/tail v1.4.8 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -76,7 +76,6 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.33.8 // indirect k8s.io/client-go v0.33.8 // indirect diff --git a/examples/example-module/hooks/go.sum b/examples/example-module/hooks/go.sum index 1bab21e3..1e5c0cc3 100644 --- a/examples/example-module/hooks/go.sum +++ b/examples/example-module/hooks/go.sum @@ -30,8 +30,6 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -48,28 +46,16 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gojuno/minimock/v3 v3.4.7 h1:vhE5zpniyPDRT0DXd5s3DbtZJVlcbmC5k80izYtj9lY= github.com/gojuno/minimock/v3 v3.4.7/go.mod h1:QxJk4mdPrVyYUmEZGc2yD2NONpqM/j4dWhsy9twjFHg= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -82,9 +68,12 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -115,17 +104,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -164,7 +144,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -198,31 +177,22 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -237,7 +207,6 @@ golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= @@ -247,12 +216,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -260,14 +223,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/example-module/hooks/subfolder/config_test.go b/examples/example-module/hooks/subfolder/config_test.go index 7afbdb13..2a672c1b 100644 --- a/examples/example-module/hooks/subfolder/config_test.go +++ b/examples/example-module/hooks/subfolder/config_test.go @@ -1,17 +1,15 @@ package hookinfolder_test import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "testing" + + "github.com/stretchr/testify/assert" "github.com/deckhouse/module-sdk/pkg/registry" ) -var _ = Describe("validate hooks config", func() { - It("hook configs must be valid", func() { - hooks := registry.Registry().ModuleHooks() - for _, hook := range hooks { - Expect(hook.Config.Validate()).ShouldNot(HaveOccurred()) - } - }) -}) +func TestRegisteredHookConfigs_AreValid(t *testing.T) { + for _, hook := range registry.Registry().ModuleHooks() { + assert.NoError(t, hook.Config.Validate(), "hook config must be valid") + } +} diff --git a/examples/example-module/hooks/subfolder/metrics_collector_getting_hook_test.go b/examples/example-module/hooks/subfolder/metrics_collector_getting_hook_test.go index 26fd576a..881a299c 100644 --- a/examples/example-module/hooks/subfolder/metrics_collector_getting_hook_test.go +++ b/examples/example-module/hooks/subfolder/metrics_collector_getting_hook_test.go @@ -2,47 +2,64 @@ package hookinfolder_test import ( "context" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - - "github.com/deckhouse/deckhouse/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/helpers" "github.com/deckhouse/module-sdk/testing/mock" subfolder "example-module/subfolder" ) -var _ = Describe("metrics collector example", func() { - collector := mock.NewMetricsCollectorMock(GinkgoT()) +// recorder collects metrics emitted by the hook so the test can assert +// on the parameters of every call. +type recorder struct { + addCalls []metricCall + setCalls []metricCall + incCalls []metricCall +} - collector.AddMock.Set(func(name string, value float64, labels map[string]string, _ ...pkg.MetricCollectorOption) { - Expect(name).Should(Equal("stub-add-metric")) - Expect(value).Should(Equal(float64(1))) - Expect(labels).Should(Equal(map[string]string{"node_found": "node_name"})) - }) +type metricCall struct { + name string + value float64 + labels map[string]string +} - collector.SetMock.Set(func(name string, value float64, labels map[string]string, _ ...pkg.MetricCollectorOption) { - Expect(name).Should(Equal("stub-set-metric")) - Expect(value).Should(Equal(float64(1))) - Expect(labels).Should(Equal(map[string]string{"node_found": "node_name"})) - }) +func newMetricsRecorder(t *testing.T) (pkg.MetricsCollector, *recorder) { + r := &recorder{} + m := mock.NewMetricsCollectorMock(t) - collector.IncMock.Set(func(name string, labels map[string]string, _ ...pkg.MetricCollectorOption) { - Expect(name).Should(Equal("stub-inc-metric")) - Expect(labels).Should(Equal(map[string]string{"node_found": "node_name"})) + m.AddMock.Set(func(name string, value float64, labels map[string]string, _ ...pkg.MetricCollectorOption) { + r.addCalls = append(r.addCalls, metricCall{name: name, value: value, labels: labels}) + }) + m.SetMock.Set(func(name string, value float64, labels map[string]string, _ ...pkg.MetricCollectorOption) { + r.setCalls = append(r.setCalls, metricCall{name: name, value: value, labels: labels}) + }) + m.IncMock.Set(func(name string, labels map[string]string, _ ...pkg.MetricCollectorOption) { + r.incCalls = append(r.incCalls, metricCall{name: name, labels: labels}) }) - var input = &pkg.HookInput{ - MetricsCollector: collector, - Logger: log.NewNop(), - } + return m, r +} - Context("refoncile func", func() { - It("reconcile func executed correctly", func() { - err := subfolder.HandlerHookMetricsCollector(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) -}) +func TestHandlerHookMetricsCollector_EmitsAllThreeMetrics(t *testing.T) { + collector, rec := newMetricsRecorder(t) + + in := helpers.NewInputBuilder(t). + WithMetricsCollector(collector). + Build() + + require.NoError(t, subfolder.HandlerHookMetricsCollector(context.Background(), in)) + + require.Len(t, rec.addCalls, 1) + assert.Equal(t, metricCall{name: "stub-add-metric", value: 1, labels: map[string]string{"node_found": "node_name"}}, rec.addCalls[0]) + + require.Len(t, rec.setCalls, 1) + assert.Equal(t, metricCall{name: "stub-set-metric", value: 1, labels: map[string]string{"node_found": "node_name"}}, rec.setCalls[0]) + + require.Len(t, rec.incCalls, 1) + assert.Equal(t, metricCall{name: "stub-inc-metric", labels: map[string]string{"node_found": "node_name"}}, rec.incCalls[0]) +} diff --git a/examples/example-module/hooks/subfolder/patch_framework_test.go b/examples/example-module/hooks/subfolder/patch_framework_test.go new file mode 100644 index 00000000..3d13e5e2 --- /dev/null +++ b/examples/example-module/hooks/subfolder/patch_framework_test.go @@ -0,0 +1,49 @@ +package hookinfolder_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/framework" + + subfolder "example-module/subfolder" +) + +// TestHandlerHookPatch_AppliesPatchesToFakeCluster runs the patch hook +// inside the framework. After RunHook() the framework replays every +// recorded patch operation against its fake cluster, so we can assert on +// the resulting cluster state directly. +func TestHandlerHookPatch_AppliesPatchesToFakeCluster(t *testing.T) { + f := framework.HookExecutionConfigInit(t, + &pkg.HookConfig{OnBeforeHelm: &pkg.OrderedConfig{Order: 1}}, + subfolder.HandlerHookPatch, + `{}`, `{}`, + ) + f.RunHook() + + require.NoError(t, f.HookError()) + + // The hook calls Create + Delete on my-first-pod, so it should be gone. + first := f.KubernetesResource("Pod", "default", "my-first-pod") + assert.Nil(t, first, "my-first-pod should have been deleted after Create+Delete") + + // my-second-pod: CreateOrUpdate, then DeleteInBackground → also gone. + second := f.KubernetesResource("Pod", "default", "my-second-pod") + assert.Nil(t, second, "my-second-pod should have been deleted after Create+Delete") + + // my-third-pod: CreateIfNotExists, then DeleteNonCascading → gone too, + // but not before being patched. Verify the hook recorded those calls. + ops := f.PatchedOperations() + require.Len(t, ops, 7) + + var sawMerge bool + for _, op := range ops { + if op.Type == framework.PatchTypeMergePatch && op.Name == "my-third-pod" { + sawMerge = true + } + } + assert.True(t, sawMerge, "expected a MergePatch on my-third-pod") +} diff --git a/examples/example-module/hooks/subfolder/patch_hook_test.go b/examples/example-module/hooks/subfolder/patch_hook_test.go index 364f4758..3ffc43cb 100644 --- a/examples/example-module/hooks/subfolder/patch_hook_test.go +++ b/examples/example-module/hooks/subfolder/patch_hook_test.go @@ -1,120 +1,70 @@ package hookinfolder_test import ( - "bytes" "context" "strings" - "time" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" - "github.com/deckhouse/deckhouse/pkg/log" - - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/testing/mock" + "github.com/deckhouse/module-sdk/testing/helpers" subfolder "example-module/subfolder" ) -var _ = Describe("patch hook", func() { - Context("HandlerHookPatch function", func() { - var ( - patchCollector *mock.PatchCollectorMock - buf *bytes.Buffer - input *pkg.HookInput - ) - - BeforeEach(func() { - patchCollector = mock.NewPatchCollectorMock(GinkgoT()) - buf = bytes.NewBuffer([]byte{}) - - input = &pkg.HookInput{ - PatchCollector: patchCollector, - Logger: log.NewLogger( - log.WithLevel(log.LevelDebug.Level()), - log.WithOutput(buf), - log.WithTimeFunc(func(_ time.Time) time.Time { - parsedTime, err := time.Parse(time.DateTime, "2006-01-02 15:04:05") - Expect(err).ShouldNot(HaveOccurred()) - return parsedTime - }), - ), - } - }) - - It("logs hello message and executes patch collector operations", func() { - // Set expectations for Create - patchCollector.CreateMock.Set(func(obj any) { - pod, ok := obj.(*corev1.Pod) - Expect(ok).To(BeTrue()) - Expect(pod.Name).To(Equal("my-first-pod")) - Expect(pod.Namespace).To(Equal("default")) - Expect(pod.Status.Phase).To(Equal(corev1.PodRunning)) - }) - - // Set expectations for CreateOrUpdate - patchCollector.CreateOrUpdateMock.Set(func(obj any) { - pod, ok := obj.(*corev1.Pod) - Expect(ok).To(BeTrue()) - Expect(pod.Name).To(Equal("my-second-pod")) - Expect(pod.Namespace).To(Equal("default")) - Expect(pod.Status.Phase).To(Equal(corev1.PodRunning)) - }) - - // Set expectations for CreateIfNotExists - patchCollector.CreateIfNotExistsMock.Set(func(obj any) { - pod, ok := obj.(*corev1.Pod) - Expect(ok).To(BeTrue()) - Expect(pod.Name).To(Equal("my-third-pod")) - Expect(pod.Namespace).To(Equal("default")) - Expect(pod.Status.Phase).To(Equal(corev1.PodRunning)) - }) - - // Set expectations for Delete - patchCollector.DeleteMock.Set(func(apiVersion, kind, namespace, name string) { - Expect(apiVersion).To(Equal("v1")) - Expect(kind).To(Equal("Pod")) - Expect(namespace).To(Equal("default")) - Expect(name).To(Equal("my-first-pod")) - }) - - // Set expectations for DeleteInBackground - patchCollector.DeleteInBackgroundMock.Set(func(apiVersion, kind, namespace, name string) { - Expect(apiVersion).To(Equal("v1")) - Expect(kind).To(Equal("Pod")) - Expect(namespace).To(Equal("default")) - Expect(name).To(Equal("my-second-pod")) - }) - - // Set expectations for DeleteNonCascading - patchCollector.DeleteNonCascadingMock.Set(func(apiVersion, kind, namespace, name string) { - Expect(apiVersion).To(Equal("v1")) - Expect(kind).To(Equal("Pod")) - Expect(namespace).To(Equal("default")) - Expect(name).To(Equal("my-third-pod")) - }) - - // Set expectations for PatchWithMerge - patchCollector.PatchWithMergeMock.Set(func(mergePatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { - patchMap, ok := mergePatch.(map[string]any) - Expect(ok).To(BeTrue()) - Expect(patchMap).To(HaveKeyWithValue("/status", "newStatus")) - Expect(apiVersion).To(Equal("v1")) - Expect(kind).To(Equal("Pod")) - Expect(namespace).To(Equal("default")) - Expect(name).To(Equal("my-third-pod")) - Expect(len(opts)).To(Equal(2)) - }) - - // Execute the handler function - err := subfolder.HandlerHookPatch(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - - // Verify log messages - logs := strings.Split(buf.String(), "\n") - Expect(logs[0]).To(ContainSubstring(`"level":"info","msg":"hello from patch hook"`)) - }) - }) -}) +// TestHandlerHookPatch_RecordsExpectedOperations verifies that the patch +// hook issues the full sequence of Create/Delete/Patch operations against +// its PatchCollector. +// +// The RecordingPatchCollector lets us assert on each call without the +// minimock boilerplate the original Ginkgo test required. +func TestHandlerHookPatch_RecordsExpectedOperations(t *testing.T) { + b := helpers.NewInputBuilder(t). + WithRecordingPatchCollector(). + WithCapturedLogger() + + require.NoError(t, subfolder.HandlerHookPatch(context.Background(), b.Build())) + + ops := b.RecordingPatchCollector().Recorded() + require.Len(t, ops, 7, "expected 3 creates, 3 deletes and 1 merge patch") + + // Creates + assert.Equal(t, "Create", ops[0].Op) + assert.Equal(t, "my-first-pod", ops[0].Object.(*corev1.Pod).Name) + + assert.Equal(t, "CreateOrUpdate", ops[1].Op) + assert.Equal(t, "my-second-pod", ops[1].Object.(*corev1.Pod).Name) + + assert.Equal(t, "CreateIfNotExists", ops[2].Op) + assert.Equal(t, "my-third-pod", ops[2].Object.(*corev1.Pod).Name) + + // Deletes + for i, expected := range []struct { + op string + name string + }{ + {"Delete", "my-first-pod"}, + {"DeleteInBackground", "my-second-pod"}, + {"DeleteNonCascading", "my-third-pod"}, + } { + op := ops[3+i] + assert.Equal(t, expected.op, op.Op) + assert.Equal(t, "v1", op.APIVersion) + assert.Equal(t, "Pod", op.Kind) + assert.Equal(t, "default", op.Namespace) + assert.Equal(t, expected.name, op.Name) + } + + // Merge patch with options + mp := ops[6] + assert.Equal(t, "MergePatch", mp.Op) + assert.Equal(t, "my-third-pod", mp.Name) + assert.Equal(t, map[string]any{"/status": "newStatus"}, mp.Patch) + assert.Len(t, mp.Options, 2, "WithSubresource + WithIgnoreMissingObject expected") + + // Logger asserts + logs := strings.Split(b.LogBuffer().String(), "\n") + assert.Contains(t, logs[0], `"msg":"hello from patch hook"`) +} diff --git a/examples/example-module/hooks/subfolder/snapshot_framework_test.go b/examples/example-module/hooks/subfolder/snapshot_framework_test.go new file mode 100644 index 00000000..9bbb8ab9 --- /dev/null +++ b/examples/example-module/hooks/subfolder/snapshot_framework_test.go @@ -0,0 +1,99 @@ +package hookinfolder_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/framework" + + subfolder "example-module/subfolder" +) + +// snapshotHookConfig matches the configuration registered by +// snapshot_getting_hook_alternative.go. It is duplicated here so the +// functional test does not depend on the global registry (which is +// shared across the package and would mix in unrelated bindings). +var snapshotHookConfig = &pkg.HookConfig{ + OnBeforeHelm: &pkg.OrderedConfig{Order: 1}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: subfolder.NodeInfoSnapshotName, + APIVersion: "v1", + Kind: "Node", + JqFilter: `{ + "apiVersion": .apiVersion, + "kind": .kind, + "metadata": { + "name": .metadata.name, + "resourceVersion": .metadata.resourceVersion, + "uid": .metadata.uid + } + }`, + }, + }, +} + +// TestSnapshotsAlt_ReadsNodesFromCluster drives the alternative snapshot +// hook against a real fake cluster: two Nodes are seeded, the framework +// generates the snapshots from the binding spec, and the hook is asserted +// to log a "node found" entry per Node. +func TestSnapshotsAlt_ReadsNodesFromCluster(t *testing.T) { + const state = ` +--- +apiVersion: v1 +kind: Node +metadata: + name: node-a + uid: "uid-a" + resourceVersion: "10" +--- +apiVersion: v1 +kind: Node +metadata: + name: node-b + uid: "uid-b" + resourceVersion: "20" +` + + f := framework.HookExecutionConfigInit(t, + snapshotHookConfig, + subfolder.HandlerHookSnapshotsAlt, + `{}`, `{}`, + ) + f.KubeStateSet(state) + f.RunHook() + + require.NoError(t, f.HookError()) + + snaps := f.Snapshots().Get(subfolder.NodeInfoSnapshotName) + require.Len(t, snaps, 2) + + logs := f.LoggerOutput().String() + assert.Contains(t, logs, "hello from snapshot alt hook") + for _, name := range []string{"node-a", "node-b"} { + assert.Contains(t, logs, name, "expected node %q to appear in logs", name) + } + + // And no extra log lines beyond the hook's expected output. + lineCount := strings.Count(logs, "\n") + assert.GreaterOrEqual(t, lineCount, 3, "expected at least 3 log lines (1 greeting + 2 nodes)") +} + +// TestSnapshotsAlt_NoNodesNoLogs checks the empty-state path. The hook is +// well-behaved and emits only the greeting line when there are no nodes. +func TestSnapshotsAlt_NoNodesNoLogs(t *testing.T) { + f := framework.HookExecutionConfigInit(t, + snapshotHookConfig, + subfolder.HandlerHookSnapshotsAlt, + `{}`, `{}`, + ) + f.RunHook() + + require.NoError(t, f.HookError()) + require.Empty(t, f.Snapshots().Get(subfolder.NodeInfoSnapshotName)) + assert.Contains(t, f.LoggerOutput().String(), "hello from snapshot alt hook") +} diff --git a/examples/example-module/hooks/subfolder/snapshot_getting_hook_alternative_test.go b/examples/example-module/hooks/subfolder/snapshot_getting_hook_alternative_test.go index e7a77718..0623d6c1 100644 --- a/examples/example-module/hooks/subfolder/snapshot_getting_hook_alternative_test.go +++ b/examples/example-module/hooks/subfolder/snapshot_getting_hook_alternative_test.go @@ -1,124 +1,47 @@ package hookinfolder_test import ( - "bytes" "context" "errors" - "fmt" "strings" - "time" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - "github.com/deckhouse/deckhouse/pkg/log" - - "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/helpers" "github.com/deckhouse/module-sdk/testing/mock" subfolder "example-module/subfolder" ) -var _ = Describe("snapshot alternative example", func() { - Context("refoncile func", func() { - When("all services works correctly", func() { - snapshots := mock.NewSnapshotsMock(GinkgoT()) - snapshots.GetMock.When(subfolder.NodeInfoSnapshotName).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - node := v.(*subfolder.NodeInfo) - *node = subfolder.NodeInfo{ - APIVersion: "v1", - Kind: "node", - Metadata: subfolder.NodeInfoMetadata{ - Name: "first-node", - ResourceVersion: "v1", - UID: "1", - }, - } - - return nil - }), - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - node := v.(*subfolder.NodeInfo) - *node = subfolder.NodeInfo{ - APIVersion: "v1", - Kind: "node", - Metadata: subfolder.NodeInfoMetadata{ - Name: "second-node", - ResourceVersion: "v1", - UID: "2", - }, - } - - return nil - }), - }, - ) - - buf := bytes.NewBuffer([]byte{}) - - var input = &pkg.HookInput{ - Snapshots: snapshots, - Logger: log.NewLogger( - log.WithLevel(log.LevelDebug.Level()), - log.WithOutput(buf), - log.WithTimeFunc(func(_ time.Time) time.Time { - parsedTime, err := time.Parse(time.DateTime, "2006-01-02 15:04:05") - Expect(err).ShouldNot(HaveOccurred()) - return parsedTime - }), - ), - } - - It("reconcile func executed correctly", func() { - err := subfolder.HandlerHookSnapshotsAlt(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - - logs := strings.Split(buf.String(), "\n") - - Expect(logs[0]).Should(ContainSubstring(`"level":"info","msg":"hello from snapshot alt hook"`)) - Expect(logs[1]).Should(ContainSubstring(`"level":"info","msg":"node found"`)) - Expect(logs[1]).Should(ContainSubstring(`"APIVersion":"v1","Kind":"node","Name":"first-node","ResourceVersion":"v1","UID":"1"`)) - Expect(logs[2]).Should(ContainSubstring(`"level":"info","msg":"node found"`)) - Expect(logs[2]).Should(ContainSubstring(`"APIVersion":"v1","Kind":"node","Name":"second-node","ResourceVersion":"v1","UID":"2"`)) - }) - }) - - When("unmarshal get error", func() { - snapshots := mock.NewSnapshotsMock(GinkgoT()) - snapshots.GetMock.When(subfolder.NodeInfoSnapshotName).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(_ any) error { - return errors.New("error") - }), - }, - ) - - buf := bytes.NewBuffer([]byte{}) - - var input = &pkg.HookInput{ - Snapshots: snapshots, - Logger: log.NewLogger( - log.WithLevel(log.LevelDebug.Level()), - log.WithOutput(buf), - log.WithTimeFunc(func(_ time.Time) time.Time { - parsedTime, err := time.Parse(time.DateTime, "2006-01-02 15:04:05") - Expect(err).ShouldNot(HaveOccurred()) - return parsedTime - }), - ), - } - - It("unmarshal returns error", func() { - err := subfolder.HandlerHookSnapshotsAlt(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("unmarshal to struct: %w", fmt.Errorf("unmarshal to: %w", errors.New("error"))))) +func TestHandlerHookSnapshotsAlt_LogsAllNodes(t *testing.T) { + b := helpers.NewInputBuilder(t). + WithSnapshot(subfolder.NodeInfoSnapshotName, + helpers.SnapshotFromObject(nodeInfo("first-node", "1")), + helpers.SnapshotFromObject(nodeInfo("second-node", "2")), + ). + WithCapturedLogger() + + require.NoError(t, subfolder.HandlerHookSnapshotsAlt(context.Background(), b.Build())) + + logs := strings.Split(b.LogBuffer().String(), "\n") + require.GreaterOrEqual(t, len(logs), 3) + assert.Contains(t, logs[0], `"msg":"hello from snapshot alt hook"`) + assert.Contains(t, logs[1], `"Name":"first-node"`) + assert.Contains(t, logs[2], `"Name":"second-node"`) +} + +func TestHandlerHookSnapshotsAlt_PropagatesUnmarshalError(t *testing.T) { + failing := mock.NewSnapshotMock(t).UnmarshalToMock.Set(func(_ any) error { + return errors.New("boom") + }) - logs := strings.Split(buf.String(), "\n") + in := helpers.NewInputBuilder(t). + WithSnapshot(subfolder.NodeInfoSnapshotName, failing). + Build() - Expect(logs[0]).Should(ContainSubstring(`"level":"info","msg":"hello from snapshot alt hook"`)) - }) - }) - }) -}) + err := subfolder.HandlerHookSnapshotsAlt(context.Background(), in) + require.Error(t, err) + assert.ErrorContains(t, err, "boom") +} diff --git a/examples/example-module/hooks/subfolder/snapshot_getting_hook_test.go b/examples/example-module/hooks/subfolder/snapshot_getting_hook_test.go index da1ef3ff..6baa1d4b 100644 --- a/examples/example-module/hooks/subfolder/snapshot_getting_hook_test.go +++ b/examples/example-module/hooks/subfolder/snapshot_getting_hook_test.go @@ -1,124 +1,78 @@ package hookinfolder_test import ( - "bytes" "context" "errors" - "fmt" "strings" - "time" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - - "github.com/deckhouse/deckhouse/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/helpers" "github.com/deckhouse/module-sdk/testing/mock" subfolder "example-module/subfolder" ) -var _ = Describe("snapshot example", func() { - Context("refoncile func", func() { - When("all services works correctly", func() { - snapshots := mock.NewSnapshotsMock(GinkgoT()) - snapshots.GetMock.When(subfolder.NodeInfoSnapshotName).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - node := v.(*subfolder.NodeInfo) - *node = subfolder.NodeInfo{ - APIVersion: "v1", - Kind: "node", - Metadata: subfolder.NodeInfoMetadata{ - Name: "first-node", - ResourceVersion: "v1", - UID: "1", - }, - } - - return nil - }), - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - node := v.(*subfolder.NodeInfo) - *node = subfolder.NodeInfo{ - APIVersion: "v1", - Kind: "node", - Metadata: subfolder.NodeInfoMetadata{ - Name: "second-node", - ResourceVersion: "v1", - UID: "2", - }, - } - - return nil - }), - }, - ) - - buf := bytes.NewBuffer([]byte{}) - - var input = &pkg.HookInput{ - Snapshots: snapshots, - Logger: log.NewLogger( - log.WithLevel(log.LevelDebug.Level()), - log.WithOutput(buf), - log.WithTimeFunc(func(_ time.Time) time.Time { - parsedTime, err := time.Parse(time.DateTime, "2006-01-02 15:04:05") - Expect(err).ShouldNot(HaveOccurred()) - return parsedTime - }), - ), - } - - It("reconcile func executed correctly", func() { - err := subfolder.HandlerHookSnapshots(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - - logs := strings.Split(buf.String(), "\n") - - Expect(logs[0]).Should(ContainSubstring(`"level":"info","msg":"hello from snapshot hook"`)) - Expect(logs[1]).Should(ContainSubstring(`"level":"info","msg":"node found"`)) - Expect(logs[1]).Should(ContainSubstring(`"APIVersion":"v1","Kind":"node","Name":"first-node","ResourceVersion":"v1","UID":"1"`)) - Expect(logs[2]).Should(ContainSubstring(`"level":"info","msg":"node found"`)) - Expect(logs[2]).Should(ContainSubstring(`"APIVersion":"v1","Kind":"node","Name":"second-node","ResourceVersion":"v1","UID":"2"`)) - }) - }) +func nodeInfo(name, uid string) subfolder.NodeInfo { + return subfolder.NodeInfo{ + APIVersion: "v1", + Kind: "node", + Metadata: subfolder.NodeInfoMetadata{ + Name: name, + ResourceVersion: "v1", + UID: uid, + }, + } +} + +func TestHandlerHookSnapshots_LogsAllNodes(t *testing.T) { + b := helpers.NewInputBuilder(t). + WithSnapshot(subfolder.NodeInfoSnapshotName, + helpers.SnapshotFromObject(nodeInfo("first-node", "1")), + helpers.SnapshotFromObject(nodeInfo("second-node", "2")), + ). + WithCapturedLogger() + + require.NoError(t, subfolder.HandlerHookSnapshots(context.Background(), b.Build())) + + logs := strings.Split(b.LogBuffer().String(), "\n") + require.GreaterOrEqual(t, len(logs), 3, "expected hello + two node-found logs") + assert.Contains(t, logs[0], `"msg":"hello from snapshot hook"`) + assert.Contains(t, logs[1], `"msg":"node found"`) + assert.Contains(t, logs[1], `"Name":"first-node"`) + assert.Contains(t, logs[2], `"Name":"second-node"`) +} + +func TestHandlerHookSnapshots_PropagatesUnmarshalError(t *testing.T) { + // We still want to assert on the propagation of the unmarshal error. + // helpers.Snapshot* always unmarshals successfully (it's just JSON), so + // here we use the existing minimock-generated SnapshotMock to inject an + // error from UnmarshalTo. + failing := mock.NewSnapshotMock(t).UnmarshalToMock.Set(func(_ any) error { + return errors.New("boom") + }) - When("unmarshal get error", func() { - snapshots := mock.NewSnapshotsMock(GinkgoT()) - snapshots.GetMock.When(subfolder.NodeInfoSnapshotName).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(_ any) error { - return errors.New("error") - }), - }, - ) + in := helpers.NewInputBuilder(t). + WithSnapshot(subfolder.NodeInfoSnapshotName, failing). + Build() - buf := bytes.NewBuffer([]byte{}) + err := subfolder.HandlerHookSnapshots(context.Background(), in) + require.Error(t, err) + assert.ErrorContains(t, err, "boom") +} - var input = &pkg.HookInput{ - Snapshots: snapshots, - Logger: log.NewLogger( - log.WithLevel(log.LevelDebug.Level()), - log.WithOutput(buf), - log.WithTimeFunc(func(_ time.Time) time.Time { - parsedTime, err := time.Parse(time.DateTime, "2006-01-02 15:04:05") - Expect(err).ShouldNot(HaveOccurred()) - return parsedTime - }), - ), - } +func TestHandlerHookSnapshots_NoNodes(t *testing.T) { + in := helpers.NewInputBuilder(t).Build() - It("unmarshal returns error", func() { - err := subfolder.HandlerHookSnapshots(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(fmt.Errorf("unmarshal to: %w", errors.New("error")))) + require.NoError(t, subfolder.HandlerHookSnapshots(context.Background(), in)) - logs := strings.Split(buf.String(), "\n") + // Sanity: with no snapshots, the input still satisfies the contract. + assert.Empty(t, in.Snapshots.Get(subfolder.NodeInfoSnapshotName)) +} - Expect(logs[0]).Should(ContainSubstring(`"level":"info","msg":"hello from snapshot hook"`)) - }) - }) - }) -}) +// Compile-time assertion that the InputBuilder produces a real *pkg.HookInput, +// so the hook handler signature is exercised. +var _ pkg.HookFunc[*pkg.HookInput] = subfolder.HandlerHookSnapshots diff --git a/examples/example-module/hooks/subfolder/suite_test.go b/examples/example-module/hooks/subfolder/suite_test.go deleted file mode 100644 index fff2f8de..00000000 --- a/examples/example-module/hooks/subfolder/suite_test.go +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2021 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package hookinfolder_test - -import ( - "testing" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -func Test_Suite(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "") -} diff --git a/examples/example-module/hooks/subfolder/values_getting_hook_test.go b/examples/example-module/hooks/subfolder/values_getting_hook_test.go index bb783883..260329af 100644 --- a/examples/example-module/hooks/subfolder/values_getting_hook_test.go +++ b/examples/example-module/hooks/subfolder/values_getting_hook_test.go @@ -2,127 +2,110 @@ package hookinfolder_test import ( "context" - "encoding/json" "errors" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/tidwall/gjson" "github.com/deckhouse/deckhouse/pkg/log" "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/pkg/utils" + "github.com/deckhouse/module-sdk/testing/helpers" "github.com/deckhouse/module-sdk/testing/mock" subfolder "example-module/subfolder" ) -var _ = Describe("values example", func() { - Context("refoncile func", func() { - When("all services works correctly", func() { - values := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - values.GetMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}) - values.GetOkMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}, true) - values.GetPatchesMock.Return([]*utils.ValuesPatchOperation{ - { - Op: "add", - Path: "/some/path/to/field", - Value: json.RawMessage(`{"name":"some-module"}`), - }, - }) - values.GetRawMock.When("some.path.to.field.someInt").Then(float64(1)) - values.ExistsMock.When("some.path.to.field.str").Then(false) - values.SetMock.When("some.path.to.field.str", "some_string") - values.RemoveMock.When("some.path.to.field") - values.ArrayCountMock.When("some.path.to.field.array").Then(10, nil) - - var input = &pkg.HookInput{ - Values: values, - Logger: log.NewNop(), - } - - It("reconcile func executed correctly", func() { - err := subfolder.HandlerHookValues(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) - - When("get ok returns false", func() { - values := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - values.GetMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}) - values.GetOkMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}, false) - values.ExistsMock.When("some.path.to.field.str").Then(false) - values.SetMock.When("some.path.to.field.str", "some_string") - values.RemoveMock.When("some.path.to.field") - values.ArrayCountMock.When("some.path.to.field.array").Then(10, nil) - - var input = &pkg.HookInput{ - Values: values, - Logger: log.NewNop(), - } - - It("reconcile func executed correctly", func() { - err := subfolder.HandlerHookValues(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) - - When("get raw geturns not number", func() { - values := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - values.GetMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}) - values.GetOkMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}, true) - values.GetPatchesMock.Return([]*utils.ValuesPatchOperation{ - { - Op: "add", - Path: "/some/path/to/field", - Value: json.RawMessage(`{"name":"some-module"}`), - }, - }) - values.GetRawMock.When("some.path.to.field.someInt").Then("not number") - values.ExistsMock.When("some.path.to.field.str").Then(false) - values.SetMock.When("some.path.to.field.str", "some_string") - values.RemoveMock.When("some.path.to.field") - values.ArrayCountMock.When("some.path.to.field.array").Then(10, nil) - - var input = &pkg.HookInput{ - Values: values, - Logger: log.NewNop(), - } - - It("reconcile func executed correctly", func() { - err := subfolder.HandlerHookValues(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) - - When("array count returns error", func() { - values := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - values.GetMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}) - values.GetOkMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}, true) - values.GetPatchesMock.Return([]*utils.ValuesPatchOperation{ - { - Op: "add", - Path: "/some/path/to/field", - Value: json.RawMessage(`{"name":"some-module"}`), - }, - }) - values.GetRawMock.When("some.path.to.field.someInt").Then(float64(1)) - values.ExistsMock.When("some.path.to.field.str").Then(false) - values.SetMock.When("some.path.to.field.str", "some_string") - values.RemoveMock.When("some.path.to.field") - values.ArrayCountMock.When("some.path.to.field.array").Then(0, errors.New("error")) - - var input = &pkg.HookInput{ - Values: values, - Logger: log.NewNop(), - } - - It("reconcile func executed correctly", func() { - err := subfolder.HandlerHookValues(context.Background(), input) - Expect(err).Should(HaveOccurred()) - Expect(err).Should(Equal(errors.New("error"))) - }) - }) - }) -}) +// The values hook reads a few paths and writes one. Where possible we +// drive it with a real PatchableValues store (via helpers.NewValuesFromJSON); +// only the deliberately error-injecting cases keep the mock. + +func TestHandlerHookValues_HappyPath(t *testing.T) { + values := helpers.NewValuesFromJSON(`{ + "some": { + "path": { + "to": { + "field": { + "someInt": 1, + "array": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + } + } + } + } + }`) + + in := helpers.NewInputBuilder(t).WithValues(values).Build() + + require.NoError(t, subfolder.HandlerHookValues(context.Background(), in)) + + patches := in.Values.GetPatches() + require.NotEmpty(t, patches) + + var setStr, removeOp bool + for _, p := range patches { + if p.Op == "add" && p.Path == "/some/path/to/field/str" { + setStr = true + } + if p.Op == "remove" && p.Path == "/some/path/to/field" { + removeOp = true + } + } + assert.True(t, setStr, "expected Set on .str path") + assert.True(t, removeOp, "expected Remove on .field path") +} + +// The remaining cases use the typed mock since they need to inject +// behaviour the real PatchableValues cannot reproduce easily (failing +// ArrayCount, non-float GetRaw value, GetOk returning false on an +// existing path). + +func TestHandlerHookValues_GetOkReturnsFalse(t *testing.T) { + values := mock.NewOutputPatchableValuesCollectorMock(t) + values.GetMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}) + values.GetOkMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}, false) + values.ExistsMock.When("some.path.to.field.str").Then(false) + values.SetMock.When("some.path.to.field.str", "some_string") + values.RemoveMock.When("some.path.to.field") + values.ArrayCountMock.When("some.path.to.field.array").Then(10, nil) + + in := &pkg.HookInput{Values: values, Logger: log.NewNop()} + + require.NoError(t, subfolder.HandlerHookValues(context.Background(), in)) +} + +func TestHandlerHookValues_GetRawNotFloat(t *testing.T) { + values := mock.NewOutputPatchableValuesCollectorMock(t) + values.GetMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}) + values.GetOkMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}, true) + values.GetPatchesMock.Return(nil) + values.GetRawMock.When("some.path.to.field.someInt").Then("not-number") + values.ExistsMock.When("some.path.to.field.str").Then(false) + values.SetMock.When("some.path.to.field.str", "some_string") + values.RemoveMock.When("some.path.to.field") + values.ArrayCountMock.When("some.path.to.field.array").Then(10, nil) + + in := &pkg.HookInput{Values: values, Logger: log.NewNop()} + require.NoError(t, subfolder.HandlerHookValues(context.Background(), in)) +} + +func TestHandlerHookValues_ArrayCountReturnsError(t *testing.T) { + wantErr := errors.New("boom") + + values := mock.NewOutputPatchableValuesCollectorMock(t) + values.GetMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}) + values.GetOkMock.When("some.path.to.field").Then(gjson.Result{Str: "str-value"}, true) + values.GetPatchesMock.Return(nil) + values.GetRawMock.When("some.path.to.field.someInt").Then(float64(1)) + values.ExistsMock.When("some.path.to.field.str").Then(false) + values.SetMock.When("some.path.to.field.str", "some_string") + values.RemoveMock.When("some.path.to.field") + values.ArrayCountMock.When("some.path.to.field.array").Then(0, wantErr) + + in := &pkg.HookInput{Values: values, Logger: log.NewNop()} + + err := subfolder.HandlerHookValues(context.Background(), in) + require.Error(t, err) + assert.ErrorIs(t, err, wantErr) +} diff --git a/examples/single-file-app-example/hooks/go.mod b/examples/single-file-app-example/hooks/go.mod index 0f380727..ef98277e 100644 --- a/examples/single-file-app-example/hooks/go.mod +++ b/examples/single-file-app-example/hooks/go.mod @@ -3,11 +3,8 @@ module singlefileappexample go 1.25.8 require ( - github.com/deckhouse/deckhouse/pkg/log v0.2.0 github.com/deckhouse/module-sdk v0.0.0 - github.com/onsi/ginkgo v1.16.5 - github.com/onsi/gomega v1.36.1 - github.com/tidwall/gjson v1.18.0 + github.com/stretchr/testify v1.10.0 k8s.io/apimachinery v0.33.8 ) @@ -18,6 +15,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deckhouse/deckhouse/pkg/log v0.2.0 // indirect github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect @@ -38,6 +36,8 @@ require ( github.com/google/go-containerregistry v0.20.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.17 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -47,7 +47,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nxadm/tail v1.4.8 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -61,6 +60,7 @@ require ( github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/sylabs/oci-tools v0.16.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/vbatts/tar-split v0.12.1 // indirect @@ -76,7 +76,6 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect k8s.io/api v0.33.8 // indirect diff --git a/examples/single-file-app-example/hooks/go.sum b/examples/single-file-app-example/hooks/go.sum index 3a49f58d..57cff78f 100644 --- a/examples/single-file-app-example/hooks/go.sum +++ b/examples/single-file-app-example/hooks/go.sum @@ -30,8 +30,6 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -48,28 +46,16 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gojuno/minimock/v3 v3.4.7 h1:vhE5zpniyPDRT0DXd5s3DbtZJVlcbmC5k80izYtj9lY= github.com/gojuno/minimock/v3 v3.4.7/go.mod h1:QxJk4mdPrVyYUmEZGc2yD2NONpqM/j4dWhsy9twjFHg= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -82,9 +68,12 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -116,17 +105,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -166,7 +146,6 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -200,31 +179,22 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -239,7 +209,6 @@ golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= @@ -249,12 +218,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -263,14 +226,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/examples/single-file-app-example/hooks/main_test.go b/examples/single-file-app-example/hooks/main_test.go index 01c7f501..f32ec251 100644 --- a/examples/single-file-app-example/hooks/main_test.go +++ b/examples/single-file-app-example/hooks/main_test.go @@ -2,78 +2,54 @@ package main_test import ( "context" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - "github.com/tidwall/gjson" - - "github.com/deckhouse/deckhouse/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/testing/mock" + "github.com/deckhouse/module-sdk/testing/helpers" singlefileappexample "singlefileappexample" ) -const ( - firstSnapshot = "one" - secondSnapshot = "two" -) - -var _ = Describe("handle hook single file example", func() { - Context("settings gate closed", func() { - settings := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - settings.GetOkMock.When("apiServersDiscovery.enabled").Then(gjson.Result{}, false) - - values := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - - var input = &pkg.ApplicationHookInput{ - Values: values, - Settings: settings, - Logger: log.NewNop(), - } - - It("does not touch values when the gate is closed", func() { - err := singlefileappexample.Handle(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) - - Context("settings gate open", func() { - snapshots := mock.NewSnapshotsMock(GinkgoT()) - snapshots.GetMock.When(singlefileappexample.SnapshotKey).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - str := v.(*string) - *str = firstSnapshot - - return nil - }), - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - str := v.(*string) - *str = secondSnapshot - - return nil - }), - }, +// newAppInput builds a *pkg.ApplicationHookInput from an InputBuilder. +// The application hook input has a different shape than the regular +// HookInput, so we re-pack the relevant collectors. +func newAppInput(b *helpers.InputBuilder, settings pkg.ReadableValuesCollector) *pkg.ApplicationHookInput { + in := b.Build() + return &pkg.ApplicationHookInput{ + Snapshots: in.Snapshots, + Values: in.Values, + Settings: settings, + Logger: in.Logger, + } +} + +func TestHandle_GateClosed_LeavesValuesUntouched(t *testing.T) { + settings := helpers.NewValuesFromJSON(`{"apiServersDiscovery":{"enabled":false}}`) + + b := helpers.NewInputBuilder(t) + in := newAppInput(b, settings) + + require.NoError(t, singlefileappexample.Handle(context.Background(), in)) + assert.Empty(t, in.Values.GetPatches(), "no patches expected when gate is closed") +} + +func TestHandle_GateOpen_WritesDiscoveredPodsIntoValues(t *testing.T) { + settings := helpers.NewValuesFromJSON(`{"apiServersDiscovery":{"enabled":true}}`) + + b := helpers.NewInputBuilder(t). + WithSnapshot(singlefileappexample.SnapshotKey, + helpers.SnapshotFromObject("kube-apiserver-1"), + helpers.SnapshotFromObject("kube-apiserver-2"), ) + in := newAppInput(b, settings) - settings := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - settings.GetOkMock.When("apiServersDiscovery.enabled").Then(gjson.Result{Type: gjson.True}, true) - - values := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - values.SetMock.When("test.internal.apiServers", []string{firstSnapshot, secondSnapshot}) - - var input = &pkg.ApplicationHookInput{ - Snapshots: snapshots, - Values: values, - Settings: settings, - Logger: log.NewNop(), - } + require.NoError(t, singlefileappexample.Handle(context.Background(), in)) - It("writes discovered pods into values when the gate is open", func() { - err := singlefileappexample.Handle(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) -}) + patches := in.Values.GetPatches() + require.Len(t, patches, 1) + assert.Equal(t, "/test/internal/apiServers", patches[0].Path) + assert.JSONEq(t, `["kube-apiserver-1","kube-apiserver-2"]`, string(patches[0].Value)) +} diff --git a/examples/single-file-app-example/hooks/suite_test.go b/examples/single-file-app-example/hooks/suite_test.go deleted file mode 100644 index 784c3128..00000000 --- a/examples/single-file-app-example/hooks/suite_test.go +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2021 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main_test - -import ( - "testing" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -func Test_Suite(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "") -} diff --git a/examples/single-file-example/hooks/config_test.go b/examples/single-file-example/hooks/config_test.go index 84efa813..86984542 100644 --- a/examples/single-file-example/hooks/config_test.go +++ b/examples/single-file-example/hooks/config_test.go @@ -1,17 +1,15 @@ package main_test import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "testing" + + "github.com/stretchr/testify/assert" "github.com/deckhouse/module-sdk/pkg/registry" ) -var _ = Describe("validate hooks config", func() { - It("hook configs must be valid", func() { - hooks := registry.Registry().ModuleHooks() - for _, hook := range hooks { - Expect(hook.Config.Validate()).ShouldNot(HaveOccurred()) - } - }) -}) +func TestRegisteredHookConfigs_AreValid(t *testing.T) { + for _, hook := range registry.Registry().ModuleHooks() { + assert.NoError(t, hook.Config.Validate(), "hook config must be valid") + } +} diff --git a/examples/single-file-example/hooks/go.mod b/examples/single-file-example/hooks/go.mod index e30676ab..fa260221 100644 --- a/examples/single-file-example/hooks/go.mod +++ b/examples/single-file-example/hooks/go.mod @@ -3,10 +3,8 @@ module singlefileexample go 1.25.8 require ( - github.com/deckhouse/deckhouse/pkg/log v0.2.0 github.com/deckhouse/module-sdk v0.0.0 - github.com/onsi/ginkgo v1.16.5 - github.com/onsi/gomega v1.36.1 + github.com/stretchr/testify v1.10.0 k8s.io/apimachinery v0.33.8 ) @@ -17,6 +15,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/deckhouse/deckhouse/pkg/log v0.2.0 // indirect github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect @@ -37,6 +36,8 @@ require ( github.com/google/go-containerregistry v0.20.6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.17 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jonboulle/clockwork v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -46,7 +47,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nxadm/tail v1.4.8 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -76,7 +76,6 @@ require ( google.golang.org/protobuf v1.36.5 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect k8s.io/api v0.33.8 // indirect diff --git a/examples/single-file-example/hooks/go.sum b/examples/single-file-example/hooks/go.sum index 3a49f58d..57cff78f 100644 --- a/examples/single-file-example/hooks/go.sum +++ b/examples/single-file-example/hooks/go.sum @@ -30,8 +30,6 @@ github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lSh github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -48,28 +46,16 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gojuno/minimock/v3 v3.4.7 h1:vhE5zpniyPDRT0DXd5s3DbtZJVlcbmC5k80izYtj9lY= github.com/gojuno/minimock/v3 v3.4.7/go.mod h1:QxJk4mdPrVyYUmEZGc2yD2NONpqM/j4dWhsy9twjFHg= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -82,9 +68,12 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -116,17 +105,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -166,7 +146,6 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -200,31 +179,22 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -239,7 +209,6 @@ golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= @@ -249,12 +218,6 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -263,14 +226,9 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/examples/single-file-example/hooks/main_framework_test.go b/examples/single-file-example/hooks/main_framework_test.go new file mode 100644 index 00000000..56d58b2d --- /dev/null +++ b/examples/single-file-example/hooks/main_framework_test.go @@ -0,0 +1,118 @@ +package main_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/testing/framework" + + singlefileexample "singlefileexample" +) + +// hookConfig mirrors the configuration in main.go but is built locally so +// that the functional test does not depend on the global registry being +// pristine when go test runs the package. +var hookConfig = &pkg.HookConfig{ + Kubernetes: []pkg.KubernetesConfig{ + { + Name: singlefileexample.SnapshotKey, + APIVersion: "v1", + Kind: "Pod", + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{MatchNames: []string{"kube-system"}}, + }, + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"component": "kube-apiserver"}, + }, + JqFilter: ".metadata.name", + }, + }, +} + +// TestHandle_DiscoversAPIServerPodsFromCluster is the canonical +// end-to-end test for this example: kube-apiserver Pods are seeded into +// the fake cluster, the framework filters them by namespace + labels, +// passes the names to the hook, and the hook writes them to internal +// values. +func TestHandle_DiscoversAPIServerPodsFromCluster(t *testing.T) { + const state = ` +--- +apiVersion: v1 +kind: Pod +metadata: + name: kube-apiserver-1 + namespace: kube-system + labels: + component: kube-apiserver +--- +apiVersion: v1 +kind: Pod +metadata: + name: kube-apiserver-2 + namespace: kube-system + labels: + component: kube-apiserver +--- +apiVersion: v1 +kind: Pod +metadata: + name: not-an-apiserver + namespace: kube-system + labels: + component: scheduler +--- +apiVersion: v1 +kind: Pod +metadata: + name: kube-apiserver-other + namespace: default + labels: + component: kube-apiserver +` + + f := framework.HookExecutionConfigInit(t, + hookConfig, + singlefileexample.Handle, + `{}`, `{}`, + ) + f.KubeStateSet(state) + f.RunHook() + + require.NoError(t, f.HookError()) + + // Only the two kube-system + apiserver Pods should be in the snapshot. + snaps := f.Snapshots().Get(singlefileexample.SnapshotKey) + require.Len(t, snaps, 2) + + got := f.ValuesGet("test.internal.apiServers") + require.True(t, got.Exists()) + + arr := got.Array() + names := make([]string, 0, len(arr)) + for _, item := range arr { + names = append(names, item.String()) + } + assert.ElementsMatch(t, []string{"kube-apiserver-1", "kube-apiserver-2"}, names) +} + +// TestHandle_NoPodsResultsInEmptyValues verifies the empty path: when +// the cluster has nothing matching the binding, the hook still writes an +// empty list to keep the rest of the chart deterministic. +func TestHandle_NoPodsResultsInEmptyValues(t *testing.T) { + f := framework.HookExecutionConfigInit(t, + hookConfig, + singlefileexample.Handle, + `{}`, `{}`, + ) + f.RunHook() + + require.NoError(t, f.HookError()) + + got := f.ValuesGet("test.internal.apiServers") + require.True(t, got.Exists()) + assert.Empty(t, got.Array()) +} diff --git a/examples/single-file-example/hooks/main_test.go b/examples/single-file-example/hooks/main_test.go index f62b0320..fa246ff9 100644 --- a/examples/single-file-example/hooks/main_test.go +++ b/examples/single-file-example/hooks/main_test.go @@ -2,55 +2,41 @@ package main_test import ( "context" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - "github.com/deckhouse/deckhouse/pkg/log" - - "github.com/deckhouse/module-sdk/pkg" - "github.com/deckhouse/module-sdk/testing/mock" + "github.com/deckhouse/module-sdk/testing/helpers" singlefileexample "singlefileexample" ) -const ( - firstSnapshot = "one" - secondSnapshot = "two" -) +func TestHandle_PopulatesValuesFromSnapshot(t *testing.T) { + in := helpers.NewInputBuilder(t). + WithSnapshot(singlefileexample.SnapshotKey, + helpers.SnapshotFromObject("apiserver-1"), + helpers.SnapshotFromObject("apiserver-2"), + ). + Build() + + require.NoError(t, singlefileexample.Handle(context.Background(), in)) + + patches := in.Values.GetPatches() + require.Len(t, patches, 1, "expected exactly one Set call") + + op := patches[0] + assert.Equal(t, "add", op.Op) + assert.Equal(t, "/test/internal/apiServers", op.Path) + assert.JSONEq(t, `["apiserver-1","apiserver-2"]`, string(op.Value)) +} + +func TestHandle_NoSnapshotsWritesEmptySlice(t *testing.T) { + in := helpers.NewInputBuilder(t).Build() + + require.NoError(t, singlefileexample.Handle(context.Background(), in)) -var _ = Describe("handle hook single file example", func() { - snapshots := mock.NewSnapshotsMock(GinkgoT()) - snapshots.GetMock.When(singlefileexample.SnapshotKey).Then( - []pkg.Snapshot{ - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - str := v.(*string) - *str = firstSnapshot - - return nil - }), - mock.NewSnapshotMock(GinkgoT()).UnmarshalToMock.Set(func(v any) error { - str := v.(*string) - *str = secondSnapshot - - return nil - }), - }, - ) - - values := mock.NewOutputPatchableValuesCollectorMock(GinkgoT()) - values.SetMock.When("test.internal.apiServers", []string{firstSnapshot, secondSnapshot}) - - var input = &pkg.HookInput{ - Snapshots: snapshots, - Values: values, - Logger: log.NewNop(), - } - - Context("refoncile func", func() { - It("reconcile func executed correctly", func() { - err := singlefileexample.Handle(context.Background(), input) - Expect(err).ShouldNot(HaveOccurred()) - }) - }) -}) + patches := in.Values.GetPatches() + require.Len(t, patches, 1) + assert.JSONEq(t, `[]`, string(patches[0].Value)) +} diff --git a/examples/single-file-example/hooks/suite_test.go b/examples/single-file-example/hooks/suite_test.go deleted file mode 100644 index 784c3128..00000000 --- a/examples/single-file-example/hooks/suite_test.go +++ /dev/null @@ -1,29 +0,0 @@ -/* -Copyright 2021 Flant JSC - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main_test - -import ( - "testing" - - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" -) - -func Test_Suite(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "") -} diff --git a/testing/README.md b/testing/README.md new file mode 100644 index 00000000..200195ff --- /dev/null +++ b/testing/README.md @@ -0,0 +1,73 @@ +# `testing/` — Tools for testing module hooks + +This directory contains everything you need to test hooks built on top of the Module SDK without spinning up a real Kubernetes cluster, addon-operator, or shell-operator. + +It is organised around **three concentric layers**, picked depending on how much of the hook pipeline you want to exercise: + +| Package | Use it when… | Style | +| --- | --- | --- | +| [`testing/mock`](./mock) | You only need to substitute one or two collaborators (`PatchCollector`, `Snapshots`, `DependencyContainer`, `KubernetesClient`, …) | minimock-generated mocks | +| [`testing/helpers`](./helpers) | You want a small, focused unit test for a single hook handler with realistic values, snapshots, patches, and logger | Builder + ready-made fakes | +| [`testing/framework`](./framework) | You want a deckhouse-style end-to-end test: declare cluster YAML, run the hook, assert on snapshots / values / patched cluster state | Full pipeline with a fake K8s cluster | + +A fuller description of when to pick each layer is in the project-level [`TESTING.md`](../TESTING.md). + +## Quick orientation + +```text +testing/ +├── mock/ # auto-generated mocks for every pkg.* interface +├── helpers/ # small, hand-written building blocks for unit tests +└── framework/ # deckhouse-style harness: fake k8s cluster + hook runner +``` + +- **`mock`** is generated with [`minimock`](https://github.com/gojuno/minimock) from the interfaces in [`../pkg`](../pkg). It is the lowest-level layer — you compose individual mocks yourself and assemble a `*pkg.HookInput`. +- **`helpers`** provides `InputBuilder`, `StaticSnapshots`, `RecordingPatchCollector`, `NewValues*`, and `JQRunOn*`. These are thin layers on top of the real implementations from `pkg/*` so the values store actually records patches, the snapshot really decodes JSON, etc. +- **`framework`** is the heaviest of the three. It runs the hook end-to-end: it owns a fake dynamic Kubernetes client, generates snapshots from the hook's `KubernetesConfig` bindings, replays the patches the hook recorded, and lets you assert on the resulting cluster state. + +## Example matrix + +The same hook tested at each layer: + +```go +// 1) testing/mock — full control, full ceremony +snapshots := mock.NewSnapshotsMock(t).GetMock.When("nodes").Then(...) +values := mock.NewOutputPatchableValuesCollectorMock(t) +input := &pkg.HookInput{Snapshots: snapshots, Values: values, Logger: log.NewNop()} +require.NoError(t, MyHook(ctx, input)) + +// 2) testing/helpers — same intent, less ceremony, real values store +input := helpers.NewInputBuilder(t). + WithSnapshot("nodes", helpers.SnapshotJSON(`{"name":"n1"}`)). + WithValuesJSON(`{}`). + Build() +require.NoError(t, MyHook(ctx, input)) +require.Equal(t, "n1", input.Values.Get("found.node").String()) + +// 3) testing/framework — drive from cluster YAML, replay patches +hec := framework.HookExecutionConfigInit(t, cfg, MyHook, `{}`, `{}`) +hec.KubeStateSet(` +apiVersion: v1 +kind: Node +metadata: {name: n1} +`) +hec.RunHook() +require.NoError(t, hec.HookError()) +require.Len(t, hec.Snapshots().Get("nodes"), 1) +``` + +## Choosing a layer + +- Need to assert that **a specific JSON path was set** on values? → `helpers.NewValuesFromJSON` (unit test). +- Need to assert that the hook produced the **right sequence of patch operations** without applying them? → `helpers.RecordingPatchCollector` (unit test). +- Need to assert that **a real cluster ends up with the expected state** after Create+Delete+Patch? → `framework` (functional test). +- Need to inject **a precise sequence of mock return values** for one specific dependency? → `mock` (low-level test). + +Most modules end up using a mix of `helpers` for fast unit tests and a handful of `framework` tests for the trickiest end-to-end paths. + +## See also + +- [`TESTING.md`](../TESTING.md) — project-wide testing philosophy. +- [`testing/framework/README.md`](./framework/README.md) — deckhouse-style harness reference. +- [`testing/helpers/README.md`](./helpers/README.md) — unit-test helper reference. +- [`pkg/jq`](../pkg/jq) — the JQ engine used by snapshot filters; useful when testing JQ expressions in isolation. diff --git a/testing/framework/README.md b/testing/framework/README.md new file mode 100644 index 00000000..37a28e59 --- /dev/null +++ b/testing/framework/README.md @@ -0,0 +1,157 @@ +# `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`](https://github.com/deckhouse/deckhouse/tree/main/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 (`KubeStateSet` → `RunHook` → assert → `KubeStateSet` → `RunHook` → assert). + +For small unit tests where you only care about a single path, prefer [`testing/helpers`](../helpers). + +## Quick start + +```go +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 `Option`s. Accepts `WithInitialValues`, `WithInitialConfigValues`, `WithSchemeBuilder`, `WithCRD`. | + +`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 + +```go +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. + +### 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`](../../pkg/patchable-values), 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 + +- [`testing/framework/example_test.go`](./example_test.go) — verbose, deliberately documentation-style end-to-end test. +- [`common-hooks/storage-class-change/hook_framework_test.go`](../../common-hooks/storage-class-change/hook_framework_test.go) — selectors, config-values overrides, `BeforeHookCheck` gating. +- [`examples/example-module/hooks/subfolder/snapshot_framework_test.go`](../../examples/example-module/hooks/subfolder/snapshot_framework_test.go) — snapshot binding driven by cluster YAML. +- [`examples/example-module/hooks/subfolder/patch_framework_test.go`](../../examples/example-module/hooks/subfolder/patch_framework_test.go) — replay of `Create`+`Delete`+`Patch` operations. +- [`examples/single-file-example/hooks/main_framework_test.go`](../../examples/single-file-example/hooks/main_framework_test.go) — labels and namespace scoping. +- [`examples/dependency-example-module/hooks/subfolder/http_client_framework_test.go`](../../examples/dependency-example-module/hooks/subfolder/http_client_framework_test.go) — overriding the HTTP client on the framework's `DependencyContainer`. diff --git a/testing/framework/apply.go b/testing/framework/apply.go new file mode 100644 index 00000000..9329aefa --- /dev/null +++ b/testing/framework/apply.go @@ -0,0 +1,233 @@ +package framework + +import ( + "context" + "encoding/json" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + + "github.com/deckhouse/module-sdk/pkg" + sdkjq "github.com/deckhouse/module-sdk/pkg/jq" +) + +// applyPatchesToCluster applies the records collected from the hook to the +// fake cluster, mutating it in-place. It is called by RunHook after the +// hook handler finishes. +func (h *HookExecutionConfig) applyPatchesToCluster() error { + if h.patchCollector == nil { + return nil + } + + ctx := context.Background() + for _, p := range h.patchCollector.Records() { + if err := h.applyPatch(ctx, p); err != nil { + return fmt.Errorf("apply %s patch %s/%s: %w", p.Type, p.Namespace, p.Name, err) + } + } + return nil +} + +func (h *HookExecutionConfig) applyPatch(ctx context.Context, p RecordedPatch) error { + switch p.Type { + case PatchTypeCreate, PatchTypeCreateOrUpdate, PatchTypeCreateIfNotExists: + return h.applyCreate(ctx, p) + case PatchTypeDelete, PatchTypeDeleteInBackground, PatchTypeDeleteNonCascading: + return h.applyDelete(ctx, p) + case PatchTypeJSONPatch: + return h.applyJSONPatch(ctx, p) + case PatchTypeMergePatch: + return h.applyMergePatch(ctx, p) + case PatchTypeJQFilter: + return h.applyJQFilter(ctx, p) + } + return fmt.Errorf("unknown patch type %q", p.Type) +} + +func (h *HookExecutionConfig) applyCreate(ctx context.Context, p RecordedPatch) error { + u, err := toUnstructured(p.Object) + if err != nil { + return fmt.Errorf("convert object: %w", err) + } + + gvr, err := h.gvrFor(u.GetAPIVersion(), u.GetKind()) + if err != nil { + return err + } + ri := h.resourceInterface(gvr, u.GetNamespace()) + + switch p.Type { + case PatchTypeCreate: + _, err := ri.Create(ctx, u, metav1.CreateOptions{}) + return err + case PatchTypeCreateIfNotExists: + _, err := ri.Create(ctx, u, metav1.CreateOptions{}) + if err != nil && apierrors.IsAlreadyExists(err) { + return nil + } + return err + case PatchTypeCreateOrUpdate: + _, err := ri.Create(ctx, u, metav1.CreateOptions{}) + if err == nil { + return nil + } + if !apierrors.IsAlreadyExists(err) { + return err + } + // Pull current resourceVersion to allow Update. + current, err := ri.Get(ctx, u.GetName(), metav1.GetOptions{}) + if err != nil { + return err + } + u.SetResourceVersion(current.GetResourceVersion()) + _, err = ri.Update(ctx, u, metav1.UpdateOptions{}) + return err + } + return nil +} + +func (h *HookExecutionConfig) applyDelete(ctx context.Context, p RecordedPatch) error { + gvr, err := h.gvrFor(p.APIVersion, p.Kind) + if err != nil { + return err + } + err = h.resourceInterface(gvr, p.Namespace).Delete(ctx, p.Name, metav1.DeleteOptions{}) + if err != nil && apierrors.IsNotFound(err) { + return nil + } + return err +} + +func (h *HookExecutionConfig) applyJSONPatch(ctx context.Context, p RecordedPatch) error { + gvr, err := h.gvrFor(p.APIVersion, p.Kind) + if err != nil { + return err + } + data, err := patchPayloadAsJSON(p.JSONPatch) + if err != nil { + return fmt.Errorf("marshal json patch: %w", err) + } + _, err = h.resourceInterface(gvr, p.Namespace).Patch(ctx, p.Name, types.JSONPatchType, data, metav1.PatchOptions{}) + if err != nil && apierrors.IsNotFound(err) && shouldIgnoreMissing(p.Options) { + return nil + } + return err +} + +func (h *HookExecutionConfig) applyMergePatch(ctx context.Context, p RecordedPatch) error { + gvr, err := h.gvrFor(p.APIVersion, p.Kind) + if err != nil { + return err + } + data, err := patchPayloadAsJSON(p.MergePatch) + if err != nil { + return fmt.Errorf("marshal merge patch: %w", err) + } + _, err = h.resourceInterface(gvr, p.Namespace).Patch(ctx, p.Name, types.MergePatchType, data, metav1.PatchOptions{}) + if err != nil && apierrors.IsNotFound(err) && shouldIgnoreMissing(p.Options) { + return nil + } + return err +} + +func (h *HookExecutionConfig) applyJQFilter(ctx context.Context, p RecordedPatch) error { + gvr, err := h.gvrFor(p.APIVersion, p.Kind) + if err != nil { + return err + } + ri := h.resourceInterface(gvr, p.Namespace) + current, err := ri.Get(ctx, p.Name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) && shouldIgnoreMissing(p.Options) { + return nil + } + return err + } + q, err := sdkjq.NewQuery(p.JQFilter) + if err != nil { + return fmt.Errorf("compile jq: %w", err) + } + res, err := q.FilterObject(ctx, current.UnstructuredContent()) + if err != nil { + return fmt.Errorf("apply jq: %w", err) + } + var patched map[string]any + if err := json.Unmarshal([]byte(res.String()), &patched); err != nil { + return fmt.Errorf("decode jq result: %w", err) + } + current.Object = patched + _, err = ri.Update(ctx, current, metav1.UpdateOptions{}) + return err +} + +// patchPayloadAsJSON normalizes the patch payload to JSON bytes. The hook may +// pass a string, []byte, or any JSON-serializable value. +func patchPayloadAsJSON(payload any) ([]byte, error) { + switch v := payload.(type) { + case nil: + return nil, fmt.Errorf("nil patch payload") + case []byte: + return v, nil + case string: + return []byte(v), nil + default: + return json.Marshal(v) + } +} + +func toUnstructured(obj any) (*unstructured.Unstructured, error) { + switch v := obj.(type) { + case *unstructured.Unstructured: + return v, nil + case unstructured.Unstructured: + return &v, nil + case map[string]any: + return &unstructured.Unstructured{Object: v}, nil + case runtime.Object: + content, err := runtime.DefaultUnstructuredConverter.ToUnstructured(v) + if err != nil { + return nil, err + } + return &unstructured.Unstructured{Object: content}, nil + } + // Fall back to round-tripping via JSON. + data, err := json.Marshal(obj) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + out := map[string]any{} + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + return &unstructured.Unstructured{Object: out}, nil +} + +// shouldIgnoreMissing inspects PatchCollectorOptions to detect WithIgnoreMissingObject(true). +// Because the option is opaque (an applier interface), we use a small helper applier to capture it. +func shouldIgnoreMissing(opts []pkg.PatchCollectorOption) bool { + flag := &flagApplier{} + for _, o := range opts { + o.Apply(flag) + } + return flag.ignoreMissing +} + +type flagApplier struct { + subresource string + ignoreMissing bool + ignoreHookErr bool +} + +func (f *flagApplier) WithSubresource(s string) { f.subresource = s } +func (f *flagApplier) WithIgnoreMissingObject(b bool) { f.ignoreMissing = b } +func (f *flagApplier) WithIgnoreHookError(b bool) { f.ignoreHookErr = b } + +// pkg import below is used for the flagApplier interface assertion. +// Keep this import here so the file is self-contained. +// + +var _ = func() any { var _ pkg.PatchCollectorOptionApplier = (*flagApplier)(nil); return nil }() diff --git a/testing/framework/config.go b/testing/framework/config.go new file mode 100644 index 00000000..4f38e637 --- /dev/null +++ b/testing/framework/config.go @@ -0,0 +1,358 @@ +package framework + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + admissionregv1 "k8s.io/api/admissionregistration/v1" + appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" + authorizationv1 "k8s.io/api/authorization/v1" + autoscalingv1 "k8s.io/api/autoscaling/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" + batchv1 "k8s.io/api/batch/v1" + certificatesv1 "k8s.io/api/certificates/v1" + coordinationv1 "k8s.io/api/coordination/v1" + corev1 "k8s.io/api/core/v1" + discoveryv1 "k8s.io/api/discovery/v1" + eventsv1 "k8s.io/api/events/v1" + flowcontrolv1 "k8s.io/api/flowcontrol/v1" + networkingv1 "k8s.io/api/networking/v1" + nodev1 "k8s.io/api/node/v1" + policyv1 "k8s.io/api/policy/v1" + rbacv1 "k8s.io/api/rbac/v1" + schedulingv1 "k8s.io/api/scheduling/v1" + storagev1 "k8s.io/api/storage/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" + + "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/module-sdk/internal/metric" + "github.com/deckhouse/module-sdk/pkg" +) + +// HookFunc is the type of hook handler functions tested by the framework. +type HookFunc = pkg.HookFunc[*pkg.HookInput] + +// 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). +type HookExecutionConfig struct { + t testing.TB + + hookConfig *pkg.HookConfig + hookHandler HookFunc + + scheme *runtime.Scheme + unstructuredScheme *runtime.Scheme + fakeClient *dynamicfake.FakeDynamicClient + gvrToListKind map[schema.GroupVersionResource]string + gvkToGVR map[schema.GroupVersionKind]schema.GroupVersionResource + + values *valuesStore + configValues *valuesStore + + patchCollector *recordingPatchCollector + metricsCollector *metric.Collector + snapshots snapshotsMap + hookError error + loggerOutput *bytes.Buffer + dc *frameworkDC + + logger *log.Logger +} + +// 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 (h *HookExecutionConfig) DependencyContainer() *frameworkDC { + if h.dc == nil { + h.dc = newFrameworkDC(h.fakeClient, h.scheme) + } + return h.dc +} + +// 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 HookExecutionConfigInit(t testing.TB, config *pkg.HookConfig, handler HookFunc, initValues, initConfigValues string) *HookExecutionConfig { + return NewHookExecutionConfig(t, config, handler, + WithInitialValues(initValues), + WithInitialConfigValues(initConfigValues), + ) +} + +// NewHookExecutionConfig creates an execution config with options. +func NewHookExecutionConfig(t testing.TB, config *pkg.HookConfig, handler HookFunc, opts ...Option) *HookExecutionConfig { + t.Helper() + + cfg := &execOptions{ + initValues: "{}", + initConfigValues: "{}", + } + for _, o := range opts { + o.apply(cfg) + } + + scheme := defaultScheme(cfg.extraSchemeBuilders...) + unstructuredScheme := newUnstructuredScheme(scheme) + + hec := &HookExecutionConfig{ + t: t, + hookConfig: config, + hookHandler: handler, + scheme: scheme, + unstructuredScheme: unstructuredScheme, + gvrToListKind: defaultGVRToListKind(scheme), + gvkToGVR: make(map[schema.GroupVersionKind]schema.GroupVersionResource), + loggerOutput: bytes.NewBuffer(nil), + } + + hec.logger = log.NewLogger(log.WithOutput(hec.loggerOutput)) + + var err error + hec.values, err = newValuesStore(cfg.initValues) + if err != nil { + t.Fatalf("framework: parse initial values: %v", err) + } + hec.configValues, err = newValuesStore(cfg.initConfigValues) + if err != nil { + t.Fatalf("framework: parse initial config values: %v", err) + } + + hec.fakeClient = dynamicfake.NewSimpleDynamicClientWithCustomListKinds(unstructuredScheme, hec.gvrToListKind) + + for _, crd := range cfg.crds { + hec.RegisterCRD(crd.group, crd.version, crd.kind, crd.namespaced) + } + + return hec +} + +// defaultScheme returns the default Kubernetes scheme registering all standard +// API groups (the same set used by sigs.k8s.io/controller-runtime by default) +// plus apiextensions/v1 and any extra builders the caller provided. +func defaultScheme(extraBuilders ...runtime.SchemeBuilder) *runtime.Scheme { + scheme := runtime.NewScheme() + + for _, b := range []runtime.SchemeBuilder{ + admissionv1.SchemeBuilder, + admissionregv1.SchemeBuilder, + apiextensionsv1.SchemeBuilder, + appsv1.SchemeBuilder, + authenticationv1.SchemeBuilder, + authorizationv1.SchemeBuilder, + autoscalingv1.SchemeBuilder, + autoscalingv2.SchemeBuilder, + batchv1.SchemeBuilder, + certificatesv1.SchemeBuilder, + coordinationv1.SchemeBuilder, + corev1.SchemeBuilder, + discoveryv1.SchemeBuilder, + eventsv1.SchemeBuilder, + flowcontrolv1.SchemeBuilder, + networkingv1.SchemeBuilder, + nodev1.SchemeBuilder, + policyv1.SchemeBuilder, + rbacv1.SchemeBuilder, + schedulingv1.SchemeBuilder, + storagev1.SchemeBuilder, + } { + utilruntime.Must(b.AddToScheme(scheme)) + } + + for _, builder := range extraBuilders { + utilruntime.Must(builder.AddToScheme(scheme)) + } + + return scheme +} + +// defaultGVRToListKind returns a list-kind mapping for all GVKs registered in +// the scheme. The fake dynamic client uses this for List operations. +func defaultGVRToListKind(scheme *runtime.Scheme) map[schema.GroupVersionResource]string { + out := map[schema.GroupVersionResource]string{} + for gvk := range scheme.AllKnownTypes() { + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + out[gvr] = gvk.Kind + "List" + } + return out +} + +// newUnstructuredScheme builds a scheme where every GVK from the typed scheme +// is rebound to *unstructured.Unstructured (or *unstructured.UnstructuredList +// for List kinds). This mirrors what client-go's NewSimpleDynamicClient does +// internally and is required to make the fake dynamic client work entirely +// with Unstructured objects. +func newUnstructuredScheme(typed *runtime.Scheme) *runtime.Scheme { + s := runtime.NewScheme() + for gvk := range typed.AllKnownTypes() { + if s.Recognizes(gvk) { + continue + } + registerUnstructuredGVK(s, gvk) + } + return s +} + +// registerUnstructuredGVK adds a GVK (and its corresponding "List" kind) +// to the scheme as unstructured types. +func registerUnstructuredGVK(s *runtime.Scheme, gvk schema.GroupVersionKind) { + if !s.Recognizes(gvk) { + if isListKind(gvk.Kind) { + s.AddKnownTypeWithName(gvk, &unstructured.UnstructuredList{}) + } else { + s.AddKnownTypeWithName(gvk, &unstructured.Unstructured{}) + } + } + if !isListKind(gvk.Kind) { + listGVK := schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind + "List"} + if !s.Recognizes(listGVK) { + s.AddKnownTypeWithName(listGVK, &unstructured.UnstructuredList{}) + } + } +} + +func isListKind(kind string) bool { + return len(kind) > 4 && kind[len(kind)-4:] == "List" +} + +// KubeClient returns the underlying fake dynamic client. Use it to inspect +// or seed cluster state directly. +func (h *HookExecutionConfig) KubeClient() dynamic.Interface { + return h.fakeClient +} + +// Logger returns the test logger (its output is captured in LoggerOutput). +func (h *HookExecutionConfig) Logger() *log.Logger { + return h.logger +} + +// LoggerOutput returns the buffer of captured log output, useful for assertions. +func (h *HookExecutionConfig) LoggerOutput() *bytes.Buffer { + return h.loggerOutput +} + +// HookError returns the error returned by the hook handler from the most +// recent RunHook call. +func (h *HookExecutionConfig) HookError() error { return h.hookError } + +// Snapshots returns the snapshots that were passed to the hook on the most +// recent RunHook call. +func (h *HookExecutionConfig) Snapshots() pkg.Snapshots { return h.snapshots } + +// PatchOperations returns the patch operations recorded by the hook during +// the most recent RunHook call. +func (h *HookExecutionConfig) PatchOperations() []pkg.PatchCollectorOperation { + if h.patchCollector == nil { + return nil + } + return h.patchCollector.Operations() +} + +// 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 (h *HookExecutionConfig) PatchedOperations() []RecordedPatch { + if h.patchCollector == nil { + return nil + } + return h.patchCollector.Records() +} + +// CollectedMetrics returns the metric operations recorded by the hook during +// the most recent RunHook call. +func (h *HookExecutionConfig) CollectedMetrics() []MetricOperation { + if h.metricsCollector == nil { + return nil + } + out := h.metricsCollector.CollectedMetrics() + res := make([]MetricOperation, 0, len(out)) + for _, m := range out { + res = append(res, MetricOperation{ + Name: m.Name, + Group: m.Group, + Action: m.Action, + Value: m.Value, + Labels: m.Labels, + }) + } + return res +} + +// MetricOperation is a stable, framework-friendly view of a metric operation. +type MetricOperation struct { + Name string + Group string + Action string + Value *float64 + Labels map[string]string +} + +// snapshotsMap is the framework's internal Snapshots type. It is exposed via +// the pkg.Snapshots interface; we keep it private to avoid leaking the type. +type snapshotsMap map[string][]pkg.Snapshot + +// Get implements pkg.Snapshots. +func (s snapshotsMap) Get(key string) []pkg.Snapshot { return s[key] } + +// rawSnapshot holds the raw filtered JSON for a single snapshot. +type rawSnapshot []byte + +// UnmarshalTo implements pkg.Snapshot. +func (r rawSnapshot) UnmarshalTo(v any) error { + return json.Unmarshal(r, v) +} + +// String implements pkg.Snapshot. +func (r rawSnapshot) String() string { return string(r) } + +// gvrFor returns the GroupVersionResource for the given APIVersion + Kind. +// It consults the explicit gvkToGVR overrides registered via RegisterCRD, +// and falls back to convention-based pluralization via meta.UnsafeGuessKindToResource. +func (h *HookExecutionConfig) gvrFor(apiVersion, kind string) (schema.GroupVersionResource, error) { + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return schema.GroupVersionResource{}, fmt.Errorf("parse apiVersion %q: %w", apiVersion, err) + } + gvk := gv.WithKind(kind) + if gvr, ok := h.gvkToGVR[gvk]; ok { + return gvr, nil + } + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return gvr, nil +} + +// resolveGVRForKind tries to find a GVR by kind alone, using the scheme and +// any registered CRDs. +func (h *HookExecutionConfig) resolveGVRForKind(kind string) (schema.GroupVersionResource, error) { + for gvk := range h.scheme.AllKnownTypes() { + if gvk.Kind == kind { + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + return gvr, nil + } + } + for gvk, gvr := range h.gvkToGVR { + if gvk.Kind == kind { + return gvr, nil + } + } + return schema.GroupVersionResource{}, fmt.Errorf("kind %q not registered (call RegisterCRD or pass a SchemeBuilder)", kind) +} diff --git a/testing/framework/crd.go b/testing/framework/crd.go new file mode 100644 index 00000000..9b699d13 --- /dev/null +++ b/testing/framework/crd.go @@ -0,0 +1,31 @@ +package framework + +import ( + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// 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 (h *HookExecutionConfig) RegisterCRD(group, version, kind string, namespaced bool) { + gvk := schema.GroupVersionKind{Group: group, Version: version, Kind: kind} + gvr, _ := meta.UnsafeGuessKindToResource(gvk) + h.gvkToGVR[gvk] = gvr + h.gvrToListKind[gvr] = kind + "List" + + // Make the GVK known to the unstructured scheme so the fake client can + // list and watch it. + registerUnstructuredGVK(h.unstructuredScheme, gvk) + + // Re-create the fake client so it picks up the new GVR mapping. + h.resetCluster() + + _ = namespaced // namespaced is reserved for future scope-tracking; see snapshots.go +} diff --git a/testing/framework/dc.go b/testing/framework/dc.go new file mode 100644 index 00000000..9ec82a32 --- /dev/null +++ b/testing/framework/dc.go @@ -0,0 +1,111 @@ +package framework + +import ( + "errors" + "net/http" + + "github.com/jonboulle/clockwork" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + crfake "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/deckhouse/module-sdk/pkg" +) + +// frameworkDC is a minimal pkg.DependencyContainer wired to the framework's +// fake clients. +// +// HTTP and registry clients return errors by default; tests that need them can +// override via SetHTTPClient / SetRegistryClient. +type frameworkDC struct { + k8sClient *fakeKubeClient + + clock clockwork.Clock + + httpClient pkg.HTTPClient + regClient pkg.RegistryClient +} + +func newFrameworkDC(dynamicClient dynamic.Interface, scheme *runtime.Scheme) *frameworkDC { + ctrlClient := crfake.NewClientBuilder().WithScheme(scheme).Build() + return &frameworkDC{ + k8sClient: &fakeKubeClient{ + Client: ctrlClient, + dynamic: dynamicClient, + }, + clock: clockwork.NewFakeClock(), + } +} + +var _ pkg.DependencyContainer = (*frameworkDC)(nil) + +// SetHTTPClient overrides the HTTP client returned by GetHTTPClient. +func (d *frameworkDC) SetHTTPClient(c pkg.HTTPClient) { d.httpClient = c } + +// SetRegistryClient overrides the registry client returned by GetRegistryClient. +func (d *frameworkDC) SetRegistryClient(c pkg.RegistryClient) { d.regClient = c } + +// SetClock overrides the framework's clock. +func (d *frameworkDC) SetClock(c clockwork.Clock) { d.clock = c } + +// GetClock implements pkg.DependencyContainer. +func (d *frameworkDC) GetClock() clockwork.Clock { return d.clock } + +// GetHTTPClient implements pkg.DependencyContainer. +func (d *frameworkDC) GetHTTPClient(_ ...pkg.HTTPOption) pkg.HTTPClient { + if d.httpClient != nil { + return d.httpClient + } + return errHTTPClient{} +} + +// GetK8sClient implements pkg.DependencyContainer. +func (d *frameworkDC) GetK8sClient(_ ...pkg.KubernetesOption) (pkg.KubernetesClient, error) { + return d.k8sClient, nil +} + +// MustGetK8sClient implements pkg.DependencyContainer. +func (d *frameworkDC) MustGetK8sClient(_ ...pkg.KubernetesOption) pkg.KubernetesClient { + return d.k8sClient +} + +// GetClientConfig implements pkg.DependencyContainer. +func (d *frameworkDC) GetClientConfig() (*rest.Config, error) { + return &rest.Config{Host: "fake-test"}, nil +} + +// GetRegistryClient implements pkg.DependencyContainer. +func (d *frameworkDC) GetRegistryClient(_ string, _ ...pkg.RegistryOption) (pkg.RegistryClient, error) { + if d.regClient != nil { + return d.regClient, nil + } + return nil, errors.New("framework: registry client not configured (use SetRegistryClient)") +} + +// MustGetRegistryClient implements pkg.DependencyContainer. +func (d *frameworkDC) MustGetRegistryClient(repo string, opts ...pkg.RegistryOption) pkg.RegistryClient { + c, err := d.GetRegistryClient(repo, opts...) + if err != nil { + panic(err) + } + return c +} + +// fakeKubeClient implements pkg.KubernetesClient by composing a controller-runtime +// fake client with the framework's dynamic fake client. +type fakeKubeClient struct { + client.Client + dynamic dynamic.Interface +} + +// Dynamic implements pkg.KubernetesClient. +func (c *fakeKubeClient) Dynamic() dynamic.Interface { return c.dynamic } + +// errHTTPClient returns an error on every request, for tests that don't override the HTTP client. +type errHTTPClient struct{} + +func (errHTTPClient) Do(_ *http.Request) (*http.Response, error) { + return nil, errors.New("framework: HTTP client not configured (use SetHTTPClient)") +} diff --git a/testing/framework/doc.go b/testing/framework/doc.go new file mode 100644 index 00000000..dddf0b07 --- /dev/null +++ b/testing/framework/doc.go @@ -0,0 +1,26 @@ +// 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()) +// } +package framework diff --git a/testing/framework/example_test.go b/testing/framework/example_test.go new file mode 100644 index 00000000..d1f808bb --- /dev/null +++ b/testing/framework/example_test.go @@ -0,0 +1,124 @@ +package framework_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/module-sdk/pkg" + objectpatch "github.com/deckhouse/module-sdk/pkg/object-patch" + "github.com/deckhouse/module-sdk/testing/framework" +) + +// TestExample_DeckhouseStyle is a comprehensive end-to-end example mirroring +// how a deckhouse hook test is typically written. It is intentionally verbose +// to serve as documentation. +// +// The hook under test counts the number of running pods in the "default" +// namespace, writes the count to values, and creates a status ConfigMap. +func TestExample_DeckhouseStyle(t *testing.T) { + // 1. Hook config — same as in production code. + cfg := &pkg.HookConfig{ + Metadata: pkg.HookMetadata{Name: "pod-counter"}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{MatchNames: []string{"default"}}, + }, + JqFilter: `{name: .metadata.name, phase: .status.phase}`, + }, + }, + } + + type podSnap struct { + Name string `json:"name"` + Phase string `json:"phase"` + } + + // 2. Hook handler — also same as in production code. + handler := func(_ context.Context, input *pkg.HookInput) error { + pods, err := objectpatch.UnmarshalToStruct[podSnap](input.Snapshots, "pods") + if err != nil { + return err + } + + var running int + for _, p := range pods { + if p.Phase == "Running" { + running++ + } + } + + input.Values.Set("podCounter.running", running) + + input.PatchCollector.Create(&corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: "pod-counter-status", Namespace: "default"}, + Data: map[string]string{"running": fmt.Sprintf("%d", running)}, + }) + return nil + } + + // 3. Initialise the framework as in deckhouse: HookExecutionConfigInit. + hec := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`) + + // 4. Describe the cluster state with YAML. + hec.KubeStateSet(` +--- +apiVersion: v1 +kind: Pod +metadata: + name: app-1 + namespace: default +status: + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + name: app-2 + namespace: default +status: + phase: Pending +--- +apiVersion: v1 +kind: Pod +metadata: + name: kube-proxy + namespace: kube-system +status: + phase: Running +`) + + // 5. Run the hook. + hec.RunHook() + + // 6. Inspect the results. + require.NoError(t, hec.HookError()) + + // Snapshots respect the namespace selector. + require.Len(t, hec.Snapshots().Get("pods"), 2) + + // Values produced by the hook. + assert.Equal(t, int64(1), hec.ValuesGet("podCounter.running").Int()) + + // Patch operations recorded. + ops := hec.PatchedOperations() + require.Len(t, ops, 1) + assert.Equal(t, framework.PatchTypeCreate, ops[0].Type) + + // And the create operation has actually been applied to the fake cluster: + cm := hec.KubernetesResource("ConfigMap", "default", "pod-counter-status") + require.NotNil(t, cm) + data, _ := cm.Object["data"].(map[string]any) + require.NotNil(t, data) + assert.Equal(t, "1", data["running"]) +} diff --git a/testing/framework/framework_test.go b/testing/framework/framework_test.go new file mode 100644 index 00000000..f405631e --- /dev/null +++ b/testing/framework/framework_test.go @@ -0,0 +1,421 @@ +package framework_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/module-sdk/pkg" + objectpatch "github.com/deckhouse/module-sdk/pkg/object-patch" + "github.com/deckhouse/module-sdk/testing/framework" +) + +// nodeBindingConfig is a sample hook config used across tests. +var nodeBindingConfig = &pkg.HookConfig{ + Metadata: pkg.HookMetadata{Name: "test-hook"}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: "nodes", + APIVersion: "v1", + Kind: "Node", + JqFilter: `{name: .metadata.name, role: .metadata.labels["node-role"]}`, + }, + }, +} + +const initialNodes = ` +--- +apiVersion: v1 +kind: Node +metadata: + name: kube-worker-1 + labels: + node-role: worker +--- +apiVersion: v1 +kind: Node +metadata: + name: kube-worker-2 + labels: + node-role: worker +--- +apiVersion: v1 +kind: Node +metadata: + name: kube-master-1 + labels: + node-role: master +` + +// TestSnapshotsFromKubeState exercises the deckhouse-style flow: +// KubeStateSet → RunHook → assert on snapshots / patches / values. +func TestSnapshotsFromKubeState(t *testing.T) { + type filteredNode struct { + Name string `json:"name"` + Role string `json:"role"` + } + + handler := func(_ context.Context, input *pkg.HookInput) error { + nodes, err := objectpatch.UnmarshalToStruct[filteredNode](input.Snapshots, "nodes") + if err != nil { + return err + } + names := make([]string, 0, len(nodes)) + for _, n := range nodes { + names = append(names, n.Name) + } + input.Values.Set("module.nodes.count", len(nodes)) + input.Values.Set("module.nodes.names", names) + return nil + } + + hec := framework.HookExecutionConfigInit(t, nodeBindingConfig, handler, `{}`, `{}`) + + hec.KubeStateSet(initialNodes) + hec.RunHook() + + require.NoError(t, hec.HookError()) + + got := hec.Snapshots().Get("nodes") + assert.Len(t, got, 3) + + assert.Equal(t, int64(3), hec.ValuesGet("module.nodes.count").Int()) + + names := hec.ValuesGet("module.nodes.names").Array() + assert.Len(t, names, 3) +} + +// TestKubeStateTransitions verifies that KubeStateSet can be called multiple +// times and each RunHook sees the latest state. +func TestKubeStateTransitions(t *testing.T) { + handler := func(_ context.Context, input *pkg.HookInput) error { + input.Values.Set("count", len(input.Snapshots.Get("nodes"))) + return nil + } + + hec := framework.HookExecutionConfigInit(t, nodeBindingConfig, handler, `{}`, `{}`) + + hec.KubeStateSet(initialNodes) + hec.RunHook() + require.NoError(t, hec.HookError()) + assert.Equal(t, int64(3), hec.ValuesGet("count").Int()) + + hec.KubeStateSet(` +--- +apiVersion: v1 +kind: Node +metadata: + name: kube-worker-1 +`) + hec.RunHook() + require.NoError(t, hec.HookError()) + assert.Equal(t, int64(1), hec.ValuesGet("count").Int()) +} + +// TestPatchCollectorAppliesToFakeCluster verifies that Create/Delete/Patch +// operations performed by the hook are replayed against the fake cluster. +func TestPatchCollectorAppliesToFakeCluster(t *testing.T) { + cfg := &pkg.HookConfig{ + Metadata: pkg.HookMetadata{Name: "patch-hook"}, + Kubernetes: nil, + } + + handler := func(_ context.Context, input *pkg.HookInput) error { + // Create a configmap. + cm := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "default"}, + Data: map[string]string{"hello": "world"}, + } + input.PatchCollector.Create(cm) + + // Issue a merge patch (target may not exist yet, so use IgnoreMissingObject). + input.PatchCollector.PatchWithMerge( + map[string]any{"data": map[string]string{"hello": "patched"}}, + "v1", "ConfigMap", "default", "demo", + ) + return nil + } + + hec := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`) + hec.RunHook() + require.NoError(t, hec.HookError()) + + // Two patch operations recorded. + ops := hec.PatchedOperations() + require.Len(t, ops, 2) + assert.Equal(t, framework.PatchTypeCreate, ops[0].Type) + assert.Equal(t, framework.PatchTypeMergePatch, ops[1].Type) + + // The fake cluster should now contain the configmap with patched value. + cm := hec.KubernetesResource("ConfigMap", "default", "demo") + require.NotNil(t, cm) + + data := nestedMap(cm.Object, "data") + assert.Equal(t, "patched", data["hello"]) +} + +// TestPatchTypesAreApplied exercises Delete and JSONPatch operations. +func TestPatchTypesAreApplied(t *testing.T) { + cfg := &pkg.HookConfig{Metadata: pkg.HookMetadata{Name: "complex"}} + + handler := func(_ context.Context, input *pkg.HookInput) error { + input.PatchCollector.Create(&corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: "to-delete", Namespace: "kube-system"}, + }) + input.PatchCollector.Delete("v1", "ConfigMap", "kube-system", "to-delete") + + // Create a config map and JSON-patch a key. + input.PatchCollector.Create(&corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"}, + ObjectMeta: metav1.ObjectMeta{Name: "json-patched", Namespace: "default"}, + Data: map[string]string{"a": "1"}, + }) + input.PatchCollector.PatchWithJSON( + []map[string]any{ + {"op": "add", "path": "/data/b", "value": "2"}, + }, + "v1", "ConfigMap", "default", "json-patched", + ) + return nil + } + + hec := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`) + hec.RunHook() + require.NoError(t, hec.HookError()) + + // Deleted resource should be gone. + assert.Nil(t, hec.KubernetesResource("ConfigMap", "kube-system", "to-delete")) + + // JSON-patched resource has both keys. + cm := hec.KubernetesResource("ConfigMap", "default", "json-patched") + require.NotNil(t, cm) + data := nestedMap(cm.Object, "data") + assert.Equal(t, "1", data["a"]) + assert.Equal(t, "2", data["b"]) +} + +// TestValuesAndConfigValuesArePatched ensures values written by the hook +// (via input.Values.Set) are visible after RunHook. +func TestValuesAndConfigValuesArePatched(t *testing.T) { + cfg := &pkg.HookConfig{Metadata: pkg.HookMetadata{Name: "values-hook"}} + + handler := func(_ context.Context, input *pkg.HookInput) error { + input.Values.Set("module.replicaCount", 3) + input.Values.Set("module.feature.enabled", true) + input.ConfigValues.Set("module.profile", "prod") + return nil + } + + initial := ` +module: + replicaCount: 1 + feature: + enabled: false +` + hec := framework.HookExecutionConfigInit(t, cfg, handler, initial, `{}`) + hec.RunHook() + require.NoError(t, hec.HookError()) + + assert.Equal(t, int64(3), hec.ValuesGet("module.replicaCount").Int()) + assert.True(t, hec.ValuesGet("module.feature.enabled").Bool()) + assert.Equal(t, "prod", hec.ConfigValuesGet("module.profile").String()) +} + +// TestNamespaceSelectorBindings verifies snapshot generation for a binding +// scoped to specific namespaces. +func TestNamespaceSelectorBindings(t *testing.T) { + cfg := &pkg.HookConfig{ + Metadata: pkg.HookMetadata{Name: "ns-hook"}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: "system_pods", + APIVersion: "v1", + Kind: "Pod", + NamespaceSelector: &pkg.NamespaceSelector{ + NameSelector: &pkg.NameSelector{MatchNames: []string{"kube-system"}}, + }, + JqFilter: `.metadata.name`, + }, + }, + } + + handler := func(_ context.Context, input *pkg.HookInput) error { + input.Values.Set("count", len(input.Snapshots.Get("system_pods"))) + return nil + } + + hec := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`) + hec.KubeStateSet(` +--- +apiVersion: v1 +kind: Pod +metadata: + name: kube-proxy + namespace: kube-system +--- +apiVersion: v1 +kind: Pod +metadata: + name: my-app + namespace: default +`) + hec.RunHook() + require.NoError(t, hec.HookError()) + + assert.Equal(t, int64(1), hec.ValuesGet("count").Int()) +} + +// TestLabelSelectorBinding verifies snapshot filtering by label selector. +func TestLabelSelectorBinding(t *testing.T) { + cfg := &pkg.HookConfig{ + Metadata: pkg.HookMetadata{Name: "label-hook"}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: "workers", + APIVersion: "v1", + Kind: "Node", + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"node-role": "worker"}, + }, + JqFilter: `.metadata.name`, + }, + }, + } + handler := func(_ context.Context, input *pkg.HookInput) error { + input.Values.Set("workerCount", len(input.Snapshots.Get("workers"))) + return nil + } + + hec := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`) + hec.KubeStateSet(initialNodes) + hec.RunHook() + require.NoError(t, hec.HookError()) + assert.Equal(t, int64(2), hec.ValuesGet("workerCount").Int()) +} + +// TestHookErrorIsCaptured verifies HookError reflects the handler's return value. +func TestHookErrorIsCaptured(t *testing.T) { + cfg := &pkg.HookConfig{Metadata: pkg.HookMetadata{Name: "error-hook"}} + handler := func(_ context.Context, _ *pkg.HookInput) error { + return assertErr("boom") + } + hec := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`) + hec.RunHook() + require.Error(t, hec.HookError()) + assert.Contains(t, hec.HookError().Error(), "boom") +} + +// TestRegisterCRD allows resources of an unknown kind to be used in state YAML. +func TestRegisterCRD(t *testing.T) { + cfg := &pkg.HookConfig{ + Metadata: pkg.HookMetadata{Name: "crd-hook"}, + Kubernetes: []pkg.KubernetesConfig{ + { + Name: "widgets", + APIVersion: "example.com/v1alpha1", + Kind: "Widget", + JqFilter: `.metadata.name`, + }, + }, + } + handler := func(_ context.Context, input *pkg.HookInput) error { + input.Values.Set("widgets", len(input.Snapshots.Get("widgets"))) + return nil + } + + hec := framework.NewHookExecutionConfig(t, cfg, handler, + framework.WithCRD("example.com", "v1alpha1", "Widget", true), + ) + + hec.KubeStateSet(` +--- +apiVersion: example.com/v1alpha1 +kind: Widget +metadata: + name: widget-a + namespace: default +spec: + size: 10 +--- +apiVersion: example.com/v1alpha1 +kind: Widget +metadata: + name: widget-b + namespace: default +`) + hec.RunHook() + require.NoError(t, hec.HookError()) + assert.Equal(t, int64(2), hec.ValuesGet("widgets").Int()) + + // Lookup individual CR via KubernetesResource. + w := hec.KubernetesResource("Widget", "default", "widget-a") + require.NotNil(t, w) + size, _ := nestedInt(w.Object, "spec", "size") + assert.Equal(t, int64(10), size) +} + +// TestLoggerOutputCaptured verifies the logger output is captured. +func TestLoggerOutputCaptured(t *testing.T) { + cfg := &pkg.HookConfig{Metadata: pkg.HookMetadata{Name: "log-hook"}} + handler := func(_ context.Context, input *pkg.HookInput) error { + input.Logger.Info("hello from hook") + return nil + } + hec := framework.HookExecutionConfigInit(t, cfg, handler, `{}`, `{}`) + hec.RunHook() + require.NoError(t, hec.HookError()) + assert.True(t, strings.Contains(hec.LoggerOutput().String(), "hello from hook")) +} + +// === helpers === + +type assertErr string + +func (a assertErr) Error() string { return string(a) } + +func nestedMap(obj map[string]any, fields ...string) map[string]string { + v := any(obj) + for _, f := range fields { + m, ok := v.(map[string]any) + if !ok { + return nil + } + v = m[f] + } + out := map[string]string{} + if m, ok := v.(map[string]any); ok { + for k, val := range m { + s, _ := val.(string) + out[k] = s + } + return out + } + return nil +} + +func nestedInt(obj map[string]any, fields ...string) (int64, bool) { + v := any(obj) + for _, f := range fields { + m, ok := v.(map[string]any) + if !ok { + return 0, false + } + v = m[f] + } + switch x := v.(type) { + case int64: + return x, true + case float64: + return int64(x), true + case int: + return int64(x), true + } + return 0, false +} diff --git a/testing/framework/options.go b/testing/framework/options.go new file mode 100644 index 00000000..ac8bd7c3 --- /dev/null +++ b/testing/framework/options.go @@ -0,0 +1,62 @@ +package framework + +import "k8s.io/apimachinery/pkg/runtime" + +// Option configures a HookExecutionConfig at construction time. +type Option interface { + apply(*execOptions) +} + +type optionFunc func(*execOptions) + +func (f optionFunc) apply(o *execOptions) { f(o) } + +// execOptions holds parsed framework options. +type execOptions struct { + initValues string + initConfigValues string + extraSchemeBuilders []runtime.SchemeBuilder + crds []customCRD +} + +type customCRD struct { + group string + version string + kind string + namespaced bool +} + +// WithInitialValues sets the initial Helm values JSON or YAML. +func WithInitialValues(v string) Option { + return optionFunc(func(o *execOptions) { + o.initValues = v + }) +} + +// WithInitialConfigValues sets the initial module config values JSON or YAML. +func WithInitialConfigValues(v string) Option { + return optionFunc(func(o *execOptions) { + o.initConfigValues = v + }) +} + +// WithSchemeBuilder registers an additional runtime.SchemeBuilder so that +// typed CRDs from your module can be used in YAML state and assertions. +func WithSchemeBuilder(builder runtime.SchemeBuilder) Option { + return optionFunc(func(o *execOptions) { + o.extraSchemeBuilders = append(o.extraSchemeBuilders, builder) + }) +} + +// 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 WithCRD(group, version, kind string, namespaced bool) Option { + return optionFunc(func(o *execOptions) { + o.crds = append(o.crds, customCRD{ + group: group, version: version, kind: kind, namespaced: namespaced, + }) + }) +} diff --git a/testing/framework/patches.go b/testing/framework/patches.go new file mode 100644 index 00000000..62d010a4 --- /dev/null +++ b/testing/framework/patches.go @@ -0,0 +1,135 @@ +package framework + +import ( + "sync" + + "github.com/deckhouse/module-sdk/pkg" +) + +// PatchType identifies a recorded patch operation kind. +type PatchType string + +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" +) + +// 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. +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 +} + +// recordingPatchCollector is a pkg.PatchCollector that records every call as +// a RecordedPatch. It also implements pkg.PatchCollectorOperation per record +// so that hooks see the operations they would normally see. +type recordingPatchCollector struct { + mu sync.Mutex + records []RecordedPatch +} + +func newRecordingPatchCollector() *recordingPatchCollector { + return &recordingPatchCollector{records: make([]RecordedPatch, 0)} +} + +var _ pkg.PatchCollector = (*recordingPatchCollector)(nil) + +func (c *recordingPatchCollector) add(r RecordedPatch) { + c.mu.Lock() + c.records = append(c.records, r) + c.mu.Unlock() +} + +func (c *recordingPatchCollector) Records() []RecordedPatch { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]RecordedPatch, len(c.records)) + copy(out, c.records) + return out +} + +func (c *recordingPatchCollector) Operations() []pkg.PatchCollectorOperation { + c.mu.Lock() + defer c.mu.Unlock() + ops := make([]pkg.PatchCollectorOperation, 0, len(c.records)) + for i := range c.records { + ops = append(ops, &c.records[i]) + } + return ops +} + +// pkg.PatchCollectorOperation implementation. +func (r *RecordedPatch) Description() string { return string(r.Type) } +func (r *RecordedPatch) SetObjectPrefix(prefix string) { + if prefix == "" || r.Name == "" { + return + } + r.Name = prefix + "-" + r.Name +} + +// === Create === +func (c *recordingPatchCollector) Create(object any) { + c.add(RecordedPatch{Type: PatchTypeCreate, Object: object}) +} +func (c *recordingPatchCollector) CreateIfNotExists(object any) { + c.add(RecordedPatch{Type: PatchTypeCreateIfNotExists, Object: object}) +} +func (c *recordingPatchCollector) CreateOrUpdate(object any) { + c.add(RecordedPatch{Type: PatchTypeCreateOrUpdate, Object: object}) +} + +// === Delete === +func (c *recordingPatchCollector) Delete(apiVersion, kind, namespace, name string) { + c.add(RecordedPatch{Type: PatchTypeDelete, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}) +} +func (c *recordingPatchCollector) DeleteInBackground(apiVersion, kind, namespace, name string) { + c.add(RecordedPatch{Type: PatchTypeDeleteInBackground, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}) +} +func (c *recordingPatchCollector) DeleteNonCascading(apiVersion, kind, namespace, name string) { + c.add(RecordedPatch{Type: PatchTypeDeleteNonCascading, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}) +} + +// === Patch === +func (c *recordingPatchCollector) JSONPatch(jsonPatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.add(RecordedPatch{Type: PatchTypeJSONPatch, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, JSONPatch: jsonPatch, Options: opts}) +} +func (c *recordingPatchCollector) MergePatch(mergePatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.add(RecordedPatch{Type: PatchTypeMergePatch, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, MergePatch: mergePatch, Options: opts}) +} +func (c *recordingPatchCollector) JQFilter(jqfilter, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.add(RecordedPatch{Type: PatchTypeJQFilter, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, JQFilter: jqfilter, Options: opts}) +} +func (c *recordingPatchCollector) PatchWithJSON(jsonPatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.add(RecordedPatch{Type: PatchTypeJSONPatch, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, JSONPatch: jsonPatch, Options: opts}) +} +func (c *recordingPatchCollector) PatchWithMerge(mergePatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.add(RecordedPatch{Type: PatchTypeMergePatch, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, MergePatch: mergePatch, Options: opts}) +} +func (c *recordingPatchCollector) PatchWithJQ(jqfilter, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.add(RecordedPatch{Type: PatchTypeJQFilter, APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, JQFilter: jqfilter, Options: opts}) +} diff --git a/testing/framework/run.go b/testing/framework/run.go new file mode 100644 index 00000000..4cefa9d6 --- /dev/null +++ b/testing/framework/run.go @@ -0,0 +1,81 @@ +package framework + +import ( + "context" + + "github.com/deckhouse/module-sdk/internal/metric" + "github.com/deckhouse/module-sdk/pkg" +) + +// 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 (h *HookExecutionConfig) RunHook() { + h.t.Helper() + h.RunHookCtx(context.Background()) +} + +// RunHookCtx is like RunHook but accepts an explicit context. +func (h *HookExecutionConfig) RunHookCtx(ctx context.Context) { + h.t.Helper() + h.hookError = nil + + snaps, err := h.generateSnapshots(ctx) + if err != nil { + h.t.Fatalf("framework: generate snapshots: %v", err) + } + h.snapshots = snaps + + patchableValues, err := patchableValuesFor(h.values) + if err != nil { + h.t.Fatalf("framework: build patchable values: %v", err) + } + patchableConfigValues, err := patchableValuesFor(h.configValues) + if err != nil { + h.t.Fatalf("framework: build patchable config values: %v", err) + } + + h.patchCollector = newRecordingPatchCollector() + h.metricsCollector = metric.NewCollector() + + if h.dc == nil { + h.dc = newFrameworkDC(h.fakeClient, h.scheme) + } + + input := &pkg.HookInput{ + Snapshots: h.snapshots, + Values: patchableValues, + ConfigValues: patchableConfigValues, + PatchCollector: h.patchCollector, + MetricsCollector: h.metricsCollector, + DC: h.dc, + Logger: h.logger, + } + + h.hookError = h.hookHandler(ctx, input) + + // Always merge values patches so callers can assert both happy and error + // paths. + if err := h.values.applyPatchOperations(patchableValues.GetPatches()); err != nil { + h.t.Fatalf("framework: apply values patches: %v", err) + } + if err := h.configValues.applyPatchOperations(patchableConfigValues.GetPatches()); err != nil { + h.t.Fatalf("framework: apply config values patches: %v", err) + } + + if h.hookError == nil { + if err := h.applyPatchesToCluster(); err != nil { + h.t.Fatalf("framework: apply collected patches: %v", err) + } + } +} diff --git a/testing/framework/snapshots.go b/testing/framework/snapshots.go new file mode 100644 index 00000000..252bb56f --- /dev/null +++ b/testing/framework/snapshots.go @@ -0,0 +1,193 @@ +package framework + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + + "github.com/deckhouse/module-sdk/pkg" + sdkjq "github.com/deckhouse/module-sdk/pkg/jq" +) + +// generateSnapshots builds snapshots for every KubernetesConfig binding in +// the hook config based on the current fake-cluster state. +// +// For each binding it: +// - resolves the GVR from APIVersion/Kind, +// - lists matching objects (filtered by NameSelector / NamespaceSelector / +// LabelSelector), +// - applies the JqFilter (if any) to each matched object, +// - stores the JSON result as a snapshot under the binding's Name. +func (h *HookExecutionConfig) generateSnapshots(ctx context.Context) (snapshotsMap, error) { + out := snapshotsMap{} + if h.hookConfig == nil { + return out, nil + } + + for _, b := range h.hookConfig.Kubernetes { + // Allow empty APIVersion (defaults to "v1"). + apiVersion := b.APIVersion + if apiVersion == "" { + apiVersion = "v1" + } + + gvr, err := h.gvrFor(apiVersion, b.Kind) + if err != nil { + return nil, fmt.Errorf("binding %q: %w", b.Name, err) + } + + listOpts := metav1.ListOptions{} + if b.LabelSelector != nil { + sel, err := metav1.LabelSelectorAsSelector(b.LabelSelector) + if err != nil { + return nil, fmt.Errorf("binding %q: parse label selector: %w", b.Name, err) + } + listOpts.LabelSelector = sel.String() + } + + // Determine which namespaces to inspect. + namespaces, err := h.namespacesForBinding(ctx, &b) + if err != nil { + return nil, fmt.Errorf("binding %q: %w", b.Name, err) + } + + var matched []unstructured.Unstructured + for _, ns := range namespaces { + list, err := h.resourceInterface(gvr, ns).List(ctx, listOpts) + if err != nil { + return nil, fmt.Errorf("binding %q: list %s in %q: %w", b.Name, gvr.Resource, ns, err) + } + for _, item := range list.Items { + if !matchesNameSelector(item.GetName(), b.NameSelector) { + continue + } + if !matchesFieldSelector(&item, b.FieldSelector) { + continue + } + matched = append(matched, item) + } + } + + var compiledJQ *sdkjq.Query + if jqExpr := strings.TrimSpace(b.JqFilter); jqExpr != "" { + compiledJQ, err = sdkjq.NewQuery(jqExpr) + if err != nil { + return nil, fmt.Errorf("binding %q: compile jq filter %q: %w", b.Name, b.JqFilter, err) + } + } + + snaps := make([]pkg.Snapshot, 0, len(matched)) + for _, obj := range matched { + snap, err := buildSnapshot(ctx, &obj, compiledJQ) + if err != nil { + return nil, fmt.Errorf("binding %q: build snapshot for %s/%s: %w", b.Name, obj.GetNamespace(), obj.GetName(), err) + } + snaps = append(snaps, snap) + } + out[b.Name] = snaps + } + return out, nil +} + +// namespacesForBinding returns the list of namespaces to scan for a given +// KubernetesConfig binding. Returns [""] for cluster-scoped queries. +// +// Rules: +// - NamespaceSelector == nil OR NameSelector matching no namespaces → []string{""} +// (means "list everywhere"; the fake client treats Namespace("") as cluster-wide). +// - NamespaceSelector.NameSelector populated → those exact namespaces. +// - NamespaceSelector.LabelSelector populated → list namespaces, filter by label. +func (h *HookExecutionConfig) namespacesForBinding(ctx context.Context, b *pkg.KubernetesConfig) ([]string, error) { + if b.NamespaceSelector == nil { + return []string{""}, nil + } + if ns := b.NamespaceSelector.NameSelector; ns != nil && len(ns.MatchNames) > 0 { + return ns.MatchNames, nil + } + if b.NamespaceSelector.LabelSelector != nil { + gvr, err := h.gvrFor("v1", "Namespace") + if err != nil { + return nil, fmt.Errorf("resolve namespace gvr: %w", err) + } + sel, err := metav1.LabelSelectorAsSelector(b.NamespaceSelector.LabelSelector) + if err != nil { + return nil, fmt.Errorf("parse namespace label selector: %w", err) + } + list, err := h.resourceInterface(gvr, "").List(ctx, metav1.ListOptions{LabelSelector: sel.String()}) + if err != nil { + return nil, fmt.Errorf("list namespaces: %w", err) + } + out := make([]string, 0, len(list.Items)) + for _, n := range list.Items { + out = append(out, n.GetName()) + } + return out, nil + } + return []string{""}, nil +} + +func matchesNameSelector(name string, sel *pkg.NameSelector) bool { + if sel == nil || len(sel.MatchNames) == 0 { + return true + } + for _, n := range sel.MatchNames { + if n == name { + return true + } + } + return false +} + +func matchesFieldSelector(obj *unstructured.Unstructured, sel *pkg.FieldSelector) bool { + if sel == nil || len(sel.MatchExpressions) == 0 { + return true + } + for _, expr := range sel.MatchExpressions { + val, _, _ := unstructured.NestedString(obj.Object, splitFieldPath(expr.Field)...) + if !matchFieldOperator(val, expr.Operator, expr.Value) { + return false + } + } + return true +} + +func splitFieldPath(field string) []string { + field = strings.TrimPrefix(field, ".") + return strings.Split(field, ".") +} + +func matchFieldOperator(value, op, target string) bool { + switch op { + case "Equals", "=", "==": + return value == target + case "NotEquals", "!=": + return value != target + } + return false +} + +// buildSnapshot serialises an object (optionally through a JQ filter) into a +// pkg.Snapshot. +func buildSnapshot(ctx context.Context, obj *unstructured.Unstructured, q *sdkjq.Query) (pkg.Snapshot, error) { + rawJSON, err := json.Marshal(obj.UnstructuredContent()) + if err != nil { + return nil, fmt.Errorf("marshal object: %w", err) + } + if q == nil { + return rawSnapshot(rawJSON), nil + } + res, err := q.FilterStringObject(ctx, string(rawJSON)) + if err != nil { + return nil, fmt.Errorf("apply jq: %w", err) + } + return rawSnapshot([]byte(res.String())), nil +} + +// silenceUnusedLabels keeps the labels import alive (used by some helpers +// during future expansion). +var _ = labels.Everything diff --git a/testing/framework/state.go b/testing/framework/state.go new file mode 100644 index 00000000..98b47a3a --- /dev/null +++ b/testing/framework/state.go @@ -0,0 +1,152 @@ +package framework + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" +) + +// 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 (h *HookExecutionConfig) KubeStateSet(yamlState string) { + h.t.Helper() + + objs, err := parseYAMLDocuments(yamlState) + if err != nil { + h.t.Fatalf("framework: parse kube state: %v", err) + } + + h.resetCluster() + + ctx := context.Background() + for i := range objs { + obj := &objs[i] + gvr, err := h.gvrFor(obj.GetAPIVersion(), obj.GetKind()) + if err != nil { + h.t.Fatalf("framework: cannot resolve GVR for %s/%s: %v", obj.GetAPIVersion(), obj.GetKind(), err) + } + + ri := h.resourceInterface(gvr, obj.GetNamespace()) + _, err = ri.Create(ctx, obj, metav1.CreateOptions{}) + if err == nil { + continue + } + if apierrors.IsAlreadyExists(err) { + if _, uerr := ri.Update(ctx, obj, metav1.UpdateOptions{}); uerr != nil { + h.t.Fatalf("framework: update %s %s/%s: %v", obj.GetKind(), obj.GetNamespace(), obj.GetName(), uerr) + } + continue + } + h.t.Fatalf("framework: create %s %s/%s: %v", obj.GetKind(), obj.GetNamespace(), obj.GetName(), err) + } +} + +// AddKubeObject appends one or more objects (multi-document YAML) to the fake +// cluster without resetting existing state. +func (h *HookExecutionConfig) AddKubeObject(yamlObject string) { + h.t.Helper() + objs, err := parseYAMLDocuments(yamlObject) + if err != nil { + h.t.Fatalf("framework: parse object: %v", err) + } + ctx := context.Background() + for i := range objs { + obj := &objs[i] + gvr, err := h.gvrFor(obj.GetAPIVersion(), obj.GetKind()) + if err != nil { + h.t.Fatalf("framework: cannot resolve GVR for %s/%s: %v", obj.GetAPIVersion(), obj.GetKind(), err) + } + _, err = h.resourceInterface(gvr, obj.GetNamespace()).Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + h.t.Fatalf("framework: create %s %s/%s: %v", obj.GetKind(), obj.GetNamespace(), obj.GetName(), err) + } + } +} + +// resetCluster wipes the fake client's tracker by rebuilding it with the same +// scheme and ListKind mapping. +func (h *HookExecutionConfig) resetCluster() { + h.fakeClient = dynamicfake.NewSimpleDynamicClientWithCustomListKinds(h.unstructuredScheme, h.gvrToListKind) +} + +// resourceInterface returns the namespaced or cluster-scoped resource client +// for a given GVR. +func (h *HookExecutionConfig) resourceInterface(gvr schema.GroupVersionResource, namespace string) dynamic.ResourceInterface { + r := h.fakeClient.Resource(gvr) + if namespace == "" { + return r + } + return r.Namespace(namespace) +} + +// parseYAMLDocuments splits a multi-document YAML string into Unstructured +// objects, ignoring empty documents. +func parseYAMLDocuments(in string) ([]unstructured.Unstructured, error) { + in = strings.TrimSpace(in) + if in == "" { + return nil, nil + } + + reader := yaml.NewYAMLOrJSONDecoder(strings.NewReader(in), 4096) + + var out []unstructured.Unstructured + for { + raw := map[string]any{} + if err := reader.Decode(&raw); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("decode yaml: %w", err) + } + if len(raw) == 0 { + continue + } + out = append(out, unstructured.Unstructured{Object: raw}) + } + return out, nil +} + +// 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 (h *HookExecutionConfig) KubernetesResource(kind, namespace, name string) *unstructured.Unstructured { + h.t.Helper() + gvr, err := h.resolveGVRForKind(kind) + if err != nil { + h.t.Fatalf("framework: KubernetesResource: %v", err) + } + obj, err := h.resourceInterface(gvr, namespace).Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil + } + h.t.Fatalf("framework: get %s/%s: %v", namespace, name, err) + } + return obj +} + +// KubernetesGlobalResource returns a cluster-scoped resource by kind and name. +// Returns nil if not found. +func (h *HookExecutionConfig) KubernetesGlobalResource(kind, name string) *unstructured.Unstructured { + return h.KubernetesResource(kind, "", name) +} diff --git a/testing/framework/values.go b/testing/framework/values.go new file mode 100644 index 00000000..cdd1d83d --- /dev/null +++ b/testing/framework/values.go @@ -0,0 +1,254 @@ +package framework + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/tidwall/gjson" + k8syaml "sigs.k8s.io/yaml" + + "github.com/deckhouse/module-sdk/internal/testutils" + patchablevalues "github.com/deckhouse/module-sdk/pkg/patchable-values" + sdkutils "github.com/deckhouse/module-sdk/pkg/utils" +) + +// valuesStore is a thin wrapper around a values map. It keeps both the +// canonical map[string]any and a derived JSON representation (regenerated +// lazily after mutations). +type valuesStore struct { + values map[string]any +} + +// newValuesStore parses initial values from a JSON or YAML string. +// Empty strings are treated as "{}". +func newValuesStore(raw string) (*valuesStore, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + raw = "{}" + } + var v map[string]any + if err := k8syaml.Unmarshal([]byte(raw), &v); err != nil { + return nil, fmt.Errorf("unmarshal values: %w", err) + } + if v == nil { + v = map[string]any{} + } + return &valuesStore{values: v}, nil +} + +// JSON returns the values as a JSON string. +func (v *valuesStore) JSON() []byte { + data, err := json.Marshal(v.values) + if err != nil { + // the store always holds a JSON-serializable map[string]any + panic(fmt.Errorf("marshal values store: %w", err)) + } + return data +} + +// Map returns a deep clone of the values map (suitable for passing to +// patchable-values, which mutates). +func (v *valuesStore) Map() map[string]any { + data := v.JSON() + out := map[string]any{} + _ = json.Unmarshal(data, &out) + return out +} + +// Get reads a dotted path using gjson. +func (v *valuesStore) Get(path string) gjson.Result { + return gjson.GetBytes(v.JSON(), path) +} + +// SetByPath sets a value at a dotted path. Intermediate maps are created on demand. +func (v *valuesStore) SetByPath(path string, value any) { + parts := splitPath(path) + setNested(v.values, parts, value) +} + +// SetByYAML parses a YAML string and sets it at path. +func (v *valuesStore) SetByYAML(path string, raw []byte) error { + var parsed any + if err := k8syaml.Unmarshal(raw, &parsed); err != nil { + return fmt.Errorf("unmarshal yaml: %w", err) + } + v.SetByPath(path, parsed) + return nil +} + +// DeleteByPath removes a path (no-op if it doesn't exist). +func (v *valuesStore) DeleteByPath(path string) { + parts := splitPath(path) + deleteNested(v.values, parts) +} + +// applyPatchOperations applies a list of utils.ValuesPatchOperation (RFC6902) +// to the current values and updates v.values in-place. +// +// Unlike a strict RFC6902 implementation, intermediate object paths missing +// from the document are created on the fly for "add" / "replace" operations. +// This matches the developer expectation of input.Values.Set("a.b.c", x) +// just working on an empty document. +func (v *valuesStore) applyPatchOperations(ops []*sdkutils.ValuesPatchOperation) error { + if len(ops) == 0 { + return nil + } + + for _, op := range ops { + if err := v.applyOne(op); err != nil { + return err + } + } + return nil +} + +// applyOne handles a single JSON-Patch-like operation. We support add, replace, +// and remove with create-on-demand semantics. +func (v *valuesStore) applyOne(op *sdkutils.ValuesPatchOperation) error { + parts, err := splitJSONPath(op.Path) + if err != nil { + return err + } + + switch op.Op { + case "add", "replace": + var decoded any + if len(op.Value) > 0 { + if err := json.Unmarshal(op.Value, &decoded); err != nil { + return fmt.Errorf("decode value at %q: %w", op.Path, err) + } + } + setNested(v.values, parts, decoded) + return nil + case "remove": + deleteNested(v.values, parts) + return nil + default: + // Fall back to the testutils JSON-Patch applier for operations we + // don't natively support (copy, move, test). + patched, _, err := testutils.ApplyValuesPatch( + testutils.Values(v.values), + testutils.ValuesPatch{Operations: []*sdkutils.ValuesPatchOperation{op}}, + testutils.IgnoreNonExistentPaths, + ) + if err != nil { + return fmt.Errorf("apply json-patch: %w", err) + } + v.values = map[string]any(patched) + return nil + } +} + +// splitJSONPath converts a JSON-Pointer path like "/a/b/c" into ["a","b","c"]. +// JSON-Pointer escapes "~" → "~0", "/" → "~1". +func splitJSONPath(p string) ([]string, error) { + if p == "" || p == "/" { + return nil, nil + } + if p[0] != '/' { + return nil, fmt.Errorf("invalid json-pointer %q (must start with '/')", p) + } + parts := strings.Split(p[1:], "/") + for i, s := range parts { + s = strings.ReplaceAll(s, "~1", "/") + s = strings.ReplaceAll(s, "~0", "~") + parts[i] = s + } + return parts, nil +} + +// splitPath splits a dotted path into segments. Empty input returns nil. +func splitPath(p string) []string { + if p == "" { + return nil + } + return strings.Split(p, ".") +} + +func setNested(m map[string]any, parts []string, value any) { + if len(parts) == 0 { + return + } + if len(parts) == 1 { + m[parts[0]] = value + return + } + next, ok := m[parts[0]].(map[string]any) + if !ok { + next = map[string]any{} + m[parts[0]] = next + } + setNested(next, parts[1:], value) +} + +func deleteNested(m map[string]any, parts []string) { + if len(parts) == 0 { + return + } + if len(parts) == 1 { + delete(m, parts[0]) + return + } + next, ok := m[parts[0]].(map[string]any) + if !ok { + return + } + deleteNested(next, parts[1:]) +} + +// patchableSnapshot is a small helper to grab final patches from a +// patchablevalues.PatchableValues without exposing it directly to the user. +func patchableValuesFor(v *valuesStore) (*patchablevalues.PatchableValues, error) { + return patchablevalues.NewPatchableValues(v.Map()) +} + +// ===== Public value accessors on HookExecutionConfig ===== + +// ValuesGet returns the current value at path (gjson dotted path). +func (h *HookExecutionConfig) ValuesGet(path string) gjson.Result { + return h.values.Get(path) +} + +// ConfigValuesGet returns the current config value at path. +func (h *HookExecutionConfig) ConfigValuesGet(path string) gjson.Result { + return h.configValues.Get(path) +} + +// ValuesSet sets a value at path. The value is written directly into the +// values store; it persists across RunHook calls. +func (h *HookExecutionConfig) ValuesSet(path string, value any) { + h.values.SetByPath(path, value) +} + +// ConfigValuesSet sets a config value at path. +func (h *HookExecutionConfig) ConfigValuesSet(path string, value any) { + h.configValues.SetByPath(path, value) +} + +// ValuesSetFromYaml parses YAML and sets the result at path. +func (h *HookExecutionConfig) ValuesSetFromYaml(path string, raw []byte) { + if err := h.values.SetByYAML(path, raw); err != nil { + h.t.Fatalf("framework: ValuesSetFromYaml: %v", err) + } +} + +// ConfigValuesSetFromYaml parses YAML and sets the result at path. +func (h *HookExecutionConfig) ConfigValuesSetFromYaml(path string, raw []byte) { + if err := h.configValues.SetByYAML(path, raw); err != nil { + h.t.Fatalf("framework: ConfigValuesSetFromYaml: %v", err) + } +} + +// ValuesDelete removes a value at path. +func (h *HookExecutionConfig) ValuesDelete(path string) { h.values.DeleteByPath(path) } + +// ConfigValuesDelete removes a config value at path. +func (h *HookExecutionConfig) ConfigValuesDelete(path string) { h.configValues.DeleteByPath(path) } + +// ValuesJSON returns the current values as a JSON string. Mostly useful for +// debugging or asserting full document state. +func (h *HookExecutionConfig) ValuesJSON() []byte { return h.values.JSON() } + +// ConfigValuesJSON returns the current config values as a JSON string. +func (h *HookExecutionConfig) ConfigValuesJSON() []byte { return h.configValues.JSON() } diff --git a/testing/helpers/README.md b/testing/helpers/README.md new file mode 100644 index 00000000..29077321 --- /dev/null +++ b/testing/helpers/README.md @@ -0,0 +1,164 @@ +# `testing/helpers` — small building blocks for hook unit tests + +`testing/helpers` is the **unit-test layer** of the Module SDK testing toolkit. Where [`testing/framework`](../framework) runs hooks against a fake Kubernetes cluster, helpers stay much closer to the metal: + +- `InputBuilder` — assembles a `*pkg.HookInput` with sensible defaults. +- `StaticSnapshots` — in-memory `pkg.Snapshots` backed by JSON / YAML / Go values. +- `RecordingPatchCollector` — `pkg.PatchCollector` that records every call for later inspection. +- `NewValues*` — real `pkg.PatchableValuesCollector` seeded from a JSON / YAML / map. +- `JQRunOnString` / `JQRunOnObject` — apply a JQ filter and decode the result in one call. + +These helpers are deliberately small and orthogonal — pick the ones you need and ignore the rest. + +## When to use it + +Use helpers for **unit tests** that focus on a single hook handler: + +- you know exactly which snapshots / values / patches the hook should see; +- you want to run a hook in microseconds without touching the fake K8s cluster; +- you are testing a JQ filter or a small piece of hook logic in isolation. + +For **functional tests** that drive the whole pipeline (cluster YAML → snapshots → hook → cluster mutations), reach for [`testing/framework`](../framework) instead. + +## Quick start + +```go +package myhook_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/module-sdk/testing/helpers" + myhook "example.com/mymodule" +) + +func TestMyHook(t *testing.T) { + in := helpers.NewInputBuilder(t). + WithSnapshot("nodes", + helpers.SnapshotJSON(`{"name":"node-a"}`), + helpers.SnapshotJSON(`{"name":"node-b"}`), + ). + WithValuesJSON(`{"my":{"existing":"value"}}`). + WithConfigValuesJSON(`{"module":{"enabled":true}}`). + WithRecordingPatchCollector(). + WithCapturedLogger(). + Build() + + require.NoError(t, myhook.Handler(context.Background(), in)) + + // Values + assert.Equal(t, "value", in.Values.Get("my.existing").String()) + require.Len(t, in.Values.GetPatches(), 1) + + // PatchCollector + pc := /* the same builder */ .RecordingPatchCollector() + require.Len(t, pc.Recorded(), 2) + assert.Equal(t, "Create", pc.Recorded()[0].Op) + + // Logs + assert.Contains(t, /* builder */ .LogBuffer().String(), "expected log line") +} +``` + +## API at a glance + +### `InputBuilder` + +```go +b := helpers.NewInputBuilder(t) + +b.WithSnapshot("nodes", helpers.SnapshotJSON(`{...}`)) // append +b.WithSnapshots(helpers.NewSnapshots()) // replace map +b.WithValuesJSON(`{}`) // or YAML / map +b.WithConfigValuesJSON(`{}`) // or YAML +b.WithRecordingPatchCollector() // typed RecordingPatchCollector +b.WithPatchCollector(myMock) // any pkg.PatchCollector +b.WithMetricsCollector(myMock) // any pkg.MetricsCollector +b.WithDependencyContainer(myDC) // any pkg.DependencyContainer +b.WithLogger(myLogger) // any pkg.Logger +b.WithCapturedLogger() // *log.Logger writing into a buffer + +in := b.Build() // *pkg.HookInput + +// Accessors after Build: +b.Snapshots() // StaticSnapshots +b.Values() // pkg.PatchableValuesCollector +b.ConfigValues() // pkg.PatchableValuesCollector +b.RecordingPatchCollector() // *RecordingPatchCollector or nil +b.LogBuffer() // *bytes.Buffer or nil +``` + +Defaults: empty snapshots, empty values + config values, a `RecordingPatchCollector`, `metric.NewCollector`, `log.NewNop()`. + +### Snapshots + +```go +helpers.NewSnapshots() // empty StaticSnapshots + .Add("k", helpers.SnapshotJSON(`{...}`)) // append + .Set("k", snaps...) // replace bucket + +helpers.SnapshotJSON(`{"name":"x"}`) // pkg.Snapshot from raw JSON +helpers.SnapshotYAML("name: x") // pkg.Snapshot from YAML +helpers.SnapshotFromObject(myStruct) // pkg.Snapshot from a Go value +helpers.SnapshotFromObjects([]MyType{...}) // []pkg.Snapshot +``` + +`StaticSnapshots` implements `pkg.Snapshots`, so you can pass it directly into a `*pkg.HookInput` if you don't want the builder. + +### Values + +```go +helpers.NewValues(map[string]any{...}) // real PatchableValues, with map seed +helpers.NewValuesFromJSON(`{"foo":{"bar":"baz"}}`) // same, from JSON +helpers.NewValuesFromYAML("foo:\n bar: baz\n") // same, from YAML + +helpers.MarshalValues(v) // JSON of v.GetPatches() +``` + +The store is a real `patchable-values.PatchableValues`, so: + +- `v.Get(path)` returns a real `gjson.Result` from the seeded data; +- `v.Set(path, value)` records a real `add` patch op available via `v.GetPatches()`; +- `v.Remove(path)` records a real `remove` patch op when the path exists. + +### `RecordingPatchCollector` + +```go +pc := helpers.NewRecordingPatchCollector() + +// hook calls pc.Create / pc.Delete / pc.PatchWith* … + +pc.Recorded() // []*RecordedOp in call order +pc.Filter("Delete", "DeleteInBackground") // subset by op name +pc.Operations() // []pkg.PatchCollectorOperation +``` + +Each `RecordedOp` has the relevant fields populated for its op type: +- `Op` — `"Create"`, `"CreateOrUpdate"`, `"CreateIfNotExists"`, `"Delete"`, `"DeleteInBackground"`, `"DeleteNonCascading"`, `"JSONPatch"`, `"MergePatch"`, `"JQFilter"`. +- `Object` — the object passed to `Create*`. +- `APIVersion`, `Kind`, `Namespace`, `Name` — for `Delete*` and `Patch*`. +- `Patch`, `JQFilter`, `Options` — for the patch operations. + +`RecordingPatchCollector` does **not** apply patches to anything — for that, use `testing/framework`. + +### JQ helpers + +```go +helpers.JQRunOnString(ctx, ".metadata.name", `{"metadata":{"name":"x"}}`, &out) +helpers.JQRunOnObject(ctx, ".spec.replicas", podSpec, &out) +``` + +Both compile the filter, run it against the input, and JSON-decode the result into `out` (which must be a non-nil pointer). + +## Real-world examples in this repo + +- [`testing/helpers/helpers_test.go`](./helpers_test.go) — exhaustive helper tests, doubles as documentation. +- [`common-hooks/copy-custom-certificate/hook_test.go`](../../common-hooks/copy-custom-certificate/hook_test.go), [`tls-certificate/order_certificate_test.go`](../../common-hooks/tls-certificate/order_certificate_test.go) — JQ filter tests. +- [`common-hooks/tls-certificate/internal_tls_test.go`](../../common-hooks/tls-certificate/internal_tls_test.go) — `InputBuilder` + real values store driving an entire certificate-rotation flow. +- [`examples/example-module/hooks/subfolder/patch_hook_test.go`](../../examples/example-module/hooks/subfolder/patch_hook_test.go) — `RecordingPatchCollector` asserting on op sequence. +- [`examples/example-module/hooks/subfolder/values_getting_hook_test.go`](../../examples/example-module/hooks/subfolder/values_getting_hook_test.go) — mix of helpers (happy path) and `mock.OutputPatchableValuesCollectorMock` (error paths). +- [`examples/dependency-example-module/hooks/subfolder/http_client_hook_test.go`](../../examples/dependency-example-module/hooks/subfolder/http_client_hook_test.go) — small `httpDC` factory wired through `InputBuilder.WithDependencyContainer`. diff --git a/testing/helpers/doc.go b/testing/helpers/doc.go new file mode 100644 index 00000000..20664f59 --- /dev/null +++ b/testing/helpers/doc.go @@ -0,0 +1,31 @@ +// Package helpers provides building blocks for hook unit tests. +// +// It complements the heavier-weight testing/framework package: where the +// framework spins up a fake Kubernetes cluster and exercises the full hook +// pipeline (snapshots → handler → patches), the helpers in this package +// are aimed at small, focused unit tests that mock just the dependencies +// the hook touches. +// +// Typical usage: +// +// func TestMyHook(t *testing.T) { +// in := helpers.NewInputBuilder(t). +// WithSnapshot("nodes", helpers.SnapshotJSON(`{"name":"n1"}`)). +// WithValuesJSON(`{"my":{"field":"value"}}`). +// Build() +// +// err := MyHookHandler(context.Background(), in) +// require.NoError(t, err) +// require.Equal(t, "value", in.Values.Get("my.field").String()) +// } +// +// Helpers are intentionally minimal and orthogonal: +// +// - InputBuilder - assembles a *pkg.HookInput / *pkg.ApplicationHookInput. +// - StaticSnapshots - in-memory pkg.Snapshots backed by JSON literals. +// - JQRun - apply a JQ filter to JSON or to a Go value. +// - PreparePatchCollector - construct a patch collector mock with sane defaults. +// - PatchOperations - decode the operations a hook recorded on a PatchCollector. +// +// All helpers play nicely with both *testing.T and Ginkgo's GinkgoT(). +package helpers diff --git a/testing/helpers/helpers_test.go b/testing/helpers/helpers_test.go new file mode 100644 index 00000000..d03bb05f --- /dev/null +++ b/testing/helpers/helpers_test.go @@ -0,0 +1,209 @@ +package helpers_test + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/module-sdk/pkg" + objectpatch "github.com/deckhouse/module-sdk/pkg/object-patch" + "github.com/deckhouse/module-sdk/testing/helpers" +) + +func TestStaticSnapshots_AddAndUnmarshal(t *testing.T) { + type Item struct { + Name string `json:"name"` + } + + snaps := helpers.NewSnapshots(). + Add("things", + helpers.SnapshotJSON(`{"name":"a"}`), + helpers.SnapshotYAML("name: b"), + ). + Add("things", helpers.SnapshotFromObject(Item{Name: "c"})) + + got, err := objectpatch.UnmarshalToStruct[Item](snaps, "things") + require.NoError(t, err) + assert.Equal(t, []Item{{Name: "a"}, {Name: "b"}, {Name: "c"}}, got) +} + +func TestStaticSnapshots_FromObjects(t *testing.T) { + type Item struct{ Name string } + + source := []Item{{Name: "x"}, {Name: "y"}} + snaps := helpers.NewSnapshots().Set("k", helpers.SnapshotFromObjects(source)...) + + got := snaps.Get("k") + require.Len(t, got, 2) + assert.JSONEq(t, `{"Name":"x"}`, got[0].String()) +} + +func TestStaticSnapshots_GetUnknownKey(t *testing.T) { + snaps := helpers.NewSnapshots() + assert.Empty(t, snaps.Get("missing")) +} + +func TestNewValuesFromJSON_RoundTrip(t *testing.T) { + v := helpers.NewValuesFromJSON(`{"foo":{"bar":"baz"}}`) + assert.Equal(t, "baz", v.Get("foo.bar").String()) + + v.Set("foo.qux", "quux") + assert.Len(t, v.GetPatches(), 1) + + op := v.GetPatches()[0] + assert.Equal(t, "add", op.Op) + assert.Equal(t, "/foo/qux", op.Path) +} + +func TestNewValuesFromYAML_Empty(t *testing.T) { + v := helpers.NewValuesFromYAML("") + assert.False(t, v.Exists("anything")) +} + +func TestRecordingPatchCollector_RecordsAllOps(t *testing.T) { + pc := helpers.NewRecordingPatchCollector() + + pc.Create(map[string]any{"kind": "Pod", "metadata": map[string]any{"name": "a"}}) + pc.CreateOrUpdate(map[string]any{"kind": "Pod"}) + pc.CreateIfNotExists(map[string]any{"kind": "Pod"}) + pc.Delete("v1", "Pod", "default", "to-delete") + pc.DeleteInBackground("v1", "Pod", "default", "bg") + pc.DeleteNonCascading("v1", "Pod", "default", "nc") + pc.PatchWithMerge(map[string]any{"x": 1}, "v1", "Pod", "default", "p", objectpatch.WithIgnoreMissingObject(true)) + pc.PatchWithJSON([]map[string]any{{"op": "add", "path": "/x", "value": 1}}, "v1", "Pod", "default", "p") + pc.PatchWithJQ(`.x = 1`, "v1", "Pod", "default", "p") + + recorded := pc.Recorded() + require.Len(t, recorded, 9) + + gotOps := make([]string, 0, len(recorded)) + for _, op := range recorded { + gotOps = append(gotOps, op.Op) + } + assert.Equal(t, []string{ + "Create", "CreateOrUpdate", "CreateIfNotExists", + "Delete", "DeleteInBackground", "DeleteNonCascading", + "MergePatch", "JSONPatch", "JQFilter", + }, gotOps) +} + +func TestRecordingPatchCollector_Filter(t *testing.T) { + pc := helpers.NewRecordingPatchCollector() + pc.Create(map[string]any{"kind": "Pod"}) + pc.Delete("v1", "Pod", "ns", "a") + pc.DeleteInBackground("v1", "Pod", "ns", "b") + + deletes := pc.Filter("Delete", "DeleteInBackground") + require.Len(t, deletes, 2) + assert.Equal(t, "a", deletes[0].Name) + assert.Equal(t, "b", deletes[1].Name) +} + +func TestRecordingPatchCollector_OperationsInterface(t *testing.T) { + pc := helpers.NewRecordingPatchCollector() + pc.Delete("v1", "Pod", "ns", "x") + + ops := pc.Operations() + require.Len(t, ops, 1) + assert.Equal(t, "Delete", ops[0].Description()) + + ops[0].SetObjectPrefix("test") + require.Len(t, pc.Recorded(), 1) + assert.Equal(t, "test-x", pc.Recorded()[0].Name) +} + +func TestInputBuilder_DefaultsAreUsable(t *testing.T) { + in := helpers.NewInputBuilder(t).Build() + + require.NotNil(t, in.Snapshots) + require.NotNil(t, in.Values) + require.NotNil(t, in.ConfigValues) + require.NotNil(t, in.PatchCollector) + require.NotNil(t, in.MetricsCollector) + require.NotNil(t, in.Logger) + + in.Values.Set("a.b", "c") + require.Len(t, in.Values.GetPatches(), 1) + in.PatchCollector.Create(map[string]any{"kind": "Pod"}) + require.Len(t, in.PatchCollector.Operations(), 1) +} + +func TestInputBuilder_FluentChain(t *testing.T) { + type Item struct { + Name string `json:"name"` + } + + b := helpers.NewInputBuilder(t). + WithSnapshot("items", + helpers.SnapshotJSON(`{"name":"first"}`), + helpers.SnapshotJSON(`{"name":"second"}`), + ). + WithValuesJSON(`{"my":{"val":1}}`). + WithConfigValuesYAML("module:\n enabled: true\n"). + WithCapturedLogger(). + WithRecordingPatchCollector() + + in := b.Build() + + require.NotNil(t, b.LogBuffer()) + require.NotNil(t, b.RecordingPatchCollector()) + + items, err := unmarshalSnapshots[Item](in.Snapshots, "items") + require.NoError(t, err) + assert.Equal(t, []Item{{Name: "first"}, {Name: "second"}}, items) + + assert.Equal(t, int64(1), in.Values.Get("my.val").Int()) + assert.True(t, in.ConfigValues.Get("module.enabled").Bool()) + + in.Logger.Info("hello") + assert.Contains(t, b.LogBuffer().String(), "hello") + + in.PatchCollector.Create(map[string]any{"kind": "Pod"}) + require.Len(t, b.RecordingPatchCollector().Recorded(), 1) +} + +func TestJQRunOnString_AndObject(t *testing.T) { + const filter = `{name: .metadata.name, count: (.spec.replicas // 0)}` + const input = `{"metadata":{"name":"deploy"},"spec":{"replicas":3}}` + + type result struct { + Name string `json:"name"` + Count int `json:"count"` + } + + t.Run("string input", func(t *testing.T) { + var got result + require.NoError(t, helpers.JQRunOnString(context.Background(), filter, input, &got)) + assert.Equal(t, result{Name: "deploy", Count: 3}, got) + }) + + t.Run("object input", func(t *testing.T) { + var got result + var asMap map[string]any + require.NoError(t, json.Unmarshal([]byte(input), &asMap)) + require.NoError(t, helpers.JQRunOnObject(context.Background(), filter, asMap, &got)) + assert.Equal(t, result{Name: "deploy", Count: 3}, got) + }) + + t.Run("nil target rejected", func(t *testing.T) { + err := helpers.JQRunOnString(context.Background(), filter, input, nil) + require.Error(t, err) + }) +} + +// unmarshalSnapshots is a tiny test helper to avoid importing object-patch +// in every assertion above. +func unmarshalSnapshots[T any](s pkg.Snapshots, key string) ([]T, error) { + out := []T{} + for _, snap := range s.Get(key) { + var v T + if err := snap.UnmarshalTo(&v); err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil +} diff --git a/testing/helpers/input_builder.go b/testing/helpers/input_builder.go new file mode 100644 index 00000000..5af27450 --- /dev/null +++ b/testing/helpers/input_builder.go @@ -0,0 +1,206 @@ +package helpers + +import ( + "bytes" + "testing" + + "github.com/deckhouse/deckhouse/pkg/log" + + "github.com/deckhouse/module-sdk/internal/metric" + "github.com/deckhouse/module-sdk/pkg" +) + +// InputBuilder is a fluent builder for *pkg.HookInput. It bundles together +// the most common boilerplate of unit tests: +// +// - StaticSnapshots seeded with JSON / YAML / Go-value snapshots +// - real PatchableValuesCollector for Values / ConfigValues +// - a RecordingPatchCollector for assertions +// - a real metric.Collector +// - a logger that can either be silent or write to a captured buffer +// +// Anything you don't configure has a sensible default, so a zero-config +// builder still produces a usable HookInput. +type InputBuilder struct { + tb testing.TB + + snapshots StaticSnapshots + values pkg.PatchableValuesCollector + config pkg.PatchableValuesCollector + patch pkg.PatchCollector + metrics pkg.MetricsCollector + dc pkg.DependencyContainer + + logger pkg.Logger + logBuffer *bytes.Buffer + + // Set when the user explicitly provided a custom collector, + // so we keep the typed reference for accessor methods. + recordingPC *RecordingPatchCollector +} + +// NewInputBuilder returns a builder bound to the given testing.TB. The TB +// is currently used for failing the test if an internal helper call fails; +// pass nil from non-test code. +func NewInputBuilder(tb testing.TB) *InputBuilder { + return &InputBuilder{ + tb: tb, + snapshots: NewSnapshots(), + } +} + +// WithSnapshot adds one or more snapshots under the given key. May be +// called multiple times; snapshots are appended. +func (b *InputBuilder) WithSnapshot(key string, snaps ...pkg.Snapshot) *InputBuilder { + b.snapshots.Add(key, snaps...) + return b +} + +// WithSnapshots replaces the entire snapshots map with the provided one. +// Use this when you have an already-constructed StaticSnapshots. +func (b *InputBuilder) WithSnapshots(s StaticSnapshots) *InputBuilder { + b.snapshots = s + return b +} + +// WithValues replaces the values collector with the given one. By default, +// the builder constructs an empty PatchableValuesCollector lazily on Build. +func (b *InputBuilder) WithValues(v pkg.PatchableValuesCollector) *InputBuilder { + b.values = v + return b +} + +// WithValuesJSON seeds the values collector from a JSON string. +func (b *InputBuilder) WithValuesJSON(raw string) *InputBuilder { + b.values = NewValuesFromJSON(raw) + return b +} + +// WithValuesYAML seeds the values collector from a YAML string. +func (b *InputBuilder) WithValuesYAML(raw string) *InputBuilder { + b.values = NewValuesFromYAML(raw) + return b +} + +// WithValuesMap seeds the values collector from a Go map. +func (b *InputBuilder) WithValuesMap(m map[string]any) *InputBuilder { + b.values = NewValues(m) + return b +} + +// WithConfigValues replaces the config values collector. +func (b *InputBuilder) WithConfigValues(v pkg.PatchableValuesCollector) *InputBuilder { + b.config = v + return b +} + +// WithConfigValuesJSON seeds the config values collector from JSON. +func (b *InputBuilder) WithConfigValuesJSON(raw string) *InputBuilder { + b.config = NewValuesFromJSON(raw) + return b +} + +// WithConfigValuesYAML seeds the config values collector from YAML. +func (b *InputBuilder) WithConfigValuesYAML(raw string) *InputBuilder { + b.config = NewValuesFromYAML(raw) + return b +} + +// WithPatchCollector replaces the patch collector with a custom one (for +// example, a minimock-generated mock). +func (b *InputBuilder) WithPatchCollector(c pkg.PatchCollector) *InputBuilder { + b.patch = c + b.recordingPC = nil + return b +} + +// WithRecordingPatchCollector wires a fresh RecordingPatchCollector into +// the input. The collector itself is returned by RecordingPatchCollector +// after Build. +func (b *InputBuilder) WithRecordingPatchCollector() *InputBuilder { + b.recordingPC = NewRecordingPatchCollector() + b.patch = b.recordingPC + return b +} + +// WithMetricsCollector replaces the metrics collector. +func (b *InputBuilder) WithMetricsCollector(c pkg.MetricsCollector) *InputBuilder { + b.metrics = c + return b +} + +// WithDependencyContainer replaces the dependency container. +func (b *InputBuilder) WithDependencyContainer(dc pkg.DependencyContainer) *InputBuilder { + b.dc = dc + return b +} + +// WithLogger replaces the logger used by the hook. +func (b *InputBuilder) WithLogger(l pkg.Logger) *InputBuilder { + b.logger = l + b.logBuffer = nil + return b +} + +// WithCapturedLogger installs a *log.Logger writing into a private buffer. +// The buffer is accessible via LogBuffer after Build. +func (b *InputBuilder) WithCapturedLogger() *InputBuilder { + buf := bytes.NewBuffer(nil) + b.logger = log.NewLogger( + log.WithLevel(log.LevelDebug.Level()), + log.WithOutput(buf), + ) + b.logBuffer = buf + return b +} + +// Build assembles the *pkg.HookInput. Calling Build twice is allowed and +// returns the same shared values / patch collector references. +func (b *InputBuilder) Build() *pkg.HookInput { + if b.values == nil { + b.values = NewValues(nil) + } + if b.config == nil { + b.config = NewValues(nil) + } + if b.patch == nil { + b.recordingPC = NewRecordingPatchCollector() + b.patch = b.recordingPC + } + if b.metrics == nil { + b.metrics = metric.NewCollector() + } + if b.logger == nil { + b.logger = log.NewNop() + } + + return &pkg.HookInput{ + Snapshots: b.snapshots, + Values: b.values, + ConfigValues: b.config, + PatchCollector: b.patch, + MetricsCollector: b.metrics, + DC: b.dc, + Logger: b.logger, + } +} + +// Snapshots returns the StaticSnapshots backing the input. Useful if you +// want to add more snapshots after the input is built. +func (b *InputBuilder) Snapshots() StaticSnapshots { return b.snapshots } + +// Values returns the values collector that will be used by the input. +func (b *InputBuilder) Values() pkg.PatchableValuesCollector { return b.values } + +// ConfigValues returns the config values collector that will be used. +func (b *InputBuilder) ConfigValues() pkg.PatchableValuesCollector { return b.config } + +// RecordingPatchCollector returns the typed RecordingPatchCollector if +// one was attached via WithRecordingPatchCollector or implicitly by Build. +// Returns nil when the user supplied a different PatchCollector. +func (b *InputBuilder) RecordingPatchCollector() *RecordingPatchCollector { + return b.recordingPC +} + +// LogBuffer returns the buffer behind WithCapturedLogger or nil. +func (b *InputBuilder) LogBuffer() *bytes.Buffer { return b.logBuffer } diff --git a/testing/helpers/jq.go b/testing/helpers/jq.go new file mode 100644 index 00000000..58f8715c --- /dev/null +++ b/testing/helpers/jq.go @@ -0,0 +1,58 @@ +package helpers + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/deckhouse/module-sdk/pkg/jq" +) + +// JQRunOnString applies the given JQ filter to a JSON string and decodes +// the result into target. target must be a non-nil pointer. +// +// This is a small convenience over manually constructing a jq.Query — it +// keeps unit tests focused on the assertions instead of the boilerplate. +func JQRunOnString(ctx context.Context, filter, jsonInput string, target any) error { + if target == nil { + return fmt.Errorf("helpers.JQRunOnString: target must be non-nil") + } + + q, err := jq.NewQuery(filter) + if err != nil { + return fmt.Errorf("compile jq: %w", err) + } + + res, err := q.FilterStringObject(ctx, jsonInput) + if err != nil { + return fmt.Errorf("apply jq: %w", err) + } + + if err := json.Unmarshal([]byte(res.String()), target); err != nil { + return fmt.Errorf("decode jq result: %w", err) + } + return nil +} + +// JQRunOnObject applies the given JQ filter to a Go value (which must be +// JSON-serialisable) and decodes the result into target. +func JQRunOnObject(ctx context.Context, filter string, input, target any) error { + if target == nil { + return fmt.Errorf("helpers.JQRunOnObject: target must be non-nil") + } + + q, err := jq.NewQuery(filter) + if err != nil { + return fmt.Errorf("compile jq: %w", err) + } + + res, err := q.FilterObject(ctx, input) + if err != nil { + return fmt.Errorf("apply jq: %w", err) + } + + if err := json.Unmarshal([]byte(res.String()), target); err != nil { + return fmt.Errorf("decode jq result: %w", err) + } + return nil +} diff --git a/testing/helpers/patch_collector.go b/testing/helpers/patch_collector.go new file mode 100644 index 00000000..95fda661 --- /dev/null +++ b/testing/helpers/patch_collector.go @@ -0,0 +1,170 @@ +package helpers + +import ( + "sync" + + "github.com/deckhouse/module-sdk/pkg" +) + +// RecordedOp captures the parameters of a single PatchCollector call. +// +// The fields are populated only for the operation that is relevant for +// the type: +// +// - Op = "Create" / "CreateOrUpdate" / "CreateIfNotExists" → Object +// - Op = "Delete*" → APIVersion, Kind, Namespace, Name +// - Op = "JSONPatch" / "MergePatch" / "JQFilter" → APIVersion, Kind, Namespace, Name, Patch +// +// RecordedOp also implements pkg.PatchCollectorOperation so it can be used +// in code paths that expect that interface. +type RecordedOp struct { + Op string + + Object any + + APIVersion string + Kind string + Namespace string + Name string + + Patch any + JQFilter string + + Options []pkg.PatchCollectorOption +} + +// Description implements pkg.PatchCollectorOperation. +func (r *RecordedOp) Description() string { return r.Op } + +// SetObjectPrefix implements pkg.PatchCollectorOperation. It mirrors the +// real implementation by prefixing the recorded Name when set. +func (r *RecordedOp) SetObjectPrefix(prefix string) { + if prefix == "" || r.Name == "" { + return + } + r.Name = prefix + "-" + r.Name +} + +// RecordingPatchCollector is a pkg.PatchCollector that records every call +// and exposes the recorded operations for assertions. +// +// It is intentionally simple — no replay against a fake cluster, no +// validation. For full end-to-end testing prefer testing/framework. +type RecordingPatchCollector struct { + mu sync.Mutex + ops []*RecordedOp +} + +// NewRecordingPatchCollector returns an empty RecordingPatchCollector. +func NewRecordingPatchCollector() *RecordingPatchCollector { + return &RecordingPatchCollector{} +} + +var _ pkg.PatchCollector = (*RecordingPatchCollector)(nil) + +// Recorded returns a copy of the recorded operations in the order they +// were issued by the hook. +func (c *RecordingPatchCollector) Recorded() []*RecordedOp { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]*RecordedOp, len(c.ops)) + copy(out, c.ops) + return out +} + +// Operations implements pkg.PatchCollector. +func (c *RecordingPatchCollector) Operations() []pkg.PatchCollectorOperation { + c.mu.Lock() + defer c.mu.Unlock() + out := make([]pkg.PatchCollectorOperation, 0, len(c.ops)) + for _, op := range c.ops { + out = append(out, op) + } + return out +} + +// Filter returns the subset of recorded operations whose Op equals one of +// the provided values. It is a small convenience for assertions: +// +// deletes := pc.Filter("Delete", "DeleteInBackground") +func (c *RecordingPatchCollector) Filter(ops ...string) []*RecordedOp { + allowed := make(map[string]struct{}, len(ops)) + for _, o := range ops { + allowed[o] = struct{}{} + } + c.mu.Lock() + defer c.mu.Unlock() + out := make([]*RecordedOp, 0) + for _, op := range c.ops { + if _, ok := allowed[op.Op]; ok { + out = append(out, op) + } + } + return out +} + +func (c *RecordingPatchCollector) record(op *RecordedOp) { + c.mu.Lock() + c.ops = append(c.ops, op) + c.mu.Unlock() +} + +// Create implements pkg.PatchCollector. +func (c *RecordingPatchCollector) Create(object any) { + c.record(&RecordedOp{Op: "Create", Object: object}) +} + +// CreateIfNotExists implements pkg.PatchCollector. +func (c *RecordingPatchCollector) CreateIfNotExists(object any) { + c.record(&RecordedOp{Op: "CreateIfNotExists", Object: object}) +} + +// CreateOrUpdate implements pkg.PatchCollector. +func (c *RecordingPatchCollector) CreateOrUpdate(object any) { + c.record(&RecordedOp{Op: "CreateOrUpdate", Object: object}) +} + +// Delete implements pkg.PatchCollector. +func (c *RecordingPatchCollector) Delete(apiVersion, kind, namespace, name string) { + c.record(&RecordedOp{Op: "Delete", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}) +} + +// DeleteInBackground implements pkg.PatchCollector. +func (c *RecordingPatchCollector) DeleteInBackground(apiVersion, kind, namespace, name string) { + c.record(&RecordedOp{Op: "DeleteInBackground", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}) +} + +// DeleteNonCascading implements pkg.PatchCollector. +func (c *RecordingPatchCollector) DeleteNonCascading(apiVersion, kind, namespace, name string) { + c.record(&RecordedOp{Op: "DeleteNonCascading", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name}) +} + +// JSONPatch implements pkg.PatchCollector (deprecated alias). +func (c *RecordingPatchCollector) JSONPatch(jsonPatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.record(&RecordedOp{Op: "JSONPatch", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, Patch: jsonPatch, Options: opts}) +} + +// MergePatch implements pkg.PatchCollector (deprecated alias). +func (c *RecordingPatchCollector) MergePatch(mergePatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.record(&RecordedOp{Op: "MergePatch", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, Patch: mergePatch, Options: opts}) +} + +// JQFilter implements pkg.PatchCollector (deprecated alias). +func (c *RecordingPatchCollector) JQFilter(jqfilter, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.record(&RecordedOp{Op: "JQFilter", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, JQFilter: jqfilter, Options: opts}) +} + +// PatchWithJSON implements pkg.PatchCollector. +func (c *RecordingPatchCollector) PatchWithJSON(jsonPatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.record(&RecordedOp{Op: "JSONPatch", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, Patch: jsonPatch, Options: opts}) +} + +// PatchWithMerge implements pkg.PatchCollector. +func (c *RecordingPatchCollector) PatchWithMerge(mergePatch any, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.record(&RecordedOp{Op: "MergePatch", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, Patch: mergePatch, Options: opts}) +} + +// PatchWithJQ implements pkg.PatchCollector. +func (c *RecordingPatchCollector) PatchWithJQ(jqfilter, apiVersion, kind, namespace, name string, opts ...pkg.PatchCollectorOption) { + c.record(&RecordedOp{Op: "JQFilter", APIVersion: apiVersion, Kind: kind, Namespace: namespace, Name: name, JQFilter: jqfilter, Options: opts}) +} diff --git a/testing/helpers/snapshots.go b/testing/helpers/snapshots.go new file mode 100644 index 00000000..6ba96b95 --- /dev/null +++ b/testing/helpers/snapshots.go @@ -0,0 +1,101 @@ +package helpers + +import ( + "encoding/json" + "fmt" + + k8syaml "sigs.k8s.io/yaml" + + "github.com/deckhouse/module-sdk/pkg" +) + +// StaticSnapshots is an in-memory implementation of pkg.Snapshots backed +// by raw JSON payloads. It is the simplest possible Snapshots stub: every +// snapshot is a JSON document that UnmarshalTo will decode into the +// caller-supplied struct. +// +// Construct a StaticSnapshots with NewSnapshots: +// +// snaps := helpers.NewSnapshots(). +// Add("nodes", helpers.SnapshotJSON(`{"name":"n1"}`)). +// Add("nodes", helpers.SnapshotYAML(`name: n2`)) +type StaticSnapshots map[string][]pkg.Snapshot + +// NewSnapshots returns an empty StaticSnapshots. +func NewSnapshots() StaticSnapshots { + return StaticSnapshots{} +} + +// Get implements pkg.Snapshots. +func (s StaticSnapshots) Get(key string) []pkg.Snapshot { + return s[key] +} + +// Add appends one or more snapshots to the bucket identified by key. +// It returns the receiver for chaining. +func (s StaticSnapshots) Add(key string, snaps ...pkg.Snapshot) StaticSnapshots { + s[key] = append(s[key], snaps...) + return s +} + +// Set replaces the bucket identified by key with the given snapshots. +// It returns the receiver for chaining. +func (s StaticSnapshots) Set(key string, snaps ...pkg.Snapshot) StaticSnapshots { + s[key] = append([]pkg.Snapshot(nil), snaps...) + return s +} + +// jsonSnapshot is a pkg.Snapshot whose payload is a JSON document. +type jsonSnapshot struct { + raw []byte +} + +// String implements pkg.Snapshot. +func (j jsonSnapshot) String() string { return string(j.raw) } + +// UnmarshalTo implements pkg.Snapshot. +func (j jsonSnapshot) UnmarshalTo(v any) error { + if len(j.raw) == 0 { + return nil + } + if err := json.Unmarshal(j.raw, v); err != nil { + return fmt.Errorf("snapshot unmarshal: %w", err) + } + return nil +} + +// SnapshotJSON builds a pkg.Snapshot from a JSON string. +func SnapshotJSON(raw string) pkg.Snapshot { + return jsonSnapshot{raw: []byte(raw)} +} + +// SnapshotYAML builds a pkg.Snapshot from a YAML string. The YAML is +// converted to canonical JSON internally so that UnmarshalTo behaves the +// same as for SnapshotJSON. +func SnapshotYAML(raw string) pkg.Snapshot { + data, err := k8syaml.YAMLToJSON([]byte(raw)) + if err != nil { + panic(fmt.Errorf("helpers.SnapshotYAML: %w", err)) + } + return jsonSnapshot{raw: data} +} + +// SnapshotFromObject builds a pkg.Snapshot by JSON-encoding the given Go +// value. It panics if the value cannot be marshalled (which would be a +// programmer error in test code). +func SnapshotFromObject(v any) pkg.Snapshot { + data, err := json.Marshal(v) + if err != nil { + panic(fmt.Errorf("helpers.SnapshotFromObject: marshal: %w", err)) + } + return jsonSnapshot{raw: data} +} + +// SnapshotFromObjects is the bulk variant of SnapshotFromObject. +func SnapshotFromObjects[T any](items []T) []pkg.Snapshot { + out := make([]pkg.Snapshot, 0, len(items)) + for i := range items { + out = append(out, SnapshotFromObject(items[i])) + } + return out +} diff --git a/testing/helpers/values.go b/testing/helpers/values.go new file mode 100644 index 00000000..1f605c48 --- /dev/null +++ b/testing/helpers/values.go @@ -0,0 +1,67 @@ +package helpers + +import ( + "encoding/json" + "fmt" + + k8syaml "sigs.k8s.io/yaml" + + "github.com/deckhouse/module-sdk/pkg" + patchablevalues "github.com/deckhouse/module-sdk/pkg/patchable-values" +) + +// NewValues constructs a real pkg.PatchableValuesCollector seeded with the +// provided map. Use it when the hook under test should observe a working +// values store rather than a mock. +// +// Tests can then assert on the patches the hook produced via GetPatches(). +func NewValues(initial map[string]any) pkg.PatchableValuesCollector { + if initial == nil { + initial = map[string]any{} + } + v, err := patchablevalues.NewPatchableValues(initial) + if err != nil { + panic(fmt.Errorf("helpers.NewValues: %w", err)) + } + return v +} + +// NewValuesFromJSON is like NewValues but parses a JSON document. +// Empty or whitespace-only input is treated as `{}`. +func NewValuesFromJSON(raw string) pkg.PatchableValuesCollector { + return NewValues(parseJSONOrYAML(raw)) +} + +// NewValuesFromYAML is like NewValues but parses a YAML document. +// Empty or whitespace-only input is treated as `{}`. +func NewValuesFromYAML(raw string) pkg.PatchableValuesCollector { + return NewValues(parseJSONOrYAML(raw)) +} + +func parseJSONOrYAML(raw string) map[string]any { + if raw == "" { + return map[string]any{} + } + var out map[string]any + if err := k8syaml.Unmarshal([]byte(raw), &out); err != nil { + panic(fmt.Errorf("helpers: parse values: %w", err)) + } + if out == nil { + out = map[string]any{} + } + return out +} + +// MarshalValues returns the JSON encoding of the patch operations recorded +// on the values collector. It is a small convenience for snapshot-style +// assertions: +// +// require.JSONEq(t, `[{"op":"add","path":"/foo","value":"bar"}]`, +// string(helpers.MarshalValues(values))) +func MarshalValues(v pkg.PatchableValuesCollector) []byte { + out, err := json.Marshal(v.GetPatches()) + if err != nil { + panic(fmt.Errorf("helpers.MarshalValues: %w", err)) + } + return out +}