Skip to content

Commit 2f6c34b

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 2f6c34b

File tree

2 files changed

+300
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)