Skip to content

Commit 9d6f4e9

Browse files
authored
Merge pull request #641 from fmount/common_ini
Add INI config utility to common/util module
2 parents db66d77 + efeeb5a commit 9d6f4e9

2 files changed

Lines changed: 325 additions & 0 deletions

File tree

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)