Skip to content

Commit a062f07

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, nor does it add a new section if one does not already exist in the specified configuration. 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 a062f07

File tree

2 files changed

+280
-0
lines changed

2 files changed

+280
-0
lines changed

modules/common/util/ini.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
}
31+
32+
// Define static errors at package level
33+
var (
34+
ErrKeyAlreadyExists = errors.New("key already exists in section")
35+
ErrCouldNotPatchSection = errors.New("could not patch target section")
36+
)
37+
38+
// repr - print key: value in .ini format
39+
func (i *IniOption) repr() string {
40+
return fmt.Sprintf("%s = %s", i.Key, i.Value)
41+
}
42+
43+
// ExtendCustomServiceConfig - customServiceConfig is tokenized and parsed in a
44+
// loop where we keep track of two indexes:
45+
// - index: keep track of the current extracted token
46+
// - sectionIndex: when we detect a [<section>] within a token, we save the index
47+
// and we update it with the next section when is detected. This way we can
48+
// make sure to evaluate only the keys of the target section
49+
//
50+
// when an invalid case is detected, we return the customServiceConfig string
51+
// unchanged, otherwise the new key=value is appended as per the IniOption struct
52+
func ExtendCustomServiceConfig(
53+
iniString string,
54+
customServiceConfigExtend IniOption,
55+
) (string, error) {
56+
// customServiceConfig is empty
57+
if len(iniString) == 0 {
58+
return iniString, nil
59+
}
60+
// Position where insert new option (-1 = target section not found)
61+
index := -1
62+
// Current section header position (-1 = no section found)
63+
sectionIndex := -1
64+
svcConfigLines := strings.Split(iniString, "\n")
65+
sectionName := ""
66+
for idx, rawLine := range svcConfigLines {
67+
line := strings.TrimSpace(rawLine)
68+
token := strings.TrimSpace(strings.SplitN(line, "=", 2)[0])
69+
70+
if token == "" || strings.HasPrefix(token, "#") {
71+
// Skip blank lines and comments
72+
continue
73+
}
74+
if strings.HasPrefix(token, "[") && strings.HasSuffix(token, "]") {
75+
// Note the section name before looking for a backend_name
76+
sectionName = strings.Trim(token, "[]")
77+
sectionIndex = idx
78+
// increment the index (as an offset) only when a section is found
79+
if sectionName == customServiceConfigExtend.Section {
80+
index = idx + 1
81+
}
82+
}
83+
// Check if key already exists in target section
84+
if token == customServiceConfigExtend.Key && sectionIndex > -1 &&
85+
sectionName == customServiceConfigExtend.Section {
86+
errMsg := fmt.Errorf("%w: key %s in section %s", ErrKeyAlreadyExists, token, sectionName)
87+
return iniString, errMsg
88+
}
89+
}
90+
// index didn't progress during the customServiceConfig scan:
91+
// return unchanged, but no error
92+
if index == -1 {
93+
errMsg := fmt.Errorf("%w: %s", ErrCouldNotPatchSection, customServiceConfigExtend.Section)
94+
return iniString, errMsg
95+
}
96+
// index has a valid value and it is used as a pivot to inject the new ini
97+
// option right after the section
98+
var svcExtended []string
99+
svcExtended = append(svcExtended, svcConfigLines[:index]...)
100+
svcExtended = append(svcExtended, []string{customServiceConfigExtend.repr()}...)
101+
svcExtended = append(svcExtended, svcConfigLines[index:]...)
102+
return strings.Join(svcExtended, "\n"), nil
103+
}

modules/common/util/ini_test.go

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

0 commit comments

Comments
 (0)