1 package services
2
3 import (
4 "context"
5 "database/sql"
6 "fmt"
7 "strconv"
8 "testing"
9
10 "github.com/DATA-DOG/go-sqlmock"
11 "github.com/stretchr/testify/require"
12
13 "edge-infra.dev/pkg/edge/api/graph/mapper"
14 "edge-infra.dev/pkg/edge/api/graph/model"
15 sqlquery "edge-infra.dev/pkg/edge/api/sql"
16 "edge-infra.dev/pkg/edge/constants"
17 linkerd "edge-infra.dev/pkg/edge/linkerd"
18 "edge-infra.dev/pkg/lib/uuid"
19 "edge-infra.dev/pkg/sds/ien/topology"
20 )
21
22 const (
23
24 clusterConfigClusterEdgeID = "1c52b9fa-1396-4ebf-a67a-23d06021774c"
25 )
26
27 var (
28 namespaceLogLevelsPayloadInsert = "[]"
29 clusterConfigRows = sqlmock.NewRows([]string{"cluster_config_edge_id", "cluster_edge_id", "config_key", "config_value"})
30 )
31
32 var (
33
34 validNamespace = "fluent-operator"
35 validLevel = "ALERT"
36 validMaximumLanOutage = int(linkerd.DefaultThickPosIdentityIssuerCertificateDurationHours)
37
38 invalidLogLevel = "NONSENSE"
39 invalidMaximumLanOutage = 23
40 )
41
42
43 var (
44 invalidL5dCertRenewal = -1
45 invalidL5dCertDuration = 0
46 validL5dCertDuration = int(linkerd.DefaultThinPosIdentityIssuerCertificateDurationHours)
47 validL5dCertRenewal = int(linkerd.DefaultThinPosIdentityIssuerCertificateRenewBeforeHours)
48 )
49
50 type clusterConfigTests struct {
51 description string
52 currentConfig *model.ClusterConfig
53 inputConfig *model.UpdateClusterConfig
54 expectedConfig *model.ClusterConfig
55 expectedErr error
56 }
57
58 var (
59 trueValue = true
60 falseValue = false
61 rateLimitValid = "4mbit"
62 rateLimitInvalid = "4"
63 )
64
65 var clusterCfgTestCases = []clusterConfigTests{
66 {
67 description: "create valid default configuration",
68 currentConfig: nil,
69 inputConfig: &model.UpdateClusterConfig{},
70 expectedConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
71 expectedErr: nil,
72 },
73 {
74 description: "change all default cluster configuration values",
75 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
76 inputConfig: &model.UpdateClusterConfig{
77 AcRelay: &falseValue,
78 PxeEnabled: &falseValue,
79 BootstrapAck: &trueValue,
80 GatewayRateLimitingEnabled: &trueValue,
81 EgressGatewayEnabled: &trueValue,
82 UplinkRateLimit: &rateLimitValid,
83 DownlinkRateLimit: &rateLimitValid,
84 ThickPos: &trueValue,
85 VpnEnabled: &trueValue,
86 ClusterLogLevel: &validLevel,
87 NamespaceLogLevels: []*model.NamespaceLogLevelPayload{
88 {
89 Namespace: &validNamespace,
90 Level: &validLevel,
91 },
92 },
93 MaximumLanOutageHours: &validMaximumLanOutage,
94
95 LinkerdIdentityIssuerCertDuration: &validL5dCertDuration,
96 LinkerdIdentityIssuerCertRenewBefore: &validL5dCertRenewal,
97 VncReadWriteAuthRequired: &trueValue,
98 VncReadAuthRequired: &falseValue,
99 },
100 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
101 AcRelay: &falseValue,
102 PxeEnabled: &falseValue,
103 BootstrapAck: &trueValue,
104 GatewayRateLimitingEnabled: &trueValue,
105 EgressGatewayEnabled: &trueValue,
106 UplinkRateLimit: &rateLimitValid,
107 DownlinkRateLimit: &rateLimitValid,
108 ThickPos: &trueValue,
109 VpnEnabled: &trueValue,
110 ClusterLogLevel: &validLevel,
111 NamespaceLogLevels: []*model.NamespaceLogLevelPayload{
112 {
113 Namespace: &validNamespace,
114 Level: &validLevel,
115 },
116 },
117 MaximumLanOutageHours: &validMaximumLanOutage,
118
119 LinkerdIdentityIssuerCertDuration: &validL5dCertDuration,
120 LinkerdIdentityIssuerCertRenewBefore: &validL5dCertRenewal,
121 VncReadWriteAuthRequired: &trueValue,
122 VncReadAuthRequired: &falseValue,
123 }),
124 expectedErr: nil,
125 },
126 {
127 description: "update boot options with valid configuration",
128 currentConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
129 AcRelay: &trueValue,
130 PxeEnabled: &trueValue,
131 BootstrapAck: &falseValue,
132 }),
133 inputConfig: &model.UpdateClusterConfig{
134 AcRelay: &falseValue,
135 PxeEnabled: &falseValue,
136 BootstrapAck: &trueValue,
137 },
138 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
139 AcRelay: &falseValue,
140 PxeEnabled: &falseValue,
141 BootstrapAck: &trueValue,
142 }),
143 expectedErr: nil,
144 },
145 {
146 description: "update egress gateway options with valid configuration",
147 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
148 inputConfig: &model.UpdateClusterConfig{
149 GatewayRateLimitingEnabled: &trueValue,
150 EgressGatewayEnabled: &trueValue,
151 UplinkRateLimit: &rateLimitValid,
152 DownlinkRateLimit: &rateLimitValid,
153 },
154 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
155 GatewayRateLimitingEnabled: &trueValue,
156 EgressGatewayEnabled: &trueValue,
157 UplinkRateLimit: &rateLimitValid,
158 DownlinkRateLimit: &rateLimitValid,
159 }),
160 expectedErr: nil,
161 },
162 {
163 description: "invalid egress gateway bandwidth limits",
164 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
165 inputConfig: &model.UpdateClusterConfig{
166 GatewayRateLimitingEnabled: &trueValue,
167 EgressGatewayEnabled: &trueValue,
168 UplinkRateLimit: &rateLimitInvalid,
169 DownlinkRateLimit: &rateLimitInvalid,
170 },
171 expectedConfig: nil,
172 expectedErr: ErrInvalidRateLimit,
173 },
174 {
175 description: "thick pos topology setting",
176 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
177 inputConfig: &model.UpdateClusterConfig{
178 ThickPos: &trueValue,
179 },
180 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
181 ThickPos: &trueValue,
182 }),
183 expectedErr: nil,
184 },
185 {
186 description: "vpn enablement cluster setting",
187 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
188 inputConfig: &model.UpdateClusterConfig{
189 VpnEnabled: &trueValue,
190 },
191 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
192 VpnEnabled: &trueValue,
193 }),
194 expectedErr: nil,
195 },
196 {
197 description: "valid cluster log levels",
198 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
199 inputConfig: &model.UpdateClusterConfig{
200 ClusterLogLevel: &validLevel,
201 },
202 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
203 ClusterLogLevel: &validLevel,
204 }),
205 expectedErr: nil,
206 },
207 {
208 description: "invalid cluster log levels",
209 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
210 inputConfig: &model.UpdateClusterConfig{
211 ClusterLogLevel: &invalidLogLevel,
212 },
213 expectedConfig: nil,
214 expectedErr: &ErrInvalidLogLevel{
215 LogLevel: invalidLogLevel,
216 },
217 },
218 {
219 description: "valid namespace log levels",
220 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
221 inputConfig: &model.UpdateClusterConfig{
222 NamespaceLogLevels: []*model.NamespaceLogLevelPayload{
223 {
224 Namespace: &validNamespace,
225 Level: &validLevel,
226 },
227 },
228 },
229 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
230 NamespaceLogLevels: []*model.NamespaceLogLevelPayload{
231 {
232 Namespace: &validNamespace,
233 Level: &validLevel,
234 },
235 },
236 }),
237 expectedErr: nil,
238 },
239 {
240 description: "invalid namespace log levels",
241 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
242 inputConfig: &model.UpdateClusterConfig{
243 NamespaceLogLevels: []*model.NamespaceLogLevelPayload{
244 {
245 Namespace: &validNamespace,
246 Level: &invalidLogLevel,
247 },
248 },
249 },
250 expectedConfig: nil,
251 expectedErr: &ErrInvalidLogLevel{
252 LogLevel: invalidLogLevel,
253 },
254 },
255 {
256 description: "valid maximum lan outage duration in hours",
257 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
258 inputConfig: &model.UpdateClusterConfig{
259 MaximumLanOutageHours: &validMaximumLanOutage,
260 },
261 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
262 MaximumLanOutageHours: &validMaximumLanOutage,
263 }),
264 expectedErr: nil,
265 },
266 {
267 description: "invalid maximum lan outage duration in hours",
268 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
269 inputConfig: &model.UpdateClusterConfig{
270 MaximumLanOutageHours: &invalidMaximumLanOutage,
271 },
272 expectedConfig: nil,
273 expectedErr: ErrInvalidMaximumLanOutageHours,
274 },
275
276 {
277 description: "valid linkerd cert duration and renew",
278 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
279 inputConfig: &model.UpdateClusterConfig{
280 LinkerdIdentityIssuerCertDuration: &validL5dCertDuration,
281 LinkerdIdentityIssuerCertRenewBefore: &validL5dCertRenewal,
282 },
283 expectedConfig: generateExpectedClusterConfig(&model.UpdateClusterConfig{
284 LinkerdIdentityIssuerCertDuration: &validL5dCertDuration,
285 LinkerdIdentityIssuerCertRenewBefore: &validL5dCertRenewal,
286 }),
287 expectedErr: nil,
288 },
289 {
290 description: "invalid linkerd cert duration and renewal",
291 currentConfig: defaultClusterConfig(clusterConfigClusterEdgeID),
292 inputConfig: &model.UpdateClusterConfig{
293 LinkerdIdentityIssuerCertDuration: &invalidL5dCertDuration,
294 LinkerdIdentityIssuerCertRenewBefore: &invalidL5dCertRenewal,
295 },
296 expectedConfig: nil,
297 expectedErr: topology.ErrInvalidCertificateDurationOrRenewBefore,
298 },
299 }
300
301 func TestUpdateClusterConfig(t *testing.T) {
302 for _, test := range clusterCfgTestCases {
303 ctx := context.Background()
304 passMsg := fmt.Sprintf("PASS: %s", test.description)
305 failMsg := fmt.Sprintf("FAIL: %s", test.description)
306
307 db, mock, err := generateMockDB(test.currentConfig, test.expectedConfig, test.inputConfig)
308 require.NoError(t, err, failMsg)
309
310 service := NewClusterConfigService(db)
311 actualClusterCfg, err := service.UpdateClusterConfig(ctx, clusterConfigClusterEdgeID, test.inputConfig)
312 if test.expectedErr != nil {
313 require.Equal(t, test.expectedErr.Error(), err.Error(), failMsg)
314 t.Log(passMsg)
315 continue
316 }
317 require.NoError(t, err, failMsg)
318 require.NotNil(t, test.expectedConfig, failMsg)
319 require.NotNil(t, actualClusterCfg, failMsg)
320 require.Equal(t, *test.expectedConfig, *actualClusterCfg, failMsg)
321 require.NoError(t, mock.ExpectationsWereMet(), failMsg)
322 t.Log(passMsg)
323 }
324 }
325
326
327
328
329
330 func TestGetNewAddedKeyClusterConfig(t *testing.T) {
331 ctx := context.Background()
332 db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
333 require.NoError(t, err)
334 defer db.Close()
335
336 mock.ExpectQuery(sqlquery.GetClusterConfig).
337 WithArgs(clusterConfigClusterEdgeID).
338 WillReturnRows(clusterConfigRows)
339
340 service := NewClusterConfigService(db)
341 clusterCfg, err := service.GetClusterConfig(ctx, clusterConfigClusterEdgeID)
342 require.NoError(t, err)
343 require.Equal(t, defaultClusterConfig(clusterConfigClusterEdgeID), clusterCfg)
344 }
345
346
347 func TestNLLPToNLL(t *testing.T) {
348 testLogLevels := make([]*model.NamespaceLogLevelPayload, 3)
349
350 validNamespace2 := "kube-system"
351 validNamespace3 := "prometheus"
352
353 validLevel2 := "EMERGENCY"
354
355 testLogLevels[0] = &model.NamespaceLogLevelPayload{
356 Namespace: &validNamespace,
357 Level: &validLevel,
358 }
359 testLogLevels[1] = &model.NamespaceLogLevelPayload{
360 Namespace: &validNamespace2,
361 Level: &validLevel,
362 }
363 testLogLevels[2] = &model.NamespaceLogLevelPayload{
364 Namespace: &validNamespace3,
365 Level: &validLevel2,
366 }
367
368 NLL := mapper.NLLPToNLL(testLogLevels)
369
370 require.Len(t, NLL, 3)
371 }
372
373
374 func generateMockDB(currentCfg, expectedCfg *model.ClusterConfig, inputCfg *model.UpdateClusterConfig) (*sql.DB, sqlmock.Sqlmock, error) {
375 db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
376 if err != nil {
377 return nil, nil, err
378 }
379
380 mock.MatchExpectationsInOrder(false)
381 if currentCfg != nil {
382 mock.ExpectQuery(sqlquery.GetClusterConfig).WithArgs(clusterConfigClusterEdgeID).
383 WillReturnRows(clusterConfigRows.
384 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, AcRelayKey, strconv.FormatBool(currentCfg.AcRelay)).
385 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, PxeEnabledKey, strconv.FormatBool(currentCfg.PxeEnabled)).
386 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, BootstrapAckKey, strconv.FormatBool(currentCfg.BootstrapAck)).
387 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, VpnEnabledKey, strconv.FormatBool(currentCfg.VpnEnabled)).
388 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, ThickPosKey, strconv.FormatBool(currentCfg.ThickPos)).
389 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, GatewayRateLimitingEnabledKey, strconv.FormatBool(currentCfg.GatewayRateLimitingEnabled)).
390 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, UplinkRateLimitKey, currentCfg.UplinkRateLimit).
391 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, DownlinkRateLimitKey, currentCfg.DownlinkRateLimit).
392 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, ClusterLogLevelKey, currentCfg.ClusterLogLevel).
393 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, NamespaceLogLevelsKey, namespaceLogLevelsPayloadInsert).
394 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, MaximumLanOutageHoursKey, strconv.FormatInt(int64(currentCfg.MaximumLanOutageHours), 10)).
395
396
397 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, constants.LinkerdIdentityIssuerCertDuration, strconv.FormatInt(int64(currentCfg.LinkerdIdentityIssuerCertDuration), 10)).
398
399
400 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, constants.LinkerdIdentityIssuerCertRenewBefore, strconv.FormatInt(int64(currentCfg.LinkerdIdentityIssuerCertRenewBefore), 10)).
401 AddRow(uuid.New().UUID, clusterConfigClusterEdgeID, EgressGatewayEnabledKey, strconv.FormatBool(currentCfg.EgressGatewayEnabled)))
402 } else {
403 mock.ExpectQuery(sqlquery.GetClusterConfig).WithArgs(clusterConfigClusterEdgeID).WillReturnRows(clusterConfigRows)
404 }
405
406 if expectedCfg == nil {
407 mock.ExpectBegin()
408 mock.ExpectCommit()
409 return db, mock, nil
410 }
411
412 mock.ExpectBegin()
413 if inputCfg.AcRelay != nil {
414 mock.ExpectExec(sqlquery.UpdateClusterConfig).
415 WithArgs(clusterConfigClusterEdgeID, AcRelayKey, strconv.FormatBool(expectedCfg.AcRelay), sqlmock.AnyArg()).
416 WillReturnResult(sqlmock.NewResult(1, 1))
417 }
418 if inputCfg.PxeEnabled != nil {
419 mock.ExpectExec(sqlquery.UpdateClusterConfig).
420 WithArgs(clusterConfigClusterEdgeID, PxeEnabledKey, strconv.FormatBool(expectedCfg.PxeEnabled), sqlmock.AnyArg()).
421 WillReturnResult(sqlmock.NewResult(1, 1))
422 }
423 if inputCfg.BootstrapAck != nil {
424 mock.ExpectExec(sqlquery.UpdateClusterConfig).
425 WithArgs(clusterConfigClusterEdgeID, BootstrapAckKey, strconv.FormatBool(expectedCfg.BootstrapAck), sqlmock.AnyArg()).
426 WillReturnResult(sqlmock.NewResult(1, 1))
427 }
428 if inputCfg.VpnEnabled != nil {
429 mock.ExpectExec(sqlquery.UpdateClusterConfig).
430 WithArgs(clusterConfigClusterEdgeID, VpnEnabledKey, strconv.FormatBool(expectedCfg.VpnEnabled), sqlmock.AnyArg()).
431 WillReturnResult(sqlmock.NewResult(1, 1))
432 }
433 if inputCfg.ThickPos != nil {
434 mock.ExpectExec(sqlquery.UpdateClusterConfig).
435 WithArgs(clusterConfigClusterEdgeID, ThickPosKey, strconv.FormatBool(expectedCfg.ThickPos), sqlmock.AnyArg()).
436 WillReturnResult(sqlmock.NewResult(1, 1))
437 }
438 if inputCfg.EgressGatewayEnabled != nil {
439 mock.ExpectExec(sqlquery.UpdateClusterConfig).
440 WithArgs(clusterConfigClusterEdgeID, EgressGatewayEnabledKey, strconv.FormatBool(expectedCfg.EgressGatewayEnabled), sqlmock.AnyArg()).
441 WillReturnResult(sqlmock.NewResult(1, 1))
442 }
443 if inputCfg.GatewayRateLimitingEnabled != nil {
444 mock.ExpectExec(sqlquery.UpdateClusterConfig).
445 WithArgs(clusterConfigClusterEdgeID, GatewayRateLimitingEnabledKey, strconv.FormatBool(expectedCfg.GatewayRateLimitingEnabled), sqlmock.AnyArg()).
446 WillReturnResult(sqlmock.NewResult(1, 1))
447 }
448 if inputCfg.UplinkRateLimit != nil {
449 mock.ExpectExec(sqlquery.UpdateClusterConfig).
450 WithArgs(clusterConfigClusterEdgeID, UplinkRateLimitKey, expectedCfg.UplinkRateLimit, sqlmock.AnyArg()).
451 WillReturnResult(sqlmock.NewResult(1, 1))
452 }
453 if inputCfg.DownlinkRateLimit != nil {
454 mock.ExpectExec(sqlquery.UpdateClusterConfig).
455 WithArgs(clusterConfigClusterEdgeID, DownlinkRateLimitKey, expectedCfg.DownlinkRateLimit, sqlmock.AnyArg()).
456 WillReturnResult(sqlmock.NewResult(1, 1))
457 }
458 if inputCfg.ClusterLogLevel != nil {
459 mock.ExpectExec(sqlquery.UpdateClusterConfig).
460 WithArgs(clusterConfigClusterEdgeID, ClusterLogLevelKey, expectedCfg.ClusterLogLevel, sqlmock.AnyArg()).
461 WillReturnResult(sqlmock.NewResult(1, 1))
462 }
463 if inputCfg.NamespaceLogLevels != nil {
464 namespaces, _ := mapper.NLLPToJSON(inputCfg.NamespaceLogLevels)
465 mock.ExpectExec(sqlquery.UpdateClusterConfig).
466 WithArgs(clusterConfigClusterEdgeID, NamespaceLogLevelsKey, namespaces, sqlmock.AnyArg()).
467 WillReturnResult(sqlmock.NewResult(1, 1))
468 }
469 if inputCfg.MaximumLanOutageHours != nil {
470 mock.ExpectExec(sqlquery.UpdateClusterConfig).
471 WithArgs(clusterConfigClusterEdgeID, MaximumLanOutageHoursKey, strconv.FormatInt(int64(expectedCfg.MaximumLanOutageHours), 10), sqlmock.AnyArg()).
472 WillReturnResult(sqlmock.NewResult(1, 1))
473 }
474
475 if inputCfg.LinkerdIdentityIssuerCertDuration != nil {
476 mock.ExpectExec(sqlquery.UpdateClusterConfig).
477 WithArgs(clusterConfigClusterEdgeID, constants.LinkerdIdentityIssuerCertDuration, strconv.FormatInt(int64(expectedCfg.LinkerdIdentityIssuerCertDuration), 10), sqlmock.AnyArg()).
478 WillReturnResult(sqlmock.NewResult(1, 1))
479 }
480
481 if inputCfg.LinkerdIdentityIssuerCertRenewBefore != nil {
482 mock.ExpectExec(sqlquery.UpdateClusterConfig).
483 WithArgs(clusterConfigClusterEdgeID, constants.LinkerdIdentityIssuerCertRenewBefore, strconv.FormatInt(int64(expectedCfg.LinkerdIdentityIssuerCertRenewBefore), 10), sqlmock.AnyArg()).
484 WillReturnResult(sqlmock.NewResult(1, 1))
485 }
486 if inputCfg.VncReadWriteAuthRequired != nil {
487 mock.ExpectExec(sqlquery.UpdateClusterConfig).
488 WithArgs(clusterConfigClusterEdgeID, VncReadWriteAuthRequired, strconv.FormatBool(*expectedCfg.VncReadWriteAuthRequired), sqlmock.AnyArg()).
489 WillReturnResult(sqlmock.NewResult(1, 1))
490 }
491 if inputCfg.VncReadAuthRequired != nil {
492 mock.ExpectExec(sqlquery.UpdateClusterConfig).
493 WithArgs(clusterConfigClusterEdgeID, VncReadAuthRequired, strconv.FormatBool(*expectedCfg.VncReadAuthRequired), sqlmock.AnyArg()).
494 WillReturnResult(sqlmock.NewResult(1, 1))
495 }
496
497 mock.ExpectCommit()
498 return db, mock, nil
499 }
500
501
502 func generateExpectedClusterConfig(expectedClusterCfg *model.UpdateClusterConfig) *model.ClusterConfig {
503 clusterCfg := defaultClusterConfig(clusterConfigClusterEdgeID)
504 mapUpdateClusterConfig(clusterCfg, expectedClusterCfg)
505 return clusterCfg
506 }
507
View as plain text