Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ After `RunHook`, you assert on:
- collected metrics,
- captured logs.

The framework can also seed values and config values with defaults from the module's OpenAPI schemas (`openapi/values.yaml`, `openapi/config-values.yaml`), so a test sees the same starting state addon-operator would build in production:

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

Schema defaults form the baseline; `WithInitialValues` is deep-merged on top so the test always wins on conflicts. See [`testing/framework/README.md`](./testing/framework/README.md#openapi-defaults) for details.

Typical test:

```go
Expand Down
155 changes: 155 additions & 0 deletions examples/example-module/hooks/subfolder/openapi_framework_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package hookinfolder_test

import (
"context"
"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"
)

// openapiDir is the path to this module's OpenAPI schemas, resolved
// relative to this test file. The schemas live next to `hooks/` in
// `examples/example-module/openapi/`.
const openapiDir = "../../openapi"

// TestOpenAPI_DefaultsSeedValuesStore demonstrates the WithOpenAPIDir
// option: the framework reads the module's OpenAPI schemas and pre-seeds
// the values / config-values stores with every `default:` declared by
// them, exactly like addon-operator does in production.
//
// This is the most realistic starting point for a hook test in a module
// that ships an `openapi/` directory: hooks running under the framework
// observe the same defaults a real cluster would see.
func TestOpenAPI_DefaultsSeedValuesStore(t *testing.T) {
cfg := &pkg.HookConfig{
Metadata: pkg.HookMetadata{Name: "openapi-defaults-demo"},
OnBeforeHelm: &pkg.OrderedConfig{Order: 1},
}

// A small hook that branches on the `https.mode` config value. In a
// real test you would point this at one of your handlers; here we
// keep it inline so the assertion is right next to the schema fields
// it exercises.
handler := func(_ context.Context, input *pkg.HookInput) error {
mode := input.Values.Get("https.mode").String()
input.Values.Set("module.runtime.https.enabled", mode != "Disabled")
input.Values.Set("module.runtime.replicas", input.ConfigValues.Get("replicas").Int())
return nil
}

hec := framework.NewHookExecutionConfig(t, cfg, handler,
framework.WithOpenAPIDir(openapiDir),
)
hec.RunHook()
require.NoError(t, hec.HookError())

// Defaults from openapi/config-values.yaml.
assert.EqualValues(t, 1, hec.ConfigValuesGet("replicas").Int(),
"replicas default should come from config-values.yaml")
assert.Equal(t, "Disabled", hec.ConfigValuesGet("https.mode").String())
assert.Equal(t, "letsencrypt",
hec.ConfigValuesGet("https.certManager.clusterIssuerName").String())

// Values inherit config-values via x-extend, plus get their own
// schema-only fields.
assert.Equal(t, "Disabled", hec.ValuesGet("https.mode").String(),
"values must inherit https.mode default via x-extend")
assert.True(t, hec.ValuesGet("internal.golangVersions").Exists(),
"values.yaml-only defaults (internal.*) must be present")

// And the hook's own writes land on top of the schema defaults.
assert.False(t, hec.ValuesGet("module.runtime.https.enabled").Bool())
assert.EqualValues(t, 1, hec.ValuesGet("module.runtime.replicas").Int())
}

// TestOpenAPI_UserOverridesWin pins the override semantics: anything
// passed via WithInitialValues / WithInitialConfigValues is deep-merged
// on top of the schema defaults, so the test author always wins.
func TestOpenAPI_UserOverridesWin(t *testing.T) {
cfg := &pkg.HookConfig{
Metadata: pkg.HookMetadata{Name: "openapi-user-overrides"},
OnBeforeHelm: &pkg.OrderedConfig{Order: 1},
}

handler := func(_ context.Context, input *pkg.HookInput) error {
input.Values.Set("module.runtime.https.enabled",
input.Values.Get("https.mode").String() != "Disabled")
return nil
}

hec := framework.NewHookExecutionConfig(t, cfg, handler,
framework.WithOpenAPIDir(openapiDir),
framework.WithInitialConfigValues(`
replicas: 5
https:
mode: CertManager
certManager:
clusterIssuerName: my-issuer
`),
framework.WithInitialValues(`
https:
mode: CertManager
`),
)
hec.RunHook()
require.NoError(t, hec.HookError())

// Explicit overrides win.
assert.EqualValues(t, 5, hec.ConfigValuesGet("replicas").Int())
assert.Equal(t, "CertManager", hec.ConfigValuesGet("https.mode").String())
assert.Equal(t, "my-issuer",
hec.ConfigValuesGet("https.certManager.clusterIssuerName").String())

// Untouched defaults survive: customCertificate.secretName is set by
// the schema and was not overridden.
assert.Equal(t, "false",
hec.ConfigValuesGet("https.customCertificate.secretName").String())

// And the hook's branch on https.mode picked up the override.
assert.True(t, hec.ValuesGet("module.runtime.https.enabled").Bool())
}

// TestOpenAPI_WithExistingHook demonstrates that WithOpenAPIDir composes
// with the actual hooks shipped by this module. The values hook writes
// to `some.path.to.field.*` paths that are independent of the schema, so
// schema defaults and hook output coexist.
func TestOpenAPI_WithExistingHook(t *testing.T) {
hec := framework.NewHookExecutionConfig(t,
&pkg.HookConfig{
Metadata: pkg.HookMetadata{Name: "openapi-with-existing-hook"},
OnBeforeHelm: &pkg.OrderedConfig{Order: 1},
},
subfolder.HandlerHookValues,
framework.WithOpenAPIDir(openapiDir),
framework.WithInitialValues(`{
"some": {
"path": {
"to": {
"field": {
"someInt": 1,
"array": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
}
}
}
}
}`),
)
hec.RunHook()
require.NoError(t, hec.HookError())

// Schema defaults made it through.
assert.Equal(t, "Disabled", hec.ValuesGet("https.mode").String())
assert.True(t, hec.ValuesGet("internal.golangVersions").Exists())

// And the hook ran: it sets `.some.path.to.field.str` then removes
// `.some.path.to.field`, so the resulting value has neither the
// original nested object nor the str — the parent was removed last.
assert.False(t, hec.ValuesGet("some.path.to.field").Exists(),
"values hook removes the parent key as part of its handler")
}
56 changes: 56 additions & 0 deletions examples/example-module/openapi/config-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
type: object
properties:
replicas:
type: integer
description: |
replicas count.
default: 1
https:
type: object
x-examples:
- mode: CustomCertificate
customCertificate:
secretName: "foobar"
- mode: CertManager
certManager:
clusterIssuerName: letsencrypt
description: |
What certificate type to use with frontend and status apps.

This parameter completely overrides the `global.modules.https` settings.
properties:
mode:
type: string
default: "Disabled"
description: |
The HTTPS usage mode:
- `Disabled` — frontend will work over HTTP only;
- `CertManager` — frontend will use HTTPS and get a certificate from the clusterissuer defined in the `certManager.clusterIssuerName` parameter.
- `CustomCertificate` — frontend will use HTTPS using the certificate from the `d8-system` namespace.
- `OnlyInURI` — frontend will work over HTTP (thinking that there is an external HTTPS load balancer in front that terminates HTTPS traffic). All the links in the `user-authn` will be generated using the HTTPS scheme.
enum:
- "Disabled"
- "CertManager"
- "CustomCertificate"
- "OnlyInURI"
certManager:
type: object
properties:
clusterIssuerName:
type: string
default: "letsencrypt"
description: |
What ClusterIssuer to use for frontend.

Currently, `letsencrypt`, `letsencrypt-staging`, `selfsigned` are available. Also, you can define your own.
customCertificate:
type: object
default: {}
properties:
secretName:
type: string
description: |
The name of the secret in the `d8-system` namespace to use with frontend.

This secret must have the [kubernetes.io/tls](https://kubernetes.github.io/ingress-nginx/user-guide/tls/#tls-secrets) format.
default: "false"
16 changes: 16 additions & 0 deletions examples/example-module/openapi/values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
x-extend:
schema: config-values.yaml
type: object
properties:
internal:
type: object
default: {}
properties:
golangVersions:
type: array
default: []
items:
type: string
registry:
type: object
description: "System field, overwritten by Deckhouse. Don't use"
33 changes: 32 additions & 1 deletion testing/framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ metadata:
| 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`. |
| `NewHookExecutionConfig(t, cfg, handler, opts...)` | Same, but with explicit `Option`s. Accepts `WithInitialValues`, `WithInitialConfigValues`, `WithSchemeBuilder`, `WithCRD`, `WithOpenAPIDir`, `WithValuesSchema`, `WithConfigValuesSchema`. |

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

Expand Down Expand Up @@ -104,6 +104,37 @@ f.RegisterCRD("acme.io", "v1", "Widget", true)

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

### OpenAPI defaults

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

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

Behaviour:

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

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

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

```go
schema, err := framework.LoadOpenAPISchema("../openapi/values.yaml")
require.NoError(t, err)
defaults := framework.SchemaDefaults(schema)
merged := framework.MergeValues(defaults, map[string]any{
"replicas": 5,
})
```

### Values

| Method | Purpose |
Expand Down
19 changes: 19 additions & 0 deletions testing/framework/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,25 @@ func NewHookExecutionConfig(t testing.TB, config *pkg.HookConfig, handler HookFu
t.Fatalf("framework: parse initial config values: %v", err)
}

// Apply defaults extracted from the OpenAPI schemas, if any. The
// user-supplied init values are deep-merged on top so they always
// win on conflicts, matching the behaviour Helm/addon-operator
// produces in a real environment.
if cfg.configValuesSchemaPath != "" {
schema, err := LoadOpenAPISchema(cfg.configValuesSchemaPath)
if err != nil {
t.Fatalf("framework: load config values schema: %v", err)
}
hec.configValues.values = MergeValues(SchemaDefaults(schema), hec.configValues.values)
}
if cfg.valuesSchemaPath != "" {
schema, err := LoadOpenAPISchema(cfg.valuesSchemaPath)
if err != nil {
t.Fatalf("framework: load values schema: %v", err)
}
hec.values.values = MergeValues(SchemaDefaults(schema), hec.values.values)
}

hec.fakeClient = dynamicfake.NewSimpleDynamicClientWithCustomListKinds(unstructuredScheme, hec.gvrToListKind)

for _, crd := range cfg.crds {
Expand Down
Loading
Loading