Skip to content

Commit b1a3ad9

Browse files
authored
[feat] add openapi defaults handling for tests (#106)
Signed-off-by: Pavel Okhlopkov <pavel.okhlopkov@flant.com>
1 parent 9fc7b3e commit b1a3ad9

11 files changed

Lines changed: 1063 additions & 6 deletions

File tree

TESTING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ After `RunHook`, you assert on:
105105
- collected metrics,
106106
- captured logs.
107107

108+
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:
109+
110+
```go
111+
f := framework.NewHookExecutionConfig(t, cfg, handler,
112+
framework.WithOpenAPIDir("../openapi"),
113+
framework.WithInitialValues(`{"https": {"mode": "CertManager"}}`),
114+
)
115+
```
116+
117+
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.
118+
108119
Typical test:
109120

110121
```go
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package hookinfolder_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/deckhouse/module-sdk/pkg"
11+
"github.com/deckhouse/module-sdk/testing/framework"
12+
13+
subfolder "example-module/subfolder"
14+
)
15+
16+
// openapiDir is the path to this module's OpenAPI schemas, resolved
17+
// relative to this test file. The schemas live next to `hooks/` in
18+
// `examples/example-module/openapi/`.
19+
const openapiDir = "../../openapi"
20+
21+
// TestOpenAPI_DefaultsSeedValuesStore demonstrates the WithOpenAPIDir
22+
// option: the framework reads the module's OpenAPI schemas and pre-seeds
23+
// the values / config-values stores with every `default:` declared by
24+
// them, exactly like addon-operator does in production.
25+
//
26+
// This is the most realistic starting point for a hook test in a module
27+
// that ships an `openapi/` directory: hooks running under the framework
28+
// observe the same defaults a real cluster would see.
29+
func TestOpenAPI_DefaultsSeedValuesStore(t *testing.T) {
30+
cfg := &pkg.HookConfig{
31+
Metadata: pkg.HookMetadata{Name: "openapi-defaults-demo"},
32+
OnBeforeHelm: &pkg.OrderedConfig{Order: 1},
33+
}
34+
35+
// A small hook that branches on the `https.mode` config value. In a
36+
// real test you would point this at one of your handlers; here we
37+
// keep it inline so the assertion is right next to the schema fields
38+
// it exercises.
39+
handler := func(_ context.Context, input *pkg.HookInput) error {
40+
mode := input.Values.Get("https.mode").String()
41+
input.Values.Set("module.runtime.https.enabled", mode != "Disabled")
42+
input.Values.Set("module.runtime.replicas", input.ConfigValues.Get("replicas").Int())
43+
return nil
44+
}
45+
46+
hec := framework.NewHookExecutionConfig(t, cfg, handler,
47+
framework.WithOpenAPIDir(openapiDir),
48+
)
49+
hec.RunHook()
50+
require.NoError(t, hec.HookError())
51+
52+
// Defaults from openapi/config-values.yaml.
53+
assert.EqualValues(t, 1, hec.ConfigValuesGet("replicas").Int(),
54+
"replicas default should come from config-values.yaml")
55+
assert.Equal(t, "Disabled", hec.ConfigValuesGet("https.mode").String())
56+
assert.Equal(t, "letsencrypt",
57+
hec.ConfigValuesGet("https.certManager.clusterIssuerName").String())
58+
59+
// Values inherit config-values via x-extend, plus get their own
60+
// schema-only fields.
61+
assert.Equal(t, "Disabled", hec.ValuesGet("https.mode").String(),
62+
"values must inherit https.mode default via x-extend")
63+
assert.True(t, hec.ValuesGet("internal.golangVersions").Exists(),
64+
"values.yaml-only defaults (internal.*) must be present")
65+
66+
// And the hook's own writes land on top of the schema defaults.
67+
assert.False(t, hec.ValuesGet("module.runtime.https.enabled").Bool())
68+
assert.EqualValues(t, 1, hec.ValuesGet("module.runtime.replicas").Int())
69+
}
70+
71+
// TestOpenAPI_UserOverridesWin pins the override semantics: anything
72+
// passed via WithInitialValues / WithInitialConfigValues is deep-merged
73+
// on top of the schema defaults, so the test author always wins.
74+
func TestOpenAPI_UserOverridesWin(t *testing.T) {
75+
cfg := &pkg.HookConfig{
76+
Metadata: pkg.HookMetadata{Name: "openapi-user-overrides"},
77+
OnBeforeHelm: &pkg.OrderedConfig{Order: 1},
78+
}
79+
80+
handler := func(_ context.Context, input *pkg.HookInput) error {
81+
input.Values.Set("module.runtime.https.enabled",
82+
input.Values.Get("https.mode").String() != "Disabled")
83+
return nil
84+
}
85+
86+
hec := framework.NewHookExecutionConfig(t, cfg, handler,
87+
framework.WithOpenAPIDir(openapiDir),
88+
framework.WithInitialConfigValues(`
89+
replicas: 5
90+
https:
91+
mode: CertManager
92+
certManager:
93+
clusterIssuerName: my-issuer
94+
`),
95+
framework.WithInitialValues(`
96+
https:
97+
mode: CertManager
98+
`),
99+
)
100+
hec.RunHook()
101+
require.NoError(t, hec.HookError())
102+
103+
// Explicit overrides win.
104+
assert.EqualValues(t, 5, hec.ConfigValuesGet("replicas").Int())
105+
assert.Equal(t, "CertManager", hec.ConfigValuesGet("https.mode").String())
106+
assert.Equal(t, "my-issuer",
107+
hec.ConfigValuesGet("https.certManager.clusterIssuerName").String())
108+
109+
// Untouched defaults survive: customCertificate.secretName is set by
110+
// the schema and was not overridden.
111+
assert.Equal(t, "false",
112+
hec.ConfigValuesGet("https.customCertificate.secretName").String())
113+
114+
// And the hook's branch on https.mode picked up the override.
115+
assert.True(t, hec.ValuesGet("module.runtime.https.enabled").Bool())
116+
}
117+
118+
// TestOpenAPI_WithExistingHook demonstrates that WithOpenAPIDir composes
119+
// with the actual hooks shipped by this module. The values hook writes
120+
// to `some.path.to.field.*` paths that are independent of the schema, so
121+
// schema defaults and hook output coexist.
122+
func TestOpenAPI_WithExistingHook(t *testing.T) {
123+
hec := framework.NewHookExecutionConfig(t,
124+
&pkg.HookConfig{
125+
Metadata: pkg.HookMetadata{Name: "openapi-with-existing-hook"},
126+
OnBeforeHelm: &pkg.OrderedConfig{Order: 1},
127+
},
128+
subfolder.HandlerHookValues,
129+
framework.WithOpenAPIDir(openapiDir),
130+
framework.WithInitialValues(`{
131+
"some": {
132+
"path": {
133+
"to": {
134+
"field": {
135+
"someInt": 1,
136+
"array": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
137+
}
138+
}
139+
}
140+
}
141+
}`),
142+
)
143+
hec.RunHook()
144+
require.NoError(t, hec.HookError())
145+
146+
// Schema defaults made it through.
147+
assert.Equal(t, "Disabled", hec.ValuesGet("https.mode").String())
148+
assert.True(t, hec.ValuesGet("internal.golangVersions").Exists())
149+
150+
// And the hook ran: it sets `.some.path.to.field.str` then removes
151+
// `.some.path.to.field`, so the resulting value has neither the
152+
// original nested object nor the str — the parent was removed last.
153+
assert.False(t, hec.ValuesGet("some.path.to.field").Exists(),
154+
"values hook removes the parent key as part of its handler")
155+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
type: object
2+
properties:
3+
replicas:
4+
type: integer
5+
description: |
6+
replicas count.
7+
default: 1
8+
https:
9+
type: object
10+
x-examples:
11+
- mode: CustomCertificate
12+
customCertificate:
13+
secretName: "foobar"
14+
- mode: CertManager
15+
certManager:
16+
clusterIssuerName: letsencrypt
17+
description: |
18+
What certificate type to use with frontend and status apps.
19+
20+
This parameter completely overrides the `global.modules.https` settings.
21+
properties:
22+
mode:
23+
type: string
24+
default: "Disabled"
25+
description: |
26+
The HTTPS usage mode:
27+
- `Disabled` — frontend will work over HTTP only;
28+
- `CertManager` — frontend will use HTTPS and get a certificate from the clusterissuer defined in the `certManager.clusterIssuerName` parameter.
29+
- `CustomCertificate` — frontend will use HTTPS using the certificate from the `d8-system` namespace.
30+
- `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.
31+
enum:
32+
- "Disabled"
33+
- "CertManager"
34+
- "CustomCertificate"
35+
- "OnlyInURI"
36+
certManager:
37+
type: object
38+
properties:
39+
clusterIssuerName:
40+
type: string
41+
default: "letsencrypt"
42+
description: |
43+
What ClusterIssuer to use for frontend.
44+
45+
Currently, `letsencrypt`, `letsencrypt-staging`, `selfsigned` are available. Also, you can define your own.
46+
customCertificate:
47+
type: object
48+
default: {}
49+
properties:
50+
secretName:
51+
type: string
52+
description: |
53+
The name of the secret in the `d8-system` namespace to use with frontend.
54+
55+
This secret must have the [kubernetes.io/tls](https://kubernetes.github.io/ingress-nginx/user-guide/tls/#tls-secrets) format.
56+
default: "false"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
x-extend:
2+
schema: config-values.yaml
3+
type: object
4+
properties:
5+
internal:
6+
type: object
7+
default: {}
8+
properties:
9+
golangVersions:
10+
type: array
11+
default: []
12+
items:
13+
type: string
14+
registry:
15+
type: object
16+
description: "System field, overwritten by Deckhouse. Don't use"

testing/framework/README.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ metadata:
7676
| Function | Purpose |
7777
| --- | --- |
7878
| `HookExecutionConfigInit(t, cfg, handler, initValues, initConfigValues)` | Deckhouse-compatible constructor. `initValues` / `initConfigValues` accept JSON or YAML; pass `"{}"` if not needed. |
79-
| `NewHookExecutionConfig(t, cfg, handler, opts...)` | Same, but with explicit `Option`s. Accepts `WithInitialValues`, `WithInitialConfigValues`, `WithSchemeBuilder`, `WithCRD`. |
79+
| `NewHookExecutionConfig(t, cfg, handler, opts...)` | Same, but with explicit `Option`s. Accepts `WithInitialValues`, `WithInitialConfigValues`, `WithSchemeBuilder`, `WithCRD`, `WithOpenAPIDir`, `WithValuesSchema`, `WithConfigValuesSchema`. |
8080

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

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

105105
`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.
106106

107+
### OpenAPI defaults
108+
109+
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:
110+
111+
```go
112+
f := framework.NewHookExecutionConfig(t, cfg, handler,
113+
framework.WithOpenAPIDir("../openapi"),
114+
framework.WithInitialValues(`{"https": {"mode": "CertManager"}}`),
115+
)
116+
```
117+
118+
Behaviour:
119+
120+
- `WithOpenAPIDir(dir)` looks for `<dir>/values.yaml` and `<dir>/config-values.yaml`. Whichever ones are present are loaded.
121+
- For each schema, the framework extracts every `default:` declared in it and uses the result as a baseline values document.
122+
- Anything passed via `WithInitialValues` / `WithInitialConfigValues` is then deep-merged on top — your test's values always override schema defaults.
123+
- 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.
124+
125+
For more granular control use `WithValuesSchema(path)` / `WithConfigValuesSchema(path)` instead — they fail the test if the file is missing.
126+
127+
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`:
128+
129+
```go
130+
schema, err := framework.LoadOpenAPISchema("../openapi/values.yaml")
131+
require.NoError(t, err)
132+
defaults := framework.SchemaDefaults(schema)
133+
merged := framework.MergeValues(defaults, map[string]any{
134+
"replicas": 5,
135+
})
136+
```
137+
107138
### Values
108139

109140
| Method | Purpose |

testing/framework/config.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,25 @@ func NewHookExecutionConfig(t testing.TB, config *pkg.HookConfig, handler HookFu
137137
t.Fatalf("framework: parse initial config values: %v", err)
138138
}
139139

140+
// Apply defaults extracted from the OpenAPI schemas, if any. The
141+
// user-supplied init values are deep-merged on top so they always
142+
// win on conflicts, matching the behaviour Helm/addon-operator
143+
// produces in a real environment.
144+
if cfg.configValuesSchemaPath != "" {
145+
schema, err := LoadOpenAPISchema(cfg.configValuesSchemaPath)
146+
if err != nil {
147+
t.Fatalf("framework: load config values schema: %v", err)
148+
}
149+
hec.configValues.values = MergeValues(SchemaDefaults(schema), hec.configValues.values)
150+
}
151+
if cfg.valuesSchemaPath != "" {
152+
schema, err := LoadOpenAPISchema(cfg.valuesSchemaPath)
153+
if err != nil {
154+
t.Fatalf("framework: load values schema: %v", err)
155+
}
156+
hec.values.values = MergeValues(SchemaDefaults(schema), hec.values.values)
157+
}
158+
140159
hec.fakeClient = dynamicfake.NewSimpleDynamicClientWithCustomListKinds(unstructuredScheme, hec.gvrToListKind)
141160

142161
for _, crd := range cfg.crds {

0 commit comments

Comments
 (0)