Skip to content

Commit 404ee45

Browse files
committed
Add INI config utility to common/util module
This patch introduces a new INI manipulation utility with the ability to extend service configurations (customServiceConfig) by adding key-value pairs to specific sections. It includes comprehensive test coverage for various scenarios, including section detection, key validation, and edge cases. This utility does not replace existing keys if they are already specified via user input, and it optionally adds - based on the Unique parameter - keys of the same kind for a given section. This design preserves the original purpose of customServiceConfig as user input. Related: https://issues.redhat.com/browse/OSPRH-14309 Signed-off-by: Francesco Pantano <[email protected]>
1 parent c83d830 commit 404ee45

File tree

2 files changed

+325
-0
lines changed

2 files changed

+325
-0
lines changed

modules/common/util/ini.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
Copyright 2025 Red Hat
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package util
18+
19+
import (
20+
"errors"
21+
"fmt"
22+
"strings"
23+
)
24+
25+
// IniOption -
26+
type IniOption struct {
27+
Section string
28+
Key string
29+
Value string
30+
Unique bool
31+
}
32+
33+
// Define static errors
34+
var (
35+
ErrKeyAlreadyExists = errors.New("key already exists in section")
36+
ErrCouldNotPatchSection = errors.New("could not patch target section")
37+
)
38+
39+
// repr - print key: value in .ini format
40+
func (i *IniOption) repr() string {
41+
return fmt.Sprintf("%s = %s", i.Key, i.Value)
42+
}
43+
44+
// ExtendCustomServiceConfig - customServiceConfig is tokenized and parsed in a
45+
// loop where we keep track of two indexes:
46+
// - index: keep track of the current extracted token
47+
// - sectionIndex: when we detect a [<section>] within a token, we save the index
48+
// and we update it with the next section when is detected. This way we can
49+
// make sure to evaluate only the keys of the target section
50+
//
51+
// when an invalid case is detected, we return the customServiceConfig string
52+
// unchanged, otherwise the new key=value is appended as per the IniOption struct
53+
func ExtendCustomServiceConfig(
54+
iniString string,
55+
customServiceConfigExtend IniOption,
56+
) (string, error) {
57+
// customServiceConfig is empty
58+
if len(iniString) == 0 {
59+
return iniString, nil
60+
}
61+
// Position where insert new option (-1 = target section not found)
62+
index := -1
63+
// Current section header position (-1 = no section found)
64+
sectionIndex := -1
65+
svcConfigLines := strings.Split(iniString, "\n")
66+
sectionName := ""
67+
for idx, rawLine := range svcConfigLines {
68+
line := strings.TrimSpace(rawLine)
69+
token := strings.TrimSpace(strings.SplitN(line, "=", 2)[0])
70+
71+
if token == "" || strings.HasPrefix(token, "#") {
72+
// Skip blank lines and comments
73+
continue
74+
}
75+
if strings.HasPrefix(token, "[") && strings.HasSuffix(token, "]") {
76+
// Note the section name before looking for a backend_name
77+
sectionName = strings.Trim(token, "[]")
78+
sectionIndex = idx
79+
// increment the index (as an offset) only when a section is found
80+
if sectionName == customServiceConfigExtend.Section {
81+
index = idx + 1
82+
}
83+
}
84+
// Check if key already exists in target section
85+
if customServiceConfigExtend.Unique && token == customServiceConfigExtend.Key && sectionIndex > -1 &&
86+
sectionName == customServiceConfigExtend.Section {
87+
errMsg := fmt.Errorf("%w: key %s in section %s", ErrKeyAlreadyExists, token, sectionName)
88+
return iniString, errMsg
89+
}
90+
}
91+
// index didn't progress during the customServiceConfig scan:
92+
// return unchanged, but no error
93+
if index == -1 {
94+
errMsg := fmt.Errorf("%w: %s", ErrCouldNotPatchSection, customServiceConfigExtend.Section)
95+
return iniString, errMsg
96+
}
97+
// index has a valid value and it is used as a pivot to inject the new ini
98+
// option right after the section
99+
var svcExtended []string
100+
svcExtended = append(svcExtended, svcConfigLines[:index]...)
101+
svcExtended = append(svcExtended, []string{customServiceConfigExtend.repr()}...)
102+
svcExtended = append(svcExtended, svcConfigLines[index:]...)
103+
return strings.Join(svcExtended, "\n"), nil
104+
}

modules/common/util/ini_test.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package util // nolint:revive
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/onsi/gomega" // nolint:revive
7+
)
8+
9+
var (
10+
defaultTestIniOption = IniOption{
11+
Section: "foo",
12+
Key: "s3_store_cacert",
13+
Value: "/etc/pki/tls/certs/ca-bundle.crt",
14+
Unique: true,
15+
}
16+
17+
aliasKeyIniOption = IniOption{
18+
Section: "pci",
19+
Key: "alias",
20+
Value: "{ \"device_type\": \"type-VF\", \"resource_class\": \"CUSTOM_A16_16A\", \"name\": \"A16_16A\" }",
21+
Unique: false,
22+
}
23+
24+
deviceSpecIniOption = IniOption{
25+
Section: "pci",
26+
Key: "device_spec",
27+
Value: "{ \"vendor_id\": \"10de\", \"product_id\": \"25b6\", \"address\": \"0000:25:00.6\", \"resource_class\": \"CUSTOM_A16_8A\", \"managed\": \"yes\" }",
28+
Unique: false,
29+
}
30+
)
31+
32+
var tests = []struct {
33+
name string
34+
input string
35+
option IniOption
36+
expected string
37+
err string
38+
}{
39+
{
40+
name: "empty customServiceConfig",
41+
input: "",
42+
option: defaultTestIniOption,
43+
expected: "",
44+
err: "",
45+
},
46+
{
47+
name: "field to non-existing section",
48+
input: `[DEFAULT]
49+
debug=true
50+
enabled_backends = backend:s3
51+
[foo]
52+
bar = bar
53+
foo = foo
54+
[backend]
55+
option1 = value1`,
56+
option: IniOption{
57+
Section: "bar",
58+
Key: "foo",
59+
Value: "foo",
60+
},
61+
expected: `[DEFAULT]
62+
debug=true
63+
enabled_backends = backend:s3
64+
[foo]
65+
bar = bar
66+
foo = foo
67+
[backend]
68+
option1 = value1`,
69+
err: "could not patch target section: bar",
70+
},
71+
{
72+
name: "add new ini line to a section in the middle",
73+
input: `[DEFAULT]
74+
debug=true
75+
enabled_backends = backend:s3
76+
[foo]
77+
bar = bar
78+
foo = foo
79+
[backend]
80+
option1 = value1`,
81+
option: defaultTestIniOption,
82+
expected: `[DEFAULT]
83+
debug=true
84+
enabled_backends = backend:s3
85+
[foo]
86+
s3_store_cacert = /etc/pki/tls/certs/ca-bundle.crt
87+
bar = bar
88+
foo = foo
89+
[backend]
90+
option1 = value1`,
91+
err: "",
92+
},
93+
{
94+
name: "section is not found, return it unchanged",
95+
input: `[DEFAULT]
96+
debug=true
97+
enabled_backends = backend:s3
98+
[backend]
99+
s3_store_cacert = /etc/pki/tls/certs/ca-bundle.crt `,
100+
option: defaultTestIniOption,
101+
expected: `[DEFAULT]
102+
debug=true
103+
enabled_backends = backend:s3
104+
[backend]
105+
s3_store_cacert = /etc/pki/tls/certs/ca-bundle.crt `,
106+
err: "could not patch target section: foo",
107+
},
108+
{
109+
name: "Add option to a section at the very beginning of customServiceConfig",
110+
input: `[foo]
111+
bar = bar
112+
foo = foo
113+
[backend]
114+
option1 = value1 `,
115+
option: defaultTestIniOption,
116+
expected: `[foo]
117+
s3_store_cacert = /etc/pki/tls/certs/ca-bundle.crt
118+
bar = bar
119+
foo = foo
120+
[backend]
121+
option1 = value1 `,
122+
err: "",
123+
},
124+
{
125+
name: "Add option to a section at the very bottom of customServiceConfig",
126+
input: `[DEFAULT]
127+
debug=true
128+
enabled_backends = backend:s3
129+
[backend]
130+
# this is a comment
131+
option1 = value1
132+
[foo]
133+
# this is a comment
134+
bar = bar
135+
foo = foo`,
136+
option: defaultTestIniOption,
137+
expected: `[DEFAULT]
138+
debug=true
139+
enabled_backends = backend:s3
140+
[backend]
141+
# this is a comment
142+
option1 = value1
143+
[foo]
144+
s3_store_cacert = /etc/pki/tls/certs/ca-bundle.crt
145+
# this is a comment
146+
bar = bar
147+
foo = foo`,
148+
err: "",
149+
},
150+
{
151+
name: "Add option to an empty target section",
152+
input: `[DEFAULT]
153+
debug=true
154+
[foo]`,
155+
option: defaultTestIniOption,
156+
expected: `[DEFAULT]
157+
debug=true
158+
[foo]
159+
s3_store_cacert = /etc/pki/tls/certs/ca-bundle.crt`,
160+
err: "",
161+
},
162+
{
163+
name: "key/value already present in the target section",
164+
input: `[DEFAULT]
165+
debug=true
166+
[foo]
167+
s3_store_cacert = /my/custom/path/ca-bundle.crt`,
168+
option: defaultTestIniOption,
169+
expected: `[DEFAULT]
170+
debug=true
171+
[foo]
172+
s3_store_cacert = /my/custom/path/ca-bundle.crt`,
173+
err: "key already exists in section: key s3_store_cacert in section foo",
174+
},
175+
{
176+
name: "add new ini line anyway even though a section contains the same key ",
177+
input: `[pci]
178+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.4", "resource_class": "CUSTOM_A16_16A", "managed": "no" }
179+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.5", "resource_class": "CUSTOM_A16_8A", "managed": "no" }
180+
alias = { "device_type": "type-VF", "resource_class": "CUSTOM_A16_8A", "name": "A16_8A" }`,
181+
option: aliasKeyIniOption,
182+
expected: `[pci]
183+
alias = { "device_type": "type-VF", "resource_class": "CUSTOM_A16_16A", "name": "A16_16A" }
184+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.4", "resource_class": "CUSTOM_A16_16A", "managed": "no" }
185+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.5", "resource_class": "CUSTOM_A16_8A", "managed": "no" }
186+
alias = { "device_type": "type-VF", "resource_class": "CUSTOM_A16_8A", "name": "A16_8A" }`,
187+
err: "",
188+
},
189+
{
190+
name: "add new ini line anyway even though a section contains the same key ",
191+
input: `[pci]
192+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.4", "resource_class": "CUSTOM_A16_16A", "managed": "no" }
193+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.5", "resource_class": "CUSTOM_A16_8A", "managed": "no" }
194+
alias = { "device_type": "type-VF", "resource_class": "CUSTOM_A16_16A", "name": "A16_16A" }
195+
alias = { "device_type": "type-VF", "resource_class": "CUSTOM_A16_8A", "name": "A16_8A" }`,
196+
option: deviceSpecIniOption,
197+
expected: `[pci]
198+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.6", "resource_class": "CUSTOM_A16_8A", "managed": "yes" }
199+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.4", "resource_class": "CUSTOM_A16_16A", "managed": "no" }
200+
device_spec = { "vendor_id": "10de", "product_id": "25b6", "address": "0000:25:00.5", "resource_class": "CUSTOM_A16_8A", "managed": "no" }
201+
alias = { "device_type": "type-VF", "resource_class": "CUSTOM_A16_16A", "name": "A16_16A" }
202+
alias = { "device_type": "type-VF", "resource_class": "CUSTOM_A16_8A", "name": "A16_8A" }`,
203+
err: "",
204+
},
205+
}
206+
207+
func TestExtendCustomServiceConfig(t *testing.T) {
208+
for _, tt := range tests {
209+
t.Run(tt.name, func(t *testing.T) {
210+
g := NewWithT(t)
211+
output, err := ExtendCustomServiceConfig(tt.input, tt.option)
212+
g.Expect(output).To(Equal(tt.expected))
213+
if err != nil {
214+
// check the string matches the expected error message
215+
g.Expect(err.Error()).To(Equal(tt.err))
216+
} else {
217+
g.Expect(err).ToNot(HaveOccurred())
218+
}
219+
})
220+
}
221+
}

0 commit comments

Comments
 (0)