Skip to content

Commit daca488

Browse files
Add a custom JSON marshaller for Alertmanager configurations
1 parent 863b097 commit daca488

File tree

4 files changed

+394
-0
lines changed

4 files changed

+394
-0
lines changed

definition/json.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package definition
2+
3+
import (
4+
"unsafe"
5+
6+
jsoniter "github.com/json-iterator/go"
7+
"github.com/modern-go/reflect2"
8+
amcfg "github.com/prometheus/alertmanager/config"
9+
commoncfg "github.com/prometheus/common/config"
10+
)
11+
12+
// secretEncoder encodes Secret to plain text JSON,
13+
// avoding the default masking behavior of the structure.
14+
type secretEncoder struct{}
15+
16+
func (encoder *secretEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
17+
stream.WriteString(getStr(ptr))
18+
}
19+
20+
func (encoder *secretEncoder) IsEmpty(ptr unsafe.Pointer) bool {
21+
return len(getStr(ptr)) == 0
22+
}
23+
24+
func getStr(ptr unsafe.Pointer) string {
25+
return *(*string)(ptr)
26+
}
27+
28+
// secretEncoder encodes SecretURL to plain text JSON,
29+
// avoding the default masking behavior of the structure.
30+
type secretURLEncoder struct{}
31+
32+
func (encoder *secretURLEncoder) Encode(ptr unsafe.Pointer, stream *jsoniter.Stream) {
33+
url := getURL(ptr)
34+
if url.URL != nil {
35+
stream.WriteString(url.String())
36+
} else {
37+
stream.WriteNil()
38+
}
39+
}
40+
41+
func (encoder *secretURLEncoder) IsEmpty(ptr unsafe.Pointer) bool {
42+
url := getURL(ptr)
43+
return url.URL == nil
44+
}
45+
46+
func getURL(ptr unsafe.Pointer) *amcfg.URL {
47+
v := (*amcfg.SecretURL)(ptr)
48+
url := amcfg.URL(*v)
49+
return &url
50+
}
51+
52+
func newPlainAPI() jsoniter.API {
53+
api := jsoniter.ConfigCompatibleWithStandardLibrary
54+
55+
secretEnc := &secretEncoder{}
56+
secretURLEnc := &secretURLEncoder{}
57+
58+
extension := jsoniter.EncoderExtension{
59+
// Value types
60+
reflect2.TypeOfPtr((*amcfg.Secret)(nil)).Elem(): secretEnc,
61+
reflect2.TypeOfPtr((*commoncfg.Secret)(nil)).Elem(): secretEnc,
62+
reflect2.TypeOfPtr((*amcfg.SecretURL)(nil)).Elem(): secretURLEnc,
63+
// Pointer types
64+
reflect2.TypeOfPtr((*amcfg.Secret)(nil)): &jsoniter.OptionalEncoder{ValueEncoder: secretEnc},
65+
reflect2.TypeOfPtr((*commoncfg.Secret)(nil)): &jsoniter.OptionalEncoder{ValueEncoder: secretEnc},
66+
reflect2.TypeOfPtr((*amcfg.SecretURL)(nil)): &jsoniter.OptionalEncoder{ValueEncoder: secretURLEnc},
67+
}
68+
69+
api.RegisterExtension(extension)
70+
71+
return api
72+
}
73+
74+
var (
75+
plainJSON = newPlainAPI()
76+
)
77+
78+
// MarshalJSONWithSecrets marshals the given value to JSON with secrets in plain text.
79+
//
80+
// alertmanager's and prometeus' Secret and SecretURL types mask their values
81+
// when marshaled with the standard JSON or YAML marshallers. This function
82+
// preserves the values of these types by using a custom JSON encoder.
83+
func MarshalJSONWithSecrets(v any) ([]byte, error) {
84+
return plainJSON.Marshal(v)
85+
}

definition/json_test.go

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
package definition
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/url"
7+
"testing"
8+
"time"
9+
10+
"github.com/prometheus/alertmanager/config"
11+
"github.com/prometheus/alertmanager/timeinterval"
12+
commoncfg "github.com/prometheus/common/config"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func TestMarshalJSONWithSecrets(t *testing.T) {
17+
u := "https://grafana.com/webhook"
18+
testURL, err := url.Parse(u)
19+
require.NoError(t, err)
20+
21+
amsLoc, err := time.LoadLocation("Europe/Amsterdam")
22+
require.NoError(t, err)
23+
24+
// stdlib json escapes < and > characters,
25+
// so just marshal the placeholder string to have the same value.
26+
maskedSecretBytes, err := json.Marshal("<secret>")
27+
require.NoError(t, err)
28+
maskedSecret := string(maskedSecretBytes)
29+
30+
cfg := PostableApiAlertingConfig{
31+
Config: Config{
32+
Route: &Route{
33+
Receiver: "test-receiver",
34+
},
35+
TimeIntervals: []config.TimeInterval{
36+
{
37+
Name: "time-interval-1",
38+
TimeIntervals: []timeinterval.TimeInterval{
39+
{
40+
Times: []timeinterval.TimeRange{
41+
{
42+
StartMinute: 60,
43+
EndMinute: 120,
44+
},
45+
},
46+
Weekdays: []timeinterval.WeekdayRange{
47+
{
48+
InclusiveRange: timeinterval.InclusiveRange{
49+
Begin: 1,
50+
End: 5,
51+
},
52+
},
53+
},
54+
},
55+
},
56+
},
57+
{
58+
Name: "time-interval-2",
59+
TimeIntervals: []timeinterval.TimeInterval{
60+
{
61+
Times: []timeinterval.TimeRange{
62+
{
63+
StartMinute: 120,
64+
EndMinute: 240,
65+
},
66+
},
67+
Weekdays: []timeinterval.WeekdayRange{
68+
{
69+
InclusiveRange: timeinterval.InclusiveRange{
70+
Begin: 0,
71+
End: 2,
72+
},
73+
},
74+
},
75+
Location: &timeinterval.Location{Location: amsLoc},
76+
},
77+
},
78+
},
79+
},
80+
},
81+
Receivers: []*PostableApiReceiver{
82+
{
83+
Receiver: config.Receiver{
84+
Name: "test-receiver",
85+
WebhookConfigs: []*config.WebhookConfig{
86+
{
87+
URL: &config.SecretURL{URL: testURL},
88+
HTTPConfig: &commoncfg.HTTPClientConfig{
89+
BasicAuth: &commoncfg.BasicAuth{
90+
Username: "user",
91+
Password: commoncfg.Secret("password"),
92+
},
93+
},
94+
},
95+
{
96+
URL: &config.SecretURL{URL: testURL},
97+
HTTPConfig: &commoncfg.HTTPClientConfig{
98+
Authorization: &commoncfg.Authorization{
99+
Type: "Bearer",
100+
Credentials: commoncfg.Secret("bearer-token-secret"),
101+
},
102+
},
103+
},
104+
},
105+
EmailConfigs: []*config.EmailConfig{
106+
{
107+
To: "test@grafana.com",
108+
From: "alerts@grafana.com",
109+
AuthUsername: "smtp-user",
110+
AuthPassword: config.Secret("smtp-password"),
111+
AuthSecret: config.Secret("smtp-secret"),
112+
Headers: map[string]string{},
113+
HTML: "{{ template \"email.default.html\" . }}",
114+
},
115+
{
116+
To: "test2@grafana.com",
117+
From: "alerts2@grafana.com",
118+
AuthUsername: "smtp-user2",
119+
AuthPassword: config.Secret(""),
120+
AuthSecret: config.Secret("smtp-secret2"),
121+
Headers: map[string]string{},
122+
HTML: "{{ template \"email.default.html\" . }}",
123+
},
124+
},
125+
},
126+
},
127+
},
128+
}
129+
130+
standardJSON, err := json.Marshal(cfg)
131+
require.NoError(t, err)
132+
133+
plainJSONBytes, err := MarshalJSONWithSecrets(cfg)
134+
require.NoError(t, err)
135+
require.True(t, json.Valid(plainJSONBytes))
136+
137+
require.True(t, json.Valid(standardJSON))
138+
require.Contains(t, string(standardJSON), maskedSecret)
139+
140+
var roundTripCfg PostableApiAlertingConfig
141+
err = json.Unmarshal(plainJSONBytes, &roundTripCfg)
142+
require.NoError(t, err)
143+
require.Equal(t, cfg, roundTripCfg)
144+
}
145+
146+
func TestSecretTypeMarshaling(t *testing.T) {
147+
// stdlib json escapes < and > characters,
148+
// so just marshal the placeholder string to have the same value.
149+
maskedSecretBytes, err := json.Marshal("<secret>")
150+
require.NoError(t, err)
151+
maskedSecret := string(maskedSecretBytes)
152+
153+
tests := []struct {
154+
name string
155+
secret any
156+
expectStandard string
157+
expectPlain string
158+
}{
159+
{
160+
name: "nil",
161+
secret: nil,
162+
expectStandard: `null`,
163+
expectPlain: `null`,
164+
},
165+
{
166+
name: "alertmanager config secret",
167+
secret: config.Secret("my-secret"),
168+
expectStandard: maskedSecret,
169+
expectPlain: `"my-secret"`,
170+
},
171+
{
172+
name: "common config secret",
173+
secret: commoncfg.Secret("common-secret"),
174+
expectStandard: maskedSecret,
175+
expectPlain: `"common-secret"`,
176+
},
177+
{
178+
name: "empty alertmanager secret",
179+
secret: config.Secret(""),
180+
expectStandard: maskedSecret,
181+
expectPlain: `""`,
182+
},
183+
{
184+
name: "empty common secret",
185+
secret: commoncfg.Secret(""),
186+
expectStandard: `""`,
187+
expectPlain: `""`,
188+
},
189+
{
190+
name: "nil alertmanager secret pointer",
191+
secret: (*config.Secret)(nil),
192+
expectStandard: "null",
193+
expectPlain: "null",
194+
},
195+
{
196+
name: "nil common config secret pointer",
197+
secret: (*commoncfg.Secret)(nil),
198+
expectStandard: "null",
199+
expectPlain: "null",
200+
},
201+
{
202+
name: "pointer to alertmanager secret",
203+
secret: func() *config.Secret { s := config.Secret("pointer-secret"); return &s }(),
204+
expectStandard: maskedSecret,
205+
expectPlain: `"pointer-secret"`,
206+
},
207+
{
208+
name: "pointer to common secret",
209+
secret: func() *commoncfg.Secret { s := commoncfg.Secret("pointer-common"); return &s }(),
210+
expectStandard: maskedSecret,
211+
expectPlain: `"pointer-common"`,
212+
},
213+
{
214+
name: "secret with special characters",
215+
secret: config.Secret("secret with spaces\nand\t 🔑"),
216+
expectStandard: maskedSecret,
217+
expectPlain: `"secret with spaces\nand\t 🔑"`,
218+
},
219+
}
220+
221+
for _, tt := range tests {
222+
t.Run(tt.name, func(t *testing.T) {
223+
standard, err := json.Marshal(tt.secret)
224+
require.NoError(t, err)
225+
require.Equal(t, tt.expectStandard, string(standard))
226+
227+
plain, err := MarshalJSONWithSecrets(tt.secret)
228+
require.NoError(t, err)
229+
require.Equal(t, tt.expectPlain, string(plain))
230+
})
231+
}
232+
}
233+
234+
func TestSecretURLTypeMarshaling(t *testing.T) {
235+
u := "https://grafana.com/webhook"
236+
testURL, err := url.Parse(u)
237+
require.NoError(t, err)
238+
239+
// stdlib json escapes < and > characters,
240+
// so just marshal the placeholder string to have the same value.
241+
maskedSecretBytes, err := json.Marshal("<secret>")
242+
require.NoError(t, err)
243+
maskedSecret := string(maskedSecretBytes)
244+
245+
complexURL, err := url.Parse("https://user:pass@example.com:8080/path?query=value#fragment")
246+
require.NoError(t, err)
247+
248+
tests := []struct {
249+
name string
250+
secretURL interface{}
251+
expectStandard string
252+
expectPlain string
253+
}{
254+
{
255+
name: "non-empty URL",
256+
secretURL: config.SecretURL{URL: testURL},
257+
expectStandard: maskedSecret,
258+
expectPlain: fmt.Sprintf(`"%s"`, u),
259+
},
260+
{
261+
name: "empty URL",
262+
secretURL: config.SecretURL{},
263+
expectStandard: maskedSecret,
264+
expectPlain: `null`,
265+
},
266+
{
267+
name: "complex URL",
268+
secretURL: config.SecretURL{URL: complexURL},
269+
expectStandard: maskedSecret,
270+
expectPlain: fmt.Sprintf(`"%s"`, complexURL.String()),
271+
},
272+
{
273+
name: "nil URL pointer",
274+
secretURL: (*config.SecretURL)(nil),
275+
expectStandard: "null",
276+
expectPlain: "null",
277+
},
278+
{
279+
name: "URL pointer",
280+
secretURL: &config.SecretURL{URL: testURL},
281+
expectStandard: maskedSecret,
282+
expectPlain: fmt.Sprintf(`"%s"`, u),
283+
},
284+
{
285+
name: "pointer to empty URL",
286+
secretURL: &config.SecretURL{},
287+
expectStandard: maskedSecret,
288+
expectPlain: `null`,
289+
},
290+
}
291+
292+
for _, tt := range tests {
293+
t.Run(tt.name, func(t *testing.T) {
294+
standard, err := json.Marshal(tt.secretURL)
295+
require.NoError(t, err)
296+
require.Equal(t, tt.expectStandard, string(standard))
297+
298+
plain, err := MarshalJSONWithSecrets(tt.secretURL)
299+
require.NoError(t, err)
300+
require.Equal(t, tt.expectPlain, string(plain))
301+
})
302+
}
303+
}

0 commit comments

Comments
 (0)