Skip to content

Commit 8e08ada

Browse files
committed
charts/console: add v2 -> v3 config migration
Add `ConfigFromV2` as a utility function in `charts/console` for migrating V2 configs to V3 configs. The migration is based off Jake Cahill's implementation in the docs repo but has been transposed to JQ to improve maintainablity. Other data manipulation DSLs (JSONpath, custom) where considered but JQ's ubiquity and power is difficult to compete with.
1 parent 03dd394 commit 8e08ada

File tree

16 files changed

+985
-1
lines changed

16 files changed

+985
-1
lines changed

acceptance/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ require (
143143
github.com/imdario/mergo v0.3.16 // indirect
144144
github.com/inconshreveable/mousetrap v1.1.0 // indirect
145145
github.com/invopop/jsonschema v0.12.0 // indirect
146+
github.com/itchyny/gojq v0.12.17 // indirect
147+
github.com/itchyny/timefmt-go v0.1.6 // indirect
146148
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
147149
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
148150
github.com/jcmturner/gofork v1.7.6 // indirect

acceptance/go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
355355
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
356356
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
357357
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
358+
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
359+
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
360+
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
361+
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
358362
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
359363
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
360364
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=

charts/console/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/cloudhut/common v0.11.0
77
github.com/cockroachdb/errors v1.11.3
88
github.com/google/gofuzz v1.2.0
9+
github.com/itchyny/gojq v0.12.17
910
github.com/redpanda-data/console/backend v0.0.0-20250915195818-3cd9fabec94b
1011
github.com/redpanda-data/redpanda-operator/gotohelm v1.2.1-0.20250909192010-c59ff494d04a
1112
github.com/redpanda-data/redpanda-operator/pkg v0.0.0-20250124085449-058118a82f50
@@ -86,6 +87,7 @@ require (
8687
github.com/imdario/mergo v0.3.16 // indirect
8788
github.com/inconshreveable/mousetrap v1.1.0 // indirect
8889
github.com/invopop/jsonschema v0.12.0 // indirect
90+
github.com/itchyny/timefmt-go v0.1.6 // indirect
8991
github.com/jmoiron/sqlx v1.4.0 // indirect
9092
github.com/josharian/intern v1.0.0 // indirect
9193
github.com/json-iterator/go v1.1.12 // indirect

charts/console/go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
222222
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
223223
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
224224
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
225+
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
226+
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
227+
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
228+
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
225229
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
226230
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
227231
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
// Copyright 2025 Redpanda Data, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.md
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0
9+
10+
//go:build !gotohelm
11+
12+
package console
13+
14+
import (
15+
"strings"
16+
17+
"github.com/cockroachdb/errors"
18+
"github.com/itchyny/gojq"
19+
)
20+
21+
// ConfigFromV2 transforms a Console v2 configuration into a v3 configuration.
22+
// It additionally returns a string slice containing human readable
23+
// descriptions of fields that could not be migrated.
24+
//
25+
// Unknown or invalid data is left in place for Console itself handle (by crashing / logging).
26+
//
27+
// V2 Config:
28+
// - https://github.com/redpanda-data/console/blob/v2.8.10/backend/pkg/config/config.go
29+
// - https://github.com/redpanda-data/console-enterprise/blob/v2.8.10/backend/pkg/config/config.go
30+
//
31+
// V3 Config:
32+
// - https://github.com/redpanda-data/console/blob/v3.2.2/backend/pkg/config/config.go
33+
// - https://github.com/redpanda-data/console-enterprise/blob/v3.2.2/backend/pkg/config/config.go
34+
func ConfigFromV2(v2 map[string]any) (map[string]any, []string, error) {
35+
v3 := make(map[string]any)
36+
37+
for _, m := range mappings {
38+
val, ok, err := execQueryScalar[any](v2, m.code)
39+
if err != nil {
40+
return nil, nil, errors.Wrapf(err, "failed to execute query for path %v", m.dst)
41+
}
42+
if !ok {
43+
continue
44+
}
45+
if err := setPath(v3, m.dst, val); err != nil {
46+
return nil, nil, errors.Wrapf(err, "failed to set path %v", m.dst)
47+
}
48+
}
49+
50+
// Generate warnings
51+
var warnings []string
52+
for _, code := range warningQueries {
53+
results, err := execQuery[string](v2, code)
54+
if err != nil {
55+
return nil, nil, errors.Wrap(err, "failed to generate warnings")
56+
}
57+
warnings = append(warnings, results...)
58+
}
59+
60+
return v3, warnings, nil
61+
}
62+
63+
type mappingSpec struct {
64+
dst string
65+
query string
66+
}
67+
68+
type mapping struct {
69+
dst []string
70+
code *gojq.Code
71+
}
72+
73+
// mappings is a slice of destination path to JQ query that together migrate a
74+
// Console v2 config to a Console v3 config. Its behavior is exactly the same
75+
// as the converter in our public docs (Thanks Jake!)
76+
// https://github.com/redpanda-data/docs-ui/blob/d55545f392e0a9aaa0dbd193606a2b629d779699/console-config-migrator/main.go#L25
77+
// Due to module dependency conflicts and the quasi closed source nature of
78+
// console typing the configurations was deemed a non-option. JQ was elected as
79+
// it's relatively well known and substantially easier to comprehend than
80+
// map[string]any manipulations.
81+
var mappings = compileMappings([]mappingSpec{
82+
// Authentication
83+
{"authentication.jwtSigningKey", ".login.jwtSecret"},
84+
{"authentication.useSecureCookies", ".login.useSecureCookies"},
85+
{"authentication.basic", ".login.plain"},
86+
{"authentication.oidc", `.login // {} | to_entries | sort_by(.key) | map(select(.value.enabled?) | .value) | first | del(.realm, .directory)`},
87+
88+
// Schema Registry
89+
{"schemaRegistry.authentication.basic.username", ".kafka.schemaRegistry.username"},
90+
{"schemaRegistry.authentication.basic.password", ".kafka.schemaRegistry.password"},
91+
{"schemaRegistry.authentication.bearerToken", ".kafka.schemaRegistry.bearerToken"},
92+
{"schemaRegistry.authentication.impersonateUser", `.kafka.schemaRegistry | select(.) | .username == null`},
93+
{"schemaRegistry", `.kafka.schemaRegistry | del(.username, .password, .bearerToken)`},
94+
95+
// Kafka
96+
{"kafka.sasl.enabled", "true"},
97+
{"kafka.sasl.impersonateUser", "true"},
98+
{"kafka", `.kafka | del(.schemaRegistry, .protobuf, .cbor, .messagePack)`},
99+
100+
// Serde
101+
{"serde.protobuf", ".kafka.protobuf"},
102+
{"serde.cbor", ".kafka.cbor"},
103+
{"serde.messagePack", ".kafka.messagePack"},
104+
{"serde.maxDeserializationPayloadSize", ".console.maxDeserializationPayloadSize"},
105+
106+
// Connect rename
107+
{"kafkaConnect", ".connect"},
108+
109+
// Redpanda adminApi
110+
{"redpanda", `.redpanda | del(.adminApi.username, .adminApi.password)`},
111+
{"redpanda.adminApi.authentication.basic.username", ".redpanda.adminApi.username"},
112+
{"redpanda.adminApi.authentication.basic.password", ".redpanda.adminApi.password"},
113+
{"redpanda.adminApi.authentication.impersonateUser", `.redpanda.adminApi | select(.) | .username == null`},
114+
115+
// RoleBindings
116+
{"authorization.roleBindings", `.roleBindings | map({
117+
roleName: .roleName,
118+
users: [.subjects[] | select(.kind == "user" or .kind == null) | {
119+
name: .name,
120+
loginType: (if .provider == "Plain" then "basic" else "oidc" end)
121+
}]
122+
})?`},
123+
124+
// Copy remaining top-level fields (empty dst means merge into root)
125+
{"", "del(.connect, .console, .enterprise, .kafka, .login, .roleBindings, .redpanda)"},
126+
})
127+
128+
// warningQueries is a slice of JQ queries that produce warnings about
129+
// configurations that can not be migrate to Console V3.
130+
var warningQueries = compileWarnings([]string{
131+
`.login // {} | to_entries | sort_by(.key) | map(select(.value.enabled?)) | select(length > 1) | "Elected '\(.[0].key)' as OIDC provider out of \(map(.key)). Only one provider is supported in v3."`,
132+
`.login // {} | to_entries | sort_by(.key) | .[] | select(.value.enabled? and .value.realm? != null) | "Removed 'realm' from '\(.key)'. OIDC groups are not supported in v3. Create Roles in Redpanda instead."`,
133+
`.login // {} | to_entries | sort_by(.key) | .[] | select(.value.enabled? and .value.directory? != null) | select(length > 1) | "Removed 'directory' from '\(.key)'. OIDC groups are not supported in v3. Create Roles in Redpanda instead."`,
134+
`.roleBindings.[]? | . as $binding | .subjects.[]? | select(.kind != "user") | "Removed group subject from role binding '\($binding.roleName)'. Groups are not supported in v3."`,
135+
})
136+
137+
func compileMappings(specs []mappingSpec) []mapping {
138+
mappings := make([]mapping, len(specs))
139+
for i, spec := range specs {
140+
var dst []string
141+
if spec.dst != "" {
142+
dst = strings.Split(spec.dst, ".")
143+
}
144+
mappings[i] = mapping{
145+
dst: dst,
146+
code: mustCompile(spec.query),
147+
}
148+
}
149+
return mappings
150+
}
151+
152+
func compileWarnings(queries []string) []*gojq.Code {
153+
compiled := make([]*gojq.Code, len(queries))
154+
for i, query := range queries {
155+
compiled[i] = mustCompile(query)
156+
}
157+
return compiled
158+
}
159+
160+
func execQuery[T any](data map[string]any, code *gojq.Code) ([]T, error) {
161+
iter := code.Run(data)
162+
163+
var results []T
164+
for {
165+
result, ok := iter.Next()
166+
if !ok {
167+
break
168+
}
169+
170+
// gojq can produce some strange results:
171+
// - errors are returned through Next and must be checked
172+
// -
173+
switch result := result.(type) {
174+
// Errors are returned through .Next and need to be checked.
175+
case error:
176+
return nil, errors.WithStack(result)
177+
// nil (untyped) can be returned directly. We don't want to emit nils, so skip.
178+
case nil:
179+
continue
180+
// nil maps in an any box ( any(map[string]any(nil)) ) can also be
181+
// returned. They need to be unboxed and explicitly checked.
182+
case map[string]any:
183+
if result == nil {
184+
continue
185+
}
186+
}
187+
188+
results = append(results, result.(T))
189+
}
190+
191+
return results, nil
192+
}
193+
194+
func execQueryScalar[T comparable](data map[string]any, code *gojq.Code) (T, bool, error) {
195+
var zero T
196+
out, err := execQuery[T](data, code)
197+
if err != nil {
198+
return zero, false, err
199+
}
200+
if len(out) == 1 {
201+
return out[0], out[0] != zero, nil
202+
}
203+
return zero, false, nil
204+
}
205+
206+
func mustCompile(expr string) *gojq.Code {
207+
query, err := gojq.Parse(expr)
208+
if err != nil {
209+
panic(err)
210+
}
211+
212+
code, err := gojq.Compile(query)
213+
if err != nil {
214+
panic(err)
215+
}
216+
return code
217+
}
218+
219+
// setPath sets a value in a nested map structure using a slice of keys.
220+
func setPath(m map[string]any, path []string, value any) error {
221+
// Special case, if path is empty merge value into m.
222+
if len(path) == 0 {
223+
valueMap, ok := value.(map[string]any)
224+
if !ok {
225+
return errors.Newf("cannot merge non-map value into root: %T", value)
226+
}
227+
mergeMaps(m, valueMap)
228+
return nil
229+
}
230+
231+
curr := m
232+
for i := 0; i < len(path)-1; i++ {
233+
key := path[i]
234+
if _, exists := curr[key]; !exists {
235+
curr[key] = make(map[string]any)
236+
}
237+
238+
next, ok := curr[key].(map[string]any)
239+
if !ok {
240+
return errors.Newf("cannot traverse through non-map at key %q: %T", key, curr[key])
241+
}
242+
curr = next
243+
}
244+
245+
lastKey := path[len(path)-1]
246+
existing, exists := curr[lastKey]
247+
if !exists {
248+
curr[lastKey] = value
249+
return nil
250+
}
251+
252+
// If both existing and new values are maps, merge them
253+
existingMap, existingIsMap := existing.(map[string]any)
254+
valueMap, valueIsMap := value.(map[string]any)
255+
if existingIsMap && valueIsMap {
256+
mergeMaps(existingMap, valueMap)
257+
} else {
258+
// Otherwise overwrite
259+
curr[lastKey] = value
260+
}
261+
262+
return nil
263+
}
264+
265+
func mergeMaps(dst, src map[string]any) {
266+
for k, v := range src {
267+
existing, exists := dst[k]
268+
if !exists {
269+
dst[k] = v
270+
continue
271+
}
272+
273+
// If both are maps, merge recursively
274+
dstMap, dstIsMap := existing.(map[string]any)
275+
srcMap, srcIsMap := v.(map[string]any)
276+
if dstIsMap && srcIsMap {
277+
mergeMaps(dstMap, srcMap)
278+
} else {
279+
// Otherwise overwrite
280+
dst[k] = v
281+
}
282+
}
283+
}

charts/console/migrate_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright 2025 Redpanda Data, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.md
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0
9+
10+
package console
11+
12+
import (
13+
"testing"
14+
15+
"github.com/stretchr/testify/require"
16+
"golang.org/x/tools/txtar"
17+
"sigs.k8s.io/yaml"
18+
19+
"github.com/redpanda-data/redpanda-operator/pkg/testutil"
20+
)
21+
22+
func TestConfigFromV2(t *testing.T) {
23+
cases, err := txtar.ParseFile("testdata/migrate-cases.txtar")
24+
require.NoError(t, err)
25+
26+
goldens := testutil.NewTxTar(t, "testdata/migrate-cases.golden.txtar")
27+
28+
for _, tc := range cases.Files {
29+
t.Run(tc.Name, func(t *testing.T) {
30+
var input map[string]any
31+
require.NoError(t, yaml.Unmarshal(tc.Data, &input))
32+
33+
converted, warnings, errJSONPath := ConfigFromV2(input)
34+
require.NoError(t, errJSONPath)
35+
36+
actual, err := yaml.Marshal(map[string]any{
37+
"output": converted,
38+
"warnings": warnings,
39+
})
40+
require.NoError(t, err)
41+
42+
goldens.AssertGolden(t, testutil.YAML, tc.Name, append(actual, '\n'))
43+
})
44+
}
45+
}

0 commit comments

Comments
 (0)