mirror of
https://github.com/open-policy-agent/opa.git
synced 2026-08-12 19:32:48 -06:00
config: migrate server.encoding and server.decoding validation to Rego (#8903)
Follow-up to #8900. Moves the gzip encoding and decoding config validation off the Go `validateAndInjectDefaults` methods and onto embedded Rego policies, injecting defaults and reporting value errors. Each config registers its recognized options via `config.RegisterConfigSpec` so unknown-option warnings live with the owning struct. Field type validation stays in the Go decode step. --------- Signed-off-by: Sebastian Spaink <sebastianspaink@gmail.com>
This commit is contained in:
@@ -14,8 +14,11 @@ rules:
|
||||
- "docs"
|
||||
- "internal/wasm/sdk/examples/*"
|
||||
- "v1/config/*.rego"
|
||||
- "internal/configpolicy/*.rego"
|
||||
- "internal/metricsexport/*.rego"
|
||||
- "v1/plugins/server/metrics/*.rego"
|
||||
- "v1/plugins/server/encoding/*.rego"
|
||||
- "v1/plugins/server/decoding/*.rego"
|
||||
style:
|
||||
line-length:
|
||||
ignore:
|
||||
|
||||
+11
-2
@@ -7,6 +7,11 @@ set -euo pipefail
|
||||
|
||||
OPA="${OPA:-opa}"
|
||||
|
||||
# The config validation policies import the helpers in this module, which the Go
|
||||
# layer compiles into every policy, so it is loaded alongside each directory here.
|
||||
CONFIG_UTIL=internal/configpolicy/util.rego
|
||||
CONFIG_UTIL_DIR="./$(dirname "$CONFIG_UTIL")"
|
||||
|
||||
dirs=$(find . -name '*_test.rego' \
|
||||
-not -path './build/policy/*' \
|
||||
-not -path '*/testdata/*' \
|
||||
@@ -23,8 +28,12 @@ fi
|
||||
|
||||
status=0
|
||||
for d in $dirs; do
|
||||
echo "==> ${OPA} test ${d}"
|
||||
"$OPA" test "$d" || status=1
|
||||
paths=("$d")
|
||||
if [ "$d" != "$CONFIG_UTIL_DIR" ]; then
|
||||
paths+=("$CONFIG_UTIL")
|
||||
fi
|
||||
echo "==> ${OPA} test ${paths[*]}"
|
||||
"$OPA" test "${paths[@]}" || status=1
|
||||
done
|
||||
|
||||
exit "$status"
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
// processed - the input config with defaults injected (required)
|
||||
// errors - a set/array of fatal error strings (joined into one error)
|
||||
// warnings - a set/array of non-fatal warning strings
|
||||
//
|
||||
// Every policy is compiled together with util.rego, so a policy can import
|
||||
// data.opa.config.util for the helpers shared across the validation policies.
|
||||
package configpolicy
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -26,6 +30,13 @@ import (
|
||||
"github.com/open-policy-agent/opa/v1/util"
|
||||
)
|
||||
|
||||
// The shared helpers are compiled into every policy, so a validation policy can
|
||||
// import data.opa.config.util rather than repeat them.
|
||||
const utilModuleName = "opa/config/util.rego"
|
||||
|
||||
//go:embed util.rego
|
||||
var utilModule string
|
||||
|
||||
// Policy is an embedded validation policy, compiled once on first use and
|
||||
// evaluated repeatedly. Safe for concurrent use.
|
||||
type Policy struct {
|
||||
@@ -110,14 +121,19 @@ func (p *Policy) Eval(ctx context.Context, input any) (map[string]any, []string,
|
||||
|
||||
// EvalConfigInto decodes raw config bytes (absent/empty/null → empty object),
|
||||
// evaluates the policy with input {"config": <raw>} to inject defaults, and
|
||||
// decodes the processed config into out, returning any warnings. The typed
|
||||
// unmarshal into out is what enforces field types, so a mistyped option
|
||||
// surfaces here rather than in the policy.
|
||||
// decodes the processed config into out, returning any warnings. Field types the
|
||||
// policy does not check are enforced by the typed unmarshal into out, so a
|
||||
// mistyped option surfaces here rather than in the policy.
|
||||
func EvalConfigInto[T any](ctx context.Context, p *Policy, raw []byte, out *T) ([]string, error) {
|
||||
rawConfig, err := unmarshalRawConfig(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := rawConfig.(map[string]any); !ok {
|
||||
// Caught here rather than in the policy, which would fail to produce a
|
||||
// processed config and report the far less obvious infrastructure error.
|
||||
return nil, fmt.Errorf("%s: config must be an object", p.name)
|
||||
}
|
||||
|
||||
processed, warnings, err := p.Eval(ctx, map[string]any{"config": rawConfig})
|
||||
if err != nil {
|
||||
@@ -160,8 +176,14 @@ func (p *Policy) Compiler() (*ast.Compiler, error) {
|
||||
p.compileErr = fmt.Errorf("%s: %w", p.name, err)
|
||||
return
|
||||
}
|
||||
helpers, err := ast.ParseModuleWithOpts(utilModuleName, utilModule, popts)
|
||||
if err != nil {
|
||||
p.compileErr = fmt.Errorf("%s: %w", utilModuleName, err)
|
||||
return
|
||||
}
|
||||
modules := map[string]*ast.Module{p.name: module, utilModuleName: helpers}
|
||||
compiler := ast.NewCompiler()
|
||||
if compiler.Compile(map[string]*ast.Module{p.name: module}); compiler.Failed() {
|
||||
if compiler.Compile(modules); compiler.Failed() {
|
||||
p.compileErr = fmt.Errorf("%s: %w", p.name, compiler.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package configpolicy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
@@ -95,6 +96,36 @@ func TestPolicyCompilerCompilesOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const helpersModule = `package test.helpers
|
||||
|
||||
import data.opa.config.util
|
||||
|
||||
processed := object.union_n(array.concat([input.config], [patch | some patch in _patches]))
|
||||
|
||||
_patches contains {"size": 10} if util.absent(["size"])
|
||||
|
||||
errors contains "size must be a positive number" if util.not_positive_number(["size"])
|
||||
`
|
||||
|
||||
// The shared helpers are compiled into every policy, so a policy can use them
|
||||
// without embedding its own copy.
|
||||
func TestPolicySharedHelpers(t *testing.T) {
|
||||
p := New("test/helpers.rego", helpersModule, "data.test.helpers = x")
|
||||
|
||||
processed, _, err := p.Eval(t.Context(), map[string]any{"config": map[string]any{}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(processed, map[string]any{"size": json.Number("10")}) {
|
||||
t.Fatalf("expected the helper-guarded default to be injected, got %v", processed)
|
||||
}
|
||||
|
||||
_, _, err = p.Eval(t.Context(), map[string]any{"config": map[string]any{"size": -1}})
|
||||
if err == nil || err.Error() != "size must be a positive number" {
|
||||
t.Fatalf("expected the helper to reject a non-positive value, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const intoModule = `package test.into
|
||||
|
||||
processed := object.union({"name": "default"}, input.config)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# METADATA
|
||||
# description: |
|
||||
# Helpers shared by the embedded configuration validation policies. Compiled
|
||||
# into every configpolicy.Policy, so a policy can import data.opa.config.util
|
||||
# instead of carrying its own copy of these rules.
|
||||
#
|
||||
# The helpers read the raw configuration from input.config, the part of the
|
||||
# input document every validation policy is given.
|
||||
package opa.config.util
|
||||
|
||||
# METADATA
|
||||
# description: the configured value at path, or null when the option is absent.
|
||||
value(path) := object.get(input.config, path, null)
|
||||
|
||||
# METADATA
|
||||
# description: |
|
||||
# true when the option at path is missing or explicitly null, the cases where a
|
||||
# default is injected, matching the pre-Rego behavior where a nil pointer was
|
||||
# replaced with a default.
|
||||
absent(path) if value(path) == null
|
||||
|
||||
# METADATA
|
||||
# description: |
|
||||
# true when the option at path is present but not an object. The shape of an
|
||||
# option holding an object has to be checked in the policy rather than left to
|
||||
# the Go unmarshal: the default patches would otherwise be merged over the bad
|
||||
# value, silently replacing it with a well-formed object.
|
||||
not_object(path) if {
|
||||
v := value(path)
|
||||
v != null
|
||||
not is_object(v)
|
||||
}
|
||||
|
||||
# METADATA
|
||||
# description: true when the option at path is present but not a number above zero.
|
||||
not_positive_number(path) if {
|
||||
v := value(path)
|
||||
v != null
|
||||
not _positive_number(v)
|
||||
}
|
||||
|
||||
_positive_number(v) if {
|
||||
is_number(v)
|
||||
v > 0
|
||||
}
|
||||
@@ -5,11 +5,13 @@
|
||||
# handled by unmarshaling into the Go struct.
|
||||
#
|
||||
# Input: {"config": <raw metrics_export config>}
|
||||
# Entrypoints: processed (config + defaults), errors (fatal).
|
||||
# Rules read by the Go layer: processed (config + defaults), errors (fatal).
|
||||
package opa.config.metrics_export
|
||||
|
||||
import future.keywords.not
|
||||
|
||||
import data.opa.config.util
|
||||
|
||||
_default_grpc_address := "localhost:4317"
|
||||
|
||||
_default_http_address := "localhost:4318"
|
||||
@@ -31,19 +33,18 @@ _default_address := _default_grpc_address if lower(input.config.type) == "otlp/g
|
||||
_default_address := _default_http_address if lower(input.config.type) == "otlp/http"
|
||||
|
||||
# METADATA
|
||||
# entrypoint: true
|
||||
# description: the config with metrics_export defaults injected for absent options.
|
||||
processed := object.union_n(array.concat([input.config], [patch | some patch in _patches]))
|
||||
|
||||
_patches contains {"address": _default_address} if _empty(["address"])
|
||||
|
||||
_patches contains {"export_interval_ms": _default_export_interval_ms} if _absent(["export_interval_ms"])
|
||||
_patches contains {"export_interval_ms": _default_export_interval_ms} if util.absent(["export_interval_ms"])
|
||||
|
||||
_patches contains {"service_name": _default_service_name} if _empty(["service_name"])
|
||||
|
||||
_patches contains {"encryption": _default_encryption_scheme} if _empty(["encryption"])
|
||||
|
||||
_patches contains {"allow_insecure_tls": false} if _absent(["allow_insecure_tls"])
|
||||
_patches contains {"allow_insecure_tls": false} if util.absent(["allow_insecure_tls"])
|
||||
|
||||
errors contains msg if {
|
||||
input.config.type
|
||||
@@ -61,16 +62,12 @@ errors contains msg if {
|
||||
msg := $`unsupported metrics_export.encryption "{processed.encryption}"`
|
||||
}
|
||||
|
||||
# _absent is true when the option at path is missing or explicitly null (a nil
|
||||
# pointer in Go).
|
||||
_absent(path) if object.get(input.config, path, null) == null
|
||||
|
||||
# _empty is true when a string option is missing, null, or the empty string,
|
||||
# matching the pre-Rego behavior that defaulted on "".
|
||||
_empty(path) if not _nonempty(path)
|
||||
|
||||
_nonempty(path) if {
|
||||
v := object.get(input.config, path, null)
|
||||
v := util.value(path)
|
||||
v != null
|
||||
v != ""
|
||||
}
|
||||
|
||||
+5
-11
@@ -8,36 +8,30 @@
|
||||
# replace each plugin's own config validation.
|
||||
#
|
||||
# Input: {"config": <raw config>, "runtime": {"id", "version"}}
|
||||
# Entrypoints: processed (config + defaults), errors (fatal), warnings.
|
||||
# Rules read by the Go layer: processed (config + defaults), errors (fatal), warnings.
|
||||
package opa.config
|
||||
|
||||
import data.opa.config.util
|
||||
|
||||
_default_decision := "/system/main"
|
||||
|
||||
_default_authorization_decision := "/system/authz/allow"
|
||||
|
||||
# METADATA
|
||||
# entrypoint: true
|
||||
# description: |
|
||||
# The user config with every _patches fragment merged over it. A fragment
|
||||
# wins where it overlaps, so enforced values (labels id/version) always apply,
|
||||
# while defaults are contributed only when the option is absent.
|
||||
processed := object.union_n(array.concat([input.config], [patch | some patch in _patches]))
|
||||
|
||||
_patches contains {"default_decision": _default_decision} if _absent_or_null("default_decision")
|
||||
_patches contains {"default_decision": _default_decision} if util.absent(["default_decision"])
|
||||
|
||||
_patches contains {"default_authorization_decision": _default_authorization_decision} if {
|
||||
_absent_or_null("default_authorization_decision")
|
||||
util.absent(["default_authorization_decision"])
|
||||
}
|
||||
|
||||
_patches contains {"labels": {"id": input.runtime.id, "version": input.runtime.version}}
|
||||
|
||||
# _absent_or_null is true when a config field is missing or explicitly null. In
|
||||
# both cases the default is injected, matching the pre-Rego behavior where a nil
|
||||
# pointer was treated as unset.
|
||||
_absent_or_null(field) if not input.config[field]
|
||||
|
||||
_absent_or_null(field) if input.config[field] == null
|
||||
|
||||
errors contains msg if {
|
||||
some field in {"default_decision", "default_authorization_decision"}
|
||||
value := input.config[field]
|
||||
|
||||
@@ -17,16 +17,26 @@
|
||||
package decoding
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"context"
|
||||
_ "embed"
|
||||
|
||||
"github.com/open-policy-agent/opa/v1/util"
|
||||
"github.com/open-policy-agent/opa/internal/configpolicy"
|
||||
"github.com/open-policy-agent/opa/v1/config"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultMaxRequestLength = int64(268435456) // 256 MB
|
||||
defaultGzipMaxContentLength = int64(536870912) // 512 MB
|
||||
//go:embed validate.rego
|
||||
var validationModule string
|
||||
|
||||
var validationPolicy = configpolicy.New(
|
||||
"opa/config/server/decoding/validate.rego",
|
||||
validationModule,
|
||||
"data.opa.config.server.decoding = x",
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.RegisterConfigSpec(config.SpecsFromStruct[Config]("server", "decoding")...)
|
||||
}
|
||||
|
||||
// Config represents the configuration for the Server.Decoding settings
|
||||
type Config struct {
|
||||
MaxLength *int64 `json:"max_length,omitempty"` // maximum request size that will be read, regardless of compression.
|
||||
@@ -56,47 +66,15 @@ func (b *ConfigBuilder) WithBytes(config []byte) *ConfigBuilder {
|
||||
|
||||
// Parse returns a valid Config object with defaults injected.
|
||||
func (b *ConfigBuilder) Parse() (*Config, error) {
|
||||
if b.raw == nil {
|
||||
defaultConfig := &Config{
|
||||
MaxLength: &defaultMaxRequestLength,
|
||||
Gzip: &Gzip{
|
||||
MaxLength: &defaultGzipMaxContentLength,
|
||||
},
|
||||
}
|
||||
return defaultConfig, nil
|
||||
}
|
||||
return b.ParseWithContext(context.Background())
|
||||
}
|
||||
|
||||
// ParseWithContext returns a valid Config object with defaults injected, using
|
||||
// ctx to evaluate the validation policy.
|
||||
func (b *ConfigBuilder) ParseWithContext(ctx context.Context) (*Config, error) {
|
||||
var result Config
|
||||
|
||||
if err := util.Unmarshal(b.raw, &result); err != nil {
|
||||
if _, err := configpolicy.EvalConfigInto(ctx, validationPolicy, b.raw, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, result.validateAndInjectDefaults()
|
||||
}
|
||||
|
||||
// validateAndInjectDefaults populates defaults if the fields are nil, then
|
||||
// validates the config values.
|
||||
func (c *Config) validateAndInjectDefaults() error {
|
||||
if c.MaxLength == nil {
|
||||
c.MaxLength = &defaultMaxRequestLength
|
||||
}
|
||||
|
||||
if c.Gzip == nil {
|
||||
c.Gzip = &Gzip{
|
||||
MaxLength: &defaultGzipMaxContentLength,
|
||||
}
|
||||
}
|
||||
if c.Gzip.MaxLength == nil {
|
||||
c.Gzip.MaxLength = &defaultGzipMaxContentLength
|
||||
}
|
||||
|
||||
if *c.MaxLength <= 0 {
|
||||
return errors.New("invalid value for server.decoding.max_length field, should be a positive number")
|
||||
}
|
||||
if *c.Gzip.MaxLength <= 0 {
|
||||
return errors.New("invalid value for server.decoding.gzip.max_length field, should be a positive number")
|
||||
}
|
||||
|
||||
return nil
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package decoding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-policy-agent/opa/v1/config"
|
||||
)
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
@@ -58,6 +62,14 @@ func TestConfigValidation(t *testing.T) {
|
||||
input: `{"max_length": 42, "gzip":{"max_length": 42}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
input: `{"gzip": [1, 2, 3]}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
input: `{"gzip": "nope"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
@@ -106,3 +118,88 @@ func TestConfigValue(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigRejectsWrongShapedOptions covers options the validation policy has to
|
||||
// reject itself. Defaults are merged over the raw config, so an option that
|
||||
// should hold an object would otherwise be silently replaced by the defaults
|
||||
// instead of reaching the Go unmarshal that used to report the type error.
|
||||
func TestConfigRejectsWrongShapedOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
input: `{"gzip": [1, 2, 3]}`,
|
||||
wantErr: "invalid value for server.decoding.gzip field, should be an object",
|
||||
},
|
||||
{
|
||||
input: `{"gzip": "nope"}`,
|
||||
wantErr: "invalid value for server.decoding.gzip field, should be an object",
|
||||
},
|
||||
{
|
||||
input: `{"max_length": true}`,
|
||||
wantErr: "invalid value for server.decoding.max_length field, should be a positive number",
|
||||
},
|
||||
{
|
||||
input: `{"max_length": "foobar"}`,
|
||||
wantErr: "invalid value for server.decoding.max_length field, should be a positive number",
|
||||
},
|
||||
{
|
||||
input: `[1, 2, 3]`,
|
||||
wantErr: "config must be an object",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
_, err := NewConfigBuilder().WithBytes([]byte(test.input)).Parse()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got none", test.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %q", test.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigNoWarningsForKnownDecodingOptions verifies the server.decoding spec
|
||||
// registered by this package lets config.ParseConfig recognize the section's
|
||||
// options. Without the registration the section is treated as open, so this also
|
||||
// guards against the unknown-option warning below silently going missing.
|
||||
func TestConfigNoWarningsForKnownDecodingOptions(t *testing.T) {
|
||||
raw := []byte(`{"server": {"decoding": {"max_length": 5, "gzip": {"max_length": 42}}}}`)
|
||||
conf, err := config.ParseConfig(raw, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(conf.Warnings) != 0 {
|
||||
t.Fatalf("expected no warnings, got %v", conf.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigWarnsOnUnknownDecodingOption(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
raw: `{"server": {"decoding": {"typo": 5}}}`,
|
||||
want: `unknown configuration option "server.decoding.typo" encountered`,
|
||||
},
|
||||
{
|
||||
raw: `{"server": {"decoding": {"gzip": {"typo": 5}}}}`,
|
||||
want: `unknown configuration option "server.decoding.gzip.typo" encountered`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.raw, func(t *testing.T) {
|
||||
conf, err := config.ParseConfig([]byte(tc.raw), "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(conf.Warnings, tc.want) {
|
||||
t.Fatalf("expected warning %q, got %v", tc.want, conf.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# METADATA
|
||||
# description: |
|
||||
# Injects defaults and validates the server.decoding configuration (request
|
||||
# size limits and gzip decompression). Evaluated by the decoding config
|
||||
# builder. The policy rejects options of the wrong shape; the remaining field
|
||||
# type validation is handled by unmarshaling into the Go struct.
|
||||
#
|
||||
# Input: {"config": <raw server.decoding config>}
|
||||
# Rules read by the Go layer: processed (config + defaults), errors (fatal).
|
||||
package opa.config.server.decoding
|
||||
|
||||
import data.opa.config.util
|
||||
|
||||
# Defaults mirror decoding/config.go.
|
||||
_default_max_length := 268435456 # 256 MB
|
||||
|
||||
_default_gzip_max_length := 536870912 # 512 MB
|
||||
|
||||
# METADATA
|
||||
# description: the config with decoding defaults injected for absent options.
|
||||
processed := object.union_n(array.concat([input.config], [patch | some patch in _patches]))
|
||||
|
||||
_patches contains {"max_length": _default_max_length} if util.absent(["max_length"])
|
||||
|
||||
_patches contains {"gzip": {"max_length": _default_gzip_max_length}} if util.absent(["gzip", "max_length"])
|
||||
|
||||
errors contains "invalid value for server.decoding.max_length field, should be a positive number" if {
|
||||
util.not_positive_number(["max_length"])
|
||||
}
|
||||
|
||||
errors contains "invalid value for server.decoding.gzip field, should be an object" if {
|
||||
util.not_object(["gzip"])
|
||||
}
|
||||
|
||||
errors contains "invalid value for server.decoding.gzip.max_length field, should be a positive number" if {
|
||||
util.not_positive_number(["gzip", "max_length"])
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package opa.config.server.decoding_test
|
||||
|
||||
import data.opa.config.server.decoding
|
||||
|
||||
# _non_numbers are values a max_length option must be rejected for, shared by the
|
||||
# top-level and gzip cases.
|
||||
_non_numbers := [
|
||||
{"note": "string", "value": "foobar"},
|
||||
{"note": "boolean", "value": true},
|
||||
{"note": "array", "value": [1]},
|
||||
]
|
||||
|
||||
test_injects_defaults[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "empty config", "config": {}},
|
||||
{"note": "gzip present", "config": {"gzip": {}}},
|
||||
{"note": "max_length null", "config": {"max_length": null}},
|
||||
{"note": "gzip.max_length null", "config": {"gzip": {"max_length": null}}},
|
||||
]
|
||||
|
||||
result := decoding.processed with input as {"config": tc.config}
|
||||
result.max_length == 268435456
|
||||
result.gzip.max_length == 536870912
|
||||
}
|
||||
|
||||
test_preserves_configured_values if {
|
||||
raw := {"max_length": 5, "gzip": {"max_length": 42}}
|
||||
result := decoding.processed with input as {"config": raw}
|
||||
result.max_length == 5
|
||||
result.gzip.max_length == 42
|
||||
}
|
||||
|
||||
test_preserves_unknown_keys if {
|
||||
result := decoding.processed with input as {"config": {"gzip": {"random_key": 0}}}
|
||||
result.gzip.random_key == 0
|
||||
}
|
||||
|
||||
test_rejects_non_positive_gzip_max_length[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "zero", "config": {"gzip": {"max_length": 0}}},
|
||||
{"note": "negative", "config": {"gzip": {"max_length": -10}}},
|
||||
]
|
||||
|
||||
result := decoding.errors with input as {"config": tc.config}
|
||||
"invalid value for server.decoding.gzip.max_length field, should be a positive number" in result
|
||||
}
|
||||
|
||||
test_rejects_non_positive_top_level_max_length[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "zero", "config": {"max_length": 0}},
|
||||
{"note": "negative", "config": {"max_length": -10}},
|
||||
]
|
||||
|
||||
result := decoding.errors with input as {"config": tc.config}
|
||||
"invalid value for server.decoding.max_length field, should be a positive number" in result
|
||||
}
|
||||
|
||||
# A non-number max_length is rejected here rather than left to the Go unmarshal,
|
||||
# so the message names the option instead of the Go field.
|
||||
test_rejects_non_number_top_level_max_length[tc.note] if {
|
||||
some tc in _non_numbers
|
||||
|
||||
result := decoding.errors with input as {"config": {"max_length": tc.value}}
|
||||
"invalid value for server.decoding.max_length field, should be a positive number" in result
|
||||
}
|
||||
|
||||
test_rejects_non_number_gzip_max_length[tc.note] if {
|
||||
some tc in _non_numbers
|
||||
|
||||
result := decoding.errors with input as {"config": {"gzip": {"max_length": tc.value}}}
|
||||
"invalid value for server.decoding.gzip.max_length field, should be a positive number" in result
|
||||
}
|
||||
|
||||
# Without this check the gzip defaults would be merged over the bad value,
|
||||
# silently replacing it with a well-formed object.
|
||||
test_rejects_non_object_gzip[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "array", "config": {"gzip": [1, 2, 3]}},
|
||||
{"note": "string", "config": {"gzip": "nope"}},
|
||||
{"note": "number", "config": {"gzip": 7}},
|
||||
]
|
||||
|
||||
result := decoding.errors with input as {"config": tc.config}
|
||||
"invalid value for server.decoding.gzip field, should be an object" in result
|
||||
}
|
||||
|
||||
test_valid_config_has_no_errors if {
|
||||
result := decoding.errors with input as {"config": {"max_length": 42, "gzip": {"max_length": 42}}}
|
||||
count(result) == 0
|
||||
}
|
||||
|
||||
test_empty_config_has_no_errors if {
|
||||
result := decoding.errors with input as {"config": {}}
|
||||
count(result) == 0
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
package encoding
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"context"
|
||||
_ "embed"
|
||||
|
||||
"github.com/open-policy-agent/opa/v1/util"
|
||||
"github.com/open-policy-agent/opa/internal/configpolicy"
|
||||
"github.com/open-policy-agent/opa/v1/config"
|
||||
)
|
||||
|
||||
var defaultGzipMinLength = 1024
|
||||
var defaultGzipCompressionLevel = gzip.BestCompression
|
||||
//go:embed validate.rego
|
||||
var validationModule string
|
||||
|
||||
var validationPolicy = configpolicy.New(
|
||||
"opa/config/server/encoding/validate.rego",
|
||||
validationModule,
|
||||
"data.opa.config.server.encoding = x",
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.RegisterConfigSpec(config.SpecsFromStruct[Config]("server", "encoding")...)
|
||||
}
|
||||
|
||||
// Config represents the configuration for the Server.Encoding settings
|
||||
type Config struct {
|
||||
@@ -39,53 +50,15 @@ func (b *ConfigBuilder) WithBytes(config []byte) *ConfigBuilder {
|
||||
|
||||
// Parse returns a valid Config object with defaults injected.
|
||||
func (b *ConfigBuilder) Parse() (*Config, error) {
|
||||
if b.raw == nil {
|
||||
defaultConfig := &Config{
|
||||
Gzip: &Gzip{
|
||||
MinLength: &defaultGzipMinLength,
|
||||
CompressionLevel: &defaultGzipCompressionLevel,
|
||||
},
|
||||
}
|
||||
return defaultConfig, nil
|
||||
}
|
||||
return b.ParseWithContext(context.Background())
|
||||
}
|
||||
|
||||
// ParseWithContext returns a valid Config object with defaults injected, using
|
||||
// ctx to evaluate the validation policy.
|
||||
func (b *ConfigBuilder) ParseWithContext(ctx context.Context) (*Config, error) {
|
||||
var result Config
|
||||
|
||||
if err := util.Unmarshal(b.raw, &result); err != nil {
|
||||
if _, err := configpolicy.EvalConfigInto(ctx, validationPolicy, b.raw, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, result.validateAndInjectDefaults()
|
||||
}
|
||||
|
||||
func (c *Config) validateAndInjectDefaults() error {
|
||||
if c.Gzip == nil {
|
||||
c.Gzip = &Gzip{
|
||||
MinLength: &defaultGzipMinLength,
|
||||
CompressionLevel: &defaultGzipCompressionLevel,
|
||||
}
|
||||
}
|
||||
if c.Gzip.MinLength == nil {
|
||||
c.Gzip.MinLength = &defaultGzipMinLength
|
||||
}
|
||||
|
||||
if c.Gzip.CompressionLevel == nil {
|
||||
c.Gzip.CompressionLevel = &defaultGzipCompressionLevel
|
||||
}
|
||||
|
||||
if *c.Gzip.MinLength <= 0 {
|
||||
return errors.New("invalid value for server.encoding.gzip.min_length field, should be a positive number")
|
||||
}
|
||||
|
||||
acceptedCompressionLevels := map[int]bool{
|
||||
gzip.NoCompression: true,
|
||||
gzip.BestSpeed: true,
|
||||
gzip.BestCompression: true,
|
||||
}
|
||||
_, compressionLevelAccepted := acceptedCompressionLevels[*c.Gzip.CompressionLevel]
|
||||
if !compressionLevelAccepted {
|
||||
return errors.New("invalid value for server.encoding.gzip.compression_level field, accepted values are 0, 1 or 9")
|
||||
}
|
||||
|
||||
return nil
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ package encoding
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-policy-agent/opa/v1/config"
|
||||
)
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
@@ -58,6 +62,14 @@ func TestConfigValidation(t *testing.T) {
|
||||
input: `{"gzip":{"min_length": 42, "compression_level": 9}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
input: `{"gzip": [1, 2, 3]}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
input: `{"gzip": "nope"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
@@ -103,3 +115,88 @@ func TestConfigValue(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigRejectsWrongShapedOptions covers options the validation policy has to
|
||||
// reject itself. Defaults are merged over the raw config, so an option that
|
||||
// should hold an object would otherwise be silently replaced by the defaults
|
||||
// instead of reaching the Go unmarshal that used to report the type error.
|
||||
func TestConfigRejectsWrongShapedOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
input: `{"gzip": [1, 2, 3]}`,
|
||||
wantErr: "invalid value for server.encoding.gzip field, should be an object",
|
||||
},
|
||||
{
|
||||
input: `{"gzip": 7}`,
|
||||
wantErr: "invalid value for server.encoding.gzip field, should be an object",
|
||||
},
|
||||
{
|
||||
input: `{"gzip": {"min_length": true}}`,
|
||||
wantErr: "invalid value for server.encoding.gzip.min_length field, should be a positive number",
|
||||
},
|
||||
{
|
||||
input: `{"gzip": {"min_length": "foobar"}}`,
|
||||
wantErr: "invalid value for server.encoding.gzip.min_length field, should be a positive number",
|
||||
},
|
||||
{
|
||||
input: `[1, 2, 3]`,
|
||||
wantErr: "config must be an object",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
_, err := NewConfigBuilder().WithBytes([]byte(test.input)).Parse()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got none", test.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %q", test.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigNoWarningsForKnownEncodingOptions verifies the server.encoding spec
|
||||
// registered by this package lets config.ParseConfig recognize the section's
|
||||
// options. Without the registration the section is treated as open, so this also
|
||||
// guards against the unknown-option warning below silently going missing.
|
||||
func TestConfigNoWarningsForKnownEncodingOptions(t *testing.T) {
|
||||
raw := []byte(`{"server": {"encoding": {"gzip": {"min_length": 42, "compression_level": 1}}}}`)
|
||||
conf, err := config.ParseConfig(raw, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(conf.Warnings) != 0 {
|
||||
t.Fatalf("expected no warnings, got %v", conf.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigWarnsOnUnknownEncodingOption(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
raw: `{"server": {"encoding": {"typo": 5}}}`,
|
||||
want: `unknown configuration option "server.encoding.typo" encountered`,
|
||||
},
|
||||
{
|
||||
raw: `{"server": {"encoding": {"gzip": {"typo": 5}}}}`,
|
||||
want: `unknown configuration option "server.encoding.gzip.typo" encountered`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.raw, func(t *testing.T) {
|
||||
conf, err := config.ParseConfig([]byte(tc.raw), "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(conf.Warnings, tc.want) {
|
||||
t.Fatalf("expected warning %q, got %v", tc.want, conf.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# METADATA
|
||||
# description: |
|
||||
# Injects defaults and validates the server.encoding configuration (gzip
|
||||
# response compression). Evaluated by the encoding config builder. The policy
|
||||
# rejects options of the wrong shape; the remaining field type validation is
|
||||
# handled by unmarshaling into the Go struct.
|
||||
#
|
||||
# Input: {"config": <raw server.encoding config>}
|
||||
# Rules read by the Go layer: processed (config + defaults), errors (fatal).
|
||||
package opa.config.server.encoding
|
||||
|
||||
import data.opa.config.util
|
||||
|
||||
# Defaults mirror encoding/config.go.
|
||||
_default_min_length := 1024
|
||||
|
||||
_default_compression_level := 9
|
||||
|
||||
# _accepted_compression_levels mirrors gzip.NoCompression, gzip.BestSpeed and
|
||||
# gzip.BestCompression.
|
||||
_accepted_compression_levels := {0, 1, 9}
|
||||
|
||||
# METADATA
|
||||
# description: the config with gzip encoding defaults injected for absent options.
|
||||
processed := object.union_n(array.concat([input.config], [patch | some patch in _patches]))
|
||||
|
||||
_patches contains {"gzip": {"min_length": _default_min_length}} if util.absent(["gzip", "min_length"])
|
||||
|
||||
_patches contains {"gzip": {"compression_level": _default_compression_level}} if {
|
||||
util.absent(["gzip", "compression_level"])
|
||||
}
|
||||
|
||||
errors contains "invalid value for server.encoding.gzip field, should be an object" if {
|
||||
util.not_object(["gzip"])
|
||||
}
|
||||
|
||||
errors contains "invalid value for server.encoding.gzip.min_length field, should be a positive number" if {
|
||||
util.not_positive_number(["gzip", "min_length"])
|
||||
}
|
||||
|
||||
errors contains "invalid value for server.encoding.gzip.compression_level field, accepted values are 0, 1 or 9" if {
|
||||
value := util.value(["gzip", "compression_level"])
|
||||
value != null
|
||||
not value in _accepted_compression_levels
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package opa.config.server.encoding_test
|
||||
|
||||
import data.opa.config.server.encoding
|
||||
|
||||
test_injects_defaults[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "empty config", "config": {}},
|
||||
{"note": "gzip present", "config": {"gzip": {}}},
|
||||
{"note": "min_length null", "config": {"gzip": {"min_length": null}}},
|
||||
{"note": "compression_level null", "config": {"gzip": {"compression_level": null}}},
|
||||
]
|
||||
|
||||
result := encoding.processed with input as {"config": tc.config}
|
||||
result.gzip.min_length == 1024
|
||||
result.gzip.compression_level == 9
|
||||
}
|
||||
|
||||
test_preserves_configured_values if {
|
||||
raw := {"gzip": {"min_length": 42, "compression_level": 1}}
|
||||
result := encoding.processed with input as {"config": raw}
|
||||
result.gzip.min_length == 42
|
||||
result.gzip.compression_level == 1
|
||||
}
|
||||
|
||||
test_preserves_unknown_keys if {
|
||||
result := encoding.processed with input as {"config": {"gzip": {"random_key": 0}}}
|
||||
result.gzip.random_key == 0
|
||||
}
|
||||
|
||||
test_rejects_non_positive_min_length[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "zero", "min_length": 0},
|
||||
{"note": "negative", "min_length": -10},
|
||||
]
|
||||
|
||||
result := encoding.errors with input as {"config": {"gzip": {"min_length": tc.min_length}}}
|
||||
"invalid value for server.encoding.gzip.min_length field, should be a positive number" in result
|
||||
}
|
||||
|
||||
# A non-number min_length is rejected here rather than left to the Go unmarshal,
|
||||
# so the message names the option instead of the Go field.
|
||||
test_rejects_non_number_min_length[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "string", "min_length": "foobar"},
|
||||
{"note": "boolean", "min_length": true},
|
||||
{"note": "array", "min_length": [1]},
|
||||
]
|
||||
|
||||
result := encoding.errors with input as {"config": {"gzip": {"min_length": tc.min_length}}}
|
||||
"invalid value for server.encoding.gzip.min_length field, should be a positive number" in result
|
||||
}
|
||||
|
||||
# Without this check the gzip defaults would be merged over the bad value,
|
||||
# silently replacing it with a well-formed object.
|
||||
test_rejects_non_object_gzip[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "array", "config": {"gzip": [1, 2, 3]}},
|
||||
{"note": "string", "config": {"gzip": "nope"}},
|
||||
{"note": "number", "config": {"gzip": 7}},
|
||||
]
|
||||
|
||||
result := encoding.errors with input as {"config": tc.config}
|
||||
"invalid value for server.encoding.gzip field, should be an object" in result
|
||||
}
|
||||
|
||||
test_rejects_unaccepted_compression_level[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "out of range", "compression_level": 13},
|
||||
{"note": "negative", "compression_level": -1},
|
||||
{"note": "string", "compression_level": "9"},
|
||||
]
|
||||
|
||||
result := encoding.errors with input as {"config": {"gzip": {"compression_level": tc.compression_level}}}
|
||||
"invalid value for server.encoding.gzip.compression_level field, accepted values are 0, 1 or 9" in result
|
||||
}
|
||||
|
||||
test_accepts_valid_compression_levels[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "none", "compression_level": 0},
|
||||
{"note": "best speed", "compression_level": 1},
|
||||
{"note": "best compression", "compression_level": 9},
|
||||
]
|
||||
|
||||
result := encoding.errors with input as {"config": {"gzip": {"compression_level": tc.compression_level}}}
|
||||
count(result) == 0
|
||||
}
|
||||
|
||||
test_empty_config_has_no_errors if {
|
||||
result := encoding.errors with input as {"config": {}}
|
||||
count(result) == 0
|
||||
}
|
||||
@@ -67,8 +67,14 @@ func (b *ConfigBuilder) WithBytes(config []byte) *ConfigBuilder {
|
||||
|
||||
// Parse returns a valid Config object with defaults injected.
|
||||
func (b *ConfigBuilder) Parse() (*Config, error) {
|
||||
return b.ParseWithContext(context.Background())
|
||||
}
|
||||
|
||||
// ParseWithContext returns a valid Config object with defaults injected, using
|
||||
// ctx to evaluate the validation policy.
|
||||
func (b *ConfigBuilder) ParseWithContext(ctx context.Context) (*Config, error) {
|
||||
var result Config
|
||||
if _, err := configpolicy.EvalConfigInto(context.TODO(), validationPolicy, b.raw, &result); err != nil {
|
||||
if _, err := configpolicy.EvalConfigInto(ctx, validationPolicy, b.raw, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
|
||||
@@ -2,7 +2,11 @@ package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/open-policy-agent/opa/v1/config"
|
||||
)
|
||||
|
||||
func TestConfigValidation(t *testing.T) {
|
||||
@@ -59,6 +63,14 @@ func TestConfigValidation(t *testing.T) {
|
||||
input: `{"prom": {"http_request_duration_seconds": {"random_key": 0}}}`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
input: `{"prom": [1, 2, 3]}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
input: `{"prom": {"http_request_duration_seconds": [1]}}`,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
@@ -127,3 +139,88 @@ func valuesAreEqual(a []float64, b []float64) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// TestConfigRejectsWrongShapedOptions covers options the validation policy has to
|
||||
// reject itself. Defaults are merged over the raw config, so an option that
|
||||
// should hold an object would otherwise be silently replaced by the defaults
|
||||
// instead of reaching the Go unmarshal that used to report the type error.
|
||||
func TestConfigRejectsWrongShapedOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
input: `{"prom": [1, 2, 3]}`,
|
||||
wantErr: "invalid value for server.metrics.prom field, should be an object",
|
||||
},
|
||||
{
|
||||
input: `{"prom": "nope"}`,
|
||||
wantErr: "invalid value for server.metrics.prom field, should be an object",
|
||||
},
|
||||
{
|
||||
input: `{"prom": {"http_request_duration_seconds": [1]}}`,
|
||||
wantErr: "invalid value for server.metrics.prom.http_request_duration_seconds field, should be an object",
|
||||
},
|
||||
{
|
||||
input: `{"prom": {"http_request_duration_seconds": {"buckets": ["a"]}}}`,
|
||||
wantErr: "buckets field, should be an array of numbers",
|
||||
},
|
||||
{
|
||||
input: `[1, 2, 3]`,
|
||||
wantErr: "config must be an object",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
_, err := NewConfigBuilder().WithBytes([]byte(test.input)).Parse()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got none", test.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %q", test.wantErr, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigNoWarningsForKnownMetricsOptions verifies the server.metrics spec
|
||||
// registered by this package lets config.ParseConfig recognize the section's
|
||||
// options. Without the registration the section is treated as open, so this also
|
||||
// guards against the unknown-option warning below silently going missing.
|
||||
func TestConfigNoWarningsForKnownMetricsOptions(t *testing.T) {
|
||||
raw := []byte(`{"server": {"metrics": {"prom": {"http_request_duration_seconds": {"buckets": [0.1, 1]}}}}}`)
|
||||
conf, err := config.ParseConfig(raw, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(conf.Warnings) != 0 {
|
||||
t.Fatalf("expected no warnings, got %v", conf.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigWarnsOnUnknownMetricsOption(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
raw: `{"server": {"metrics": {"typo": 5}}}`,
|
||||
want: `unknown configuration option "server.metrics.typo" encountered`,
|
||||
},
|
||||
{
|
||||
raw: `{"server": {"metrics": {"prom": {"typo": 5}}}}`,
|
||||
want: `unknown configuration option "server.metrics.prom.typo" encountered`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.raw, func(t *testing.T) {
|
||||
conf, err := config.ParseConfig([]byte(tc.raw), "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(conf.Warnings, tc.want) {
|
||||
t.Fatalf("expected warning %q, got %v", tc.want, conf.Warnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,57 @@
|
||||
# METADATA
|
||||
# description: |
|
||||
# Injects defaults for the server.metrics configuration (Prometheus HTTP
|
||||
# request duration histogram buckets). Evaluated by the metrics config
|
||||
# builder. Value/type validation of the buckets is handled by unmarshaling
|
||||
# into the Go struct.
|
||||
# Injects defaults and validates the server.metrics configuration (Prometheus
|
||||
# HTTP request duration histogram buckets). Evaluated by the metrics config
|
||||
# builder. The policy rejects options of the wrong shape; the remaining field
|
||||
# type validation is handled by unmarshaling into the Go struct.
|
||||
#
|
||||
# Input: {"config": <raw server.metrics config>}
|
||||
# Entrypoint: processed (config + defaults).
|
||||
# Rules read by the Go layer: processed (config + defaults), errors (fatal).
|
||||
package opa.config.server.metrics
|
||||
|
||||
import data.opa.config.util
|
||||
|
||||
# _default_buckets mirrors defaultHTTPRequestBuckets in config.go.
|
||||
_default_buckets := [1e-6, 5e-6, 1e-5, 5e-5, 1e-4, 5e-4, 1e-3, 0.01, 0.1, 1]
|
||||
|
||||
# METADATA
|
||||
# entrypoint: true
|
||||
# description: the config with the default histogram buckets injected when absent.
|
||||
processed := object.union_n(array.concat([input.config], [patch | some patch in _patches]))
|
||||
|
||||
_patches contains {"prom": {"http_request_duration_seconds": {"buckets": _default_buckets}}} if {
|
||||
_absent(["prom", "http_request_duration_seconds", "buckets"])
|
||||
util.absent(["prom", "http_request_duration_seconds", "buckets"])
|
||||
}
|
||||
|
||||
# _absent is true when the option at path is missing or explicitly null, matching
|
||||
# the pre-Rego behavior where a nil slice was replaced with defaults.
|
||||
_absent(path) if object.get(input.config, path, null) == null
|
||||
errors contains "invalid value for server.metrics.prom field, should be an object" if {
|
||||
util.not_object(["prom"])
|
||||
}
|
||||
|
||||
errors contains "invalid value for server.metrics.prom.http_request_duration_seconds field, should be an object" if {
|
||||
util.not_object(["prom", "http_request_duration_seconds"])
|
||||
}
|
||||
|
||||
# The message is a rule of its own because naming the option in the rule head would
|
||||
# put the line over the length limit.
|
||||
errors contains _buckets_msg if {
|
||||
_not_number_array(["prom", "http_request_duration_seconds", "buckets"])
|
||||
}
|
||||
|
||||
_buckets_field := "server.metrics.prom.http_request_duration_seconds.buckets"
|
||||
|
||||
_buckets_msg := $`invalid value for {_buckets_field} field, should be an array of numbers`
|
||||
|
||||
# _not_number_array rejects a present-but-wrong-shaped buckets option here rather
|
||||
# than leaving it to the Go unmarshal, so the message names the config option
|
||||
# instead of the Go struct field.
|
||||
_not_number_array(path) if {
|
||||
value := util.value(path)
|
||||
value != null
|
||||
not _number_array(value)
|
||||
}
|
||||
|
||||
_number_array(value) if {
|
||||
is_array(value)
|
||||
every item in value {
|
||||
is_number(item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import data.opa.config.server.metrics
|
||||
|
||||
_default_buckets := [1e-6, 5e-6, 1e-5, 5e-5, 1e-4, 5e-4, 1e-3, 0.01, 0.1, 1]
|
||||
|
||||
_buckets_field := "server.metrics.prom.http_request_duration_seconds.buckets"
|
||||
|
||||
_buckets_msg := $`invalid value for {_buckets_field} field, should be an array of numbers`
|
||||
|
||||
test_injects_default_buckets[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "empty config", "config": {}},
|
||||
@@ -31,3 +35,55 @@ test_preserves_unknown_keys if {
|
||||
result := metrics.processed with input as {"config": {"prom": {"random_key": 0}}}
|
||||
result.prom.random_key == 0
|
||||
}
|
||||
|
||||
# Without these checks the bucket defaults would be merged over the bad value,
|
||||
# silently replacing it with a well-formed object.
|
||||
test_rejects_non_object_prom[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "array", "config": {"prom": [1, 2, 3]}},
|
||||
{"note": "string", "config": {"prom": "nope"}},
|
||||
{"note": "number", "config": {"prom": 7}},
|
||||
]
|
||||
|
||||
result := metrics.errors with input as {"config": tc.config}
|
||||
"invalid value for server.metrics.prom field, should be an object" in result
|
||||
}
|
||||
|
||||
test_rejects_non_object_http_request_duration_seconds[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "array", "value": [1]},
|
||||
{"note": "string", "value": "x"},
|
||||
]
|
||||
|
||||
result := metrics.errors with input as {"config": {"prom": {"http_request_duration_seconds": tc.value}}}
|
||||
"invalid value for server.metrics.prom.http_request_duration_seconds field, should be an object" in result
|
||||
}
|
||||
|
||||
# A non-numeric buckets value is rejected here rather than left to the Go
|
||||
# unmarshal, so the message names the option instead of the Go field.
|
||||
test_rejects_non_number_array_buckets[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "string", "buckets": "x"},
|
||||
{"note": "object", "buckets": {}},
|
||||
{"note": "array of strings", "buckets": ["a"]},
|
||||
{"note": "mixed array", "buckets": [1, "a"]},
|
||||
]
|
||||
|
||||
config := {"prom": {"http_request_duration_seconds": {"buckets": tc.buckets}}}
|
||||
result := metrics.errors with input as {"config": config}
|
||||
_buckets_msg in result
|
||||
}
|
||||
|
||||
test_valid_config_has_no_errors[tc.note] if {
|
||||
some tc in [
|
||||
{"note": "empty config", "config": {}},
|
||||
{"note": "prom present", "config": {"prom": {}}},
|
||||
{"note": "section present", "config": {"prom": {"http_request_duration_seconds": {}}}},
|
||||
{"note": "custom buckets", "config": {"prom": {"http_request_duration_seconds": {"buckets": [0.1, 4]}}}},
|
||||
{"note": "empty buckets", "config": {"prom": {"http_request_duration_seconds": {"buckets": []}}}},
|
||||
{"note": "buckets null", "config": {"prom": {"http_request_duration_seconds": {"buckets": null}}}},
|
||||
]
|
||||
|
||||
result := metrics.errors with input as {"config": tc.config}
|
||||
count(result) == 0
|
||||
}
|
||||
|
||||
@@ -457,7 +457,7 @@ func NewRuntime(ctx context.Context, params Params) (*Runtime, error) {
|
||||
params.Router = http.NewServeMux()
|
||||
}
|
||||
|
||||
metricsConfig, parseConfigErr := extractMetricsConfig(config, params)
|
||||
metricsConfig, parseConfigErr := extractMetricsConfig(ctx, config, params)
|
||||
if parseConfigErr != nil {
|
||||
return nil, parseConfigErr
|
||||
}
|
||||
@@ -589,7 +589,7 @@ func NewRuntime(ctx context.Context, params Params) (*Runtime, error) {
|
||||
}
|
||||
|
||||
// extractMetricsConfig returns the configuration for server metrics and parsing errors if any
|
||||
func extractMetricsConfig(config []byte, params Params) (*metrics_config.Config, error) {
|
||||
func extractMetricsConfig(ctx context.Context, config []byte, params Params) (*metrics_config.Config, error) {
|
||||
opaParsedConfig, opaParsedConfigErr := opa_config.ParseConfig(config, params.ID)
|
||||
if opaParsedConfigErr != nil {
|
||||
return nil, opaParsedConfigErr
|
||||
@@ -601,7 +601,7 @@ func extractMetricsConfig(config []byte, params Params) (*metrics_config.Config,
|
||||
}
|
||||
|
||||
configBuilder := metrics_config.NewConfigBuilder()
|
||||
metricsParsedConfig, metricsParsedConfigErr := configBuilder.WithBytes(serverMetricsData).Parse()
|
||||
metricsParsedConfig, metricsParsedConfigErr := configBuilder.WithBytes(serverMetricsData).ParseWithContext(ctx)
|
||||
if metricsParsedConfigErr != nil {
|
||||
return nil, fmt.Errorf("server metrics configuration parse error: %w", metricsParsedConfigErr)
|
||||
}
|
||||
|
||||
+6
-6
@@ -232,13 +232,13 @@ func (s *Server) Init(ctx context.Context) (*Server, error) {
|
||||
s.Handler = s.initHandlerAuthn(s.Handler)
|
||||
|
||||
// compression handler
|
||||
s.Handler, err = s.initHandlerCompression(s.Handler)
|
||||
s.Handler, err = s.initHandlerCompression(ctx, s.Handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.DiagnosticHandler = s.initHandlerAuthn(s.DiagnosticHandler)
|
||||
|
||||
s.Handler, err = s.initHandlerDecodingLimits(s.Handler)
|
||||
s.Handler, err = s.initHandlerDecodingLimits(ctx, s.Handler)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -812,13 +812,13 @@ func (s *Server) initHandlerAuthz(handler http.Handler) http.Handler {
|
||||
// Enforces request body size limits on incoming requests. For gzipped requests,
|
||||
// it passes the size limit down the body-reading method via the request
|
||||
// context.
|
||||
func (s *Server) initHandlerDecodingLimits(handler http.Handler) (http.Handler, error) {
|
||||
func (s *Server) initHandlerDecodingLimits(ctx context.Context, handler http.Handler) (http.Handler, error) {
|
||||
cfg := s.manager.GetConfig()
|
||||
var decodingRawConfig []byte
|
||||
if cfg.Server != nil {
|
||||
decodingRawConfig = []byte(cfg.Server.Decoding)
|
||||
}
|
||||
decodingConfig, err := serverDecodingPlugin.NewConfigBuilder().WithBytes(decodingRawConfig).Parse()
|
||||
decodingConfig, err := serverDecodingPlugin.NewConfigBuilder().WithBytes(decodingRawConfig).ParseWithContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -827,13 +827,13 @@ func (s *Server) initHandlerDecodingLimits(handler http.Handler) (http.Handler,
|
||||
return decodingHandler, nil
|
||||
}
|
||||
|
||||
func (s *Server) initHandlerCompression(handler http.Handler) (http.Handler, error) {
|
||||
func (s *Server) initHandlerCompression(ctx context.Context, handler http.Handler) (http.Handler, error) {
|
||||
cfg := s.manager.GetConfig()
|
||||
var encodingRawConfig []byte
|
||||
if cfg.Server != nil {
|
||||
encodingRawConfig = []byte(cfg.Server.Encoding)
|
||||
}
|
||||
encodingConfig, err := serverEncodingPlugin.NewConfigBuilder().WithBytes(encodingRawConfig).Parse()
|
||||
encodingConfig, err := serverEncodingPlugin.NewConfigBuilder().WithBytes(encodingRawConfig).ParseWithContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user