Skip to content

Commit a86eccd

Browse files
josunectaljesusg
andauthored
feat(kiali): standardize Certificate Authority Configuration Method (#511)
* Get certificate by path as well Signed-off-by: josunect <jcordoba@redhat.com> * Remove inline certs support Signed-off-by: josunect <jcordoba@redhat.com> * Fix for windows Signed-off-by: josunect <jcordoba@redhat.com> * Update failing test Signed-off-by: josunect <jcordoba@redhat.com> * Unify test usages Signed-off-by: josunect <jcordoba@redhat.com> * Remove PEM from docs Signed-off-by: josunect <jcordoba@redhat.com> * Missing doc Signed-off-by: josunect <jcordoba@redhat.com> * Remove PEM checks Signed-off-by: josunect <jcordoba@redhat.com> * Lint Signed-off-by: josunect <jcordoba@redhat.com> * Update docs Signed-off-by: josunect <jcordoba@redhat.com> * Update test Signed-off-by: josunect <jcordoba@redhat.com> * Comments from review Signed-off-by: josunect <jcordoba@redhat.com> * Update pkg/config/config.go Co-authored-by: Alberto Gutierrez <aljesusg@gmail.com> Signed-off-by: josunect <jcordoba@redhat.com> --------- Signed-off-by: josunect <jcordoba@redhat.com> Co-authored-by: Alberto Gutierrez <aljesusg@gmail.com>
1 parent 3a03479 commit a86eccd

File tree

7 files changed

+231
-9
lines changed

7 files changed

+231
-9
lines changed

AGENTS.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,92 @@ When adding a new tool:
4848
3. Add the tool to an appropriate toolset (or create a new toolset if needed).
4949
4. Register the toolset in `pkg/toolsets/` if it's a new toolset.
5050

51+
### Configuration Standards for Red Hat-Maintained Components
52+
53+
To ensure consistency across Red Hat-maintained toolsets and cluster providers, follow these standards when implementing certificate authority (CA) configuration:
54+
55+
#### Certificate Authority (CA) Configuration Standard
56+
57+
**Standard Pattern:** Certificate authorities must be configured using **file paths**, not inline PEM content.
58+
59+
**Implementation Guidelines:**
60+
61+
1. **Config Parser (`pkg/toolsets/<toolset>/config.go`):**
62+
- Accept `certificate_authority` as a string field in your toolset config struct.
63+
- In the toolset parser function, resolve relative paths using `config.ConfigDirPathFromContext(ctx)` to make them relative to the config file directory.
64+
- Store the resolved absolute path in the config struct.
65+
- Validate in the `Validate()` method that `certificate_authority` is a valid file path using `os.Stat()`.
66+
67+
Example:
68+
```go
69+
func (c *Config) Validate() error {
70+
// ... other validations ...
71+
72+
// Validate that certificate_authority is a valid file
73+
if caValue := strings.TrimSpace(c.CertificateAuthority); caValue != "" {
74+
if _, err := os.Stat(caValue); err != nil {
75+
return fmt.Errorf("certificate_authority must be a valid file path: %w", err)
76+
}
77+
}
78+
return nil
79+
}
80+
81+
func toolsetParser(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (config.Extended, error) {
82+
var cfg Config
83+
if err := md.PrimitiveDecode(primitive, &cfg); err != nil {
84+
return nil, err
85+
}
86+
87+
// Resolve relative CA file paths
88+
if cfg.CertificateAuthority != "" {
89+
configDir := config.ConfigDirPathFromContext(ctx)
90+
if configDir != "" && !filepath.IsAbs(cfg.CertificateAuthority) {
91+
cfg.CertificateAuthority = filepath.Join(configDir, cfg.CertificateAuthority)
92+
}
93+
}
94+
95+
return &cfg, nil
96+
}
97+
```
98+
99+
2. **Client Implementation:**
100+
- When using the CA certificate, read it from the file path using `os.ReadFile()`.
101+
- Only valid file paths are supported; the path must exist and be readable.
102+
103+
Example:
104+
```go
105+
func (c *Client) createHTTPClient() *http.Client {
106+
tlsConfig := &tls.Config{InsecureSkipVerify: c.insecure}
107+
108+
if caValue := strings.TrimSpace(c.certificateAuthority); caValue != "" {
109+
// Read the certificate from file
110+
caPEM, err := os.ReadFile(caValue)
111+
if err != nil {
112+
// Handle error appropriately
113+
}
114+
115+
// Use caPEM to configure TLS...
116+
}
117+
118+
return &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}}
119+
}
120+
```
121+
122+
3. **Documentation:**
123+
- Document that `certificate_authority` accepts only a file path (absolute or relative).
124+
- Mention that relative paths are resolved relative to the config file directory.
125+
- Note that the file path must exist and be readable; invalid paths will cause validation to fail.
126+
127+
**Reference Implementation:**
128+
- See `pkg/kiali/config.go` and `pkg/kiali/kiali.go` for a complete example.
129+
- See `pkg/kubernetes-mcp-server/cmd/root.go` (lines 292-314) for how the main server handles CA files.
130+
131+
**Rationale:**
132+
- Consistency with ACM cluster provider and other Red Hat-maintained components.
133+
- Better security practices (certificates stored in files, not in config files).
134+
- Easier management and rotation of certificates.
135+
- Clearer separation of configuration and secrets.
136+
51137
## Building
52138

53139
Use the provided Makefile targets:

docs/KIALI.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ toolsets = ["core", "kiali"]
1414
[toolset_configs.kiali]
1515
url = "https://kiali.example" # Endpoint/route to reach Kiali console
1616
# insecure = true # optional: allow insecure TLS (not recommended in production)
17-
# certificate_authority = """-----BEGIN CERTIFICATE-----
18-
# MIID...
19-
# -----END CERTIFICATE-----"""
17+
# certificate_authority = "/path/to/ca.crt" # File path to CA certificate
2018
# When url is https and insecure is false, certificate_authority is required.
2119
```
2220

@@ -32,6 +30,6 @@ When the `kiali` toolset is enabled, a Kiali toolset configuration is required v
3230
- Missing Kiali configuration when `kiali` toolset is enabled → set `[toolset_configs.kiali].url` in the config TOML.
3331
- Invalid URL → ensure `[toolset_configs.kiali].url` is a valid `http(s)://host` URL.
3432
- TLS certificate validation:
35-
- If `[toolset_configs.kiali].url` uses HTTPS and `[toolset_configs.kiali].insecure` is false, you must set `[toolset_configs.kiali].certificate_authority` with the PEM-encoded certificate(s) used by the Kiali server. This field expects inline PEM content, not a file path. You may concatenate multiple PEM blocks to include an intermediate chain.
33+
- If `[toolset_configs.kiali].url` uses HTTPS and `[toolset_configs.kiali].insecure` is false, you must set `[toolset_configs.kiali].certificate_authority` with the path to the CA certificate file. Relative paths are resolved relative to the directory containing the config file.
3634
- For non-production environments you can set `[toolset_configs.kiali].insecure = true` to skip certificate verification.
3735

pkg/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ func withDirPath(path string) ReadConfigOpt {
9595
}
9696
}
9797

98+
// WithDirPath returns a ReadConfigOpt that sets the config directory path.
99+
func WithDirPath(path string) ReadConfigOpt {
100+
return withDirPath(path)
101+
}
102+
98103
// Read reads the toml file and returns the StaticConfig, with any opts applied.
99104
func Read(configPath string, opts ...ReadConfigOpt) (*StaticConfig, error) {
100105
configData, err := os.ReadFile(configPath)

pkg/kiali/config.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package kiali
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"net/url"
8+
"os"
9+
"path/filepath"
710
"strings"
811

912
"github.com/BurntSushi/toml"
@@ -33,14 +36,30 @@ func (c *Config) Validate() error {
3336
if strings.EqualFold(u.Scheme, "https") && !c.Insecure && strings.TrimSpace(c.CertificateAuthority) == "" {
3437
return errors.New("certificate_authority is required for https when insecure is false")
3538
}
39+
// Validate that certificate_authority is a valid file
40+
if caValue := strings.TrimSpace(c.CertificateAuthority); caValue != "" {
41+
if _, err := os.Stat(caValue); err != nil {
42+
return fmt.Errorf("certificate_authority must be a valid file path: %w", err)
43+
}
44+
}
3645
return nil
3746
}
3847

39-
func kialiToolsetParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (config.Extended, error) {
48+
func kialiToolsetParser(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (config.Extended, error) {
4049
var cfg Config
4150
if err := md.PrimitiveDecode(primitive, &cfg); err != nil {
4251
return nil, err
4352
}
53+
54+
// If certificate_authority is provided, resolve it relative to the config directory if it's a relative path
55+
if cfg.CertificateAuthority != "" {
56+
configDir := config.ConfigDirPathFromContext(ctx)
57+
if configDir != "" && !filepath.IsAbs(cfg.CertificateAuthority) {
58+
cfg.CertificateAuthority = filepath.Join(configDir, cfg.CertificateAuthority)
59+
}
60+
// If it's already absolute or configDir is empty, use as-is
61+
}
62+
4463
return &cfg, nil
4564
}
4665

pkg/kiali/config_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package kiali
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/containers/kubernetes-mcp-server/internal/test"
9+
"github.com/containers/kubernetes-mcp-server/pkg/config"
10+
"github.com/stretchr/testify/suite"
11+
)
12+
13+
type ConfigSuite struct {
14+
suite.Suite
15+
tempDir string
16+
caFile string
17+
}
18+
19+
func (s *ConfigSuite) SetupTest() {
20+
// Create a temporary directory for test files
21+
tempDir, err := os.MkdirTemp("", "kiali-config-test-*")
22+
s.Require().NoError(err, "Failed to create temp directory")
23+
s.tempDir = tempDir
24+
25+
// Create a test CA certificate file
26+
s.caFile = filepath.Join(s.tempDir, "ca.crt")
27+
err = os.WriteFile(s.caFile, []byte("test ca content"), 0644)
28+
s.Require().NoError(err, "Failed to write CA file")
29+
}
30+
31+
func (s *ConfigSuite) TestConfigParser_ResolvesRelativePath() {
32+
// Create CA file in temp directory
33+
caFile := filepath.Join(s.tempDir, "ca.crt")
34+
err := os.WriteFile(caFile, []byte("test ca content"), 0644)
35+
s.Require().NoError(err, "Failed to write CA file")
36+
37+
// Read config with configDirPath set to tempDir to resolve relative paths
38+
cfg := test.Must(config.ReadToml([]byte(`
39+
[toolset_configs.kiali]
40+
url = "https://kiali.example/"
41+
certificate_authority = "ca.crt"
42+
`), config.WithDirPath(s.tempDir)))
43+
44+
// Get Kiali config
45+
kialiCfg, ok := cfg.GetToolsetConfig("kiali")
46+
s.Require().True(ok, "Kiali config should be present")
47+
kcfg, ok := kialiCfg.(*Config)
48+
s.Require().True(ok, "Kiali config should be of type *Config")
49+
50+
// Verify the path was resolved to absolute
51+
expectedPath := caFile
52+
s.Equal(expectedPath, kcfg.CertificateAuthority, "Relative path should be resolved to absolute path")
53+
}
54+
55+
func (s *ConfigSuite) TestConfigParser_PreservesAbsolutePath() {
56+
// Convert backslashes to forward slashes for TOML compatibility on Windows
57+
caFileForTOML := filepath.ToSlash(s.caFile)
58+
59+
cfg := test.Must(config.ReadToml([]byte(`
60+
[toolset_configs.kiali]
61+
url = "https://kiali.example/"
62+
certificate_authority = "` + caFileForTOML + `"
63+
`)))
64+
65+
kialiCfg, ok := cfg.GetToolsetConfig("kiali")
66+
s.Require().True(ok, "Kiali config should be present")
67+
kcfg, ok := kialiCfg.(*Config)
68+
s.Require().True(ok, "Kiali config should be of type *Config")
69+
70+
// Absolute path should be preserved
71+
actualPath := filepath.Clean(filepath.FromSlash(kcfg.CertificateAuthority))
72+
expectedPath := filepath.Clean(s.caFile)
73+
s.Equal(expectedPath, actualPath, "Absolute path should be preserved")
74+
}
75+
76+
func (s *ConfigSuite) TestConfigParser_RejectsInvalidFile() {
77+
// Use a non-existent file path
78+
nonExistentFile := filepath.Join(s.tempDir, "non-existent.crt")
79+
// Convert backslashes to forward slashes for TOML compatibility on Windows
80+
nonExistentFileForTOML := filepath.ToSlash(nonExistentFile)
81+
82+
cfg, err := config.ReadToml([]byte(`
83+
[toolset_configs.kiali]
84+
url = "https://kiali.example/"
85+
certificate_authority = "` + nonExistentFileForTOML + `"
86+
`))
87+
88+
// Validate should reject invalid file path
89+
s.Require().Error(err, "Validate should reject invalid file path")
90+
s.Contains(err.Error(), "certificate_authority must be a valid file path", "Error message should indicate file path is invalid")
91+
s.Nil(cfg, "Config should be nil when validation fails")
92+
}
93+
94+
func TestConfig(t *testing.T) {
95+
suite.Run(t, new(ConfigSuite))
96+
}

pkg/kiali/kiali.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"net/http"
1010
"net/url"
11+
"os"
1112
"strings"
1213

1314
"github.com/containers/kubernetes-mcp-server/pkg/config"
@@ -85,19 +86,30 @@ func (k *Kiali) createHTTPClient() *http.Client {
8586
InsecureSkipVerify: k.kialiInsecure,
8687
}
8788

88-
// If a custom Certificate Authority PEM is configured, load and add it
89-
if caPEM := strings.TrimSpace(k.certificateAuthority); caPEM != "" {
89+
// If a custom Certificate Authority is configured, load and add it
90+
if caValue := strings.TrimSpace(k.certificateAuthority); caValue != "" {
91+
// Read the certificate from file
92+
caPEM, err := os.ReadFile(caValue)
93+
if err != nil {
94+
klog.Errorf("failed to read CA certificate from file %s: %v; proceeding without custom CA", caValue, err)
95+
return &http.Client{
96+
Transport: &http.Transport{
97+
TLSClientConfig: tlsConfig,
98+
},
99+
}
100+
}
101+
90102
// Start with the host system pool when possible so we don't drop system roots
91103
var certPool *x509.CertPool
92104
if systemPool, err := x509.SystemCertPool(); err == nil && systemPool != nil {
93105
certPool = systemPool
94106
} else {
95107
certPool = x509.NewCertPool()
96108
}
97-
if ok := certPool.AppendCertsFromPEM([]byte(caPEM)); ok {
109+
if ok := certPool.AppendCertsFromPEM(caPEM); ok {
98110
tlsConfig.RootCAs = certPool
99111
} else {
100-
klog.V(0).Infof("failed to append provided certificate authority PEM; proceeding without custom CA")
112+
klog.V(0).Infof("failed to append provided certificate authority; proceeding without custom CA")
101113
}
102114
}
103115

pkg/kubernetes-mcp-server/cmd/root.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,12 @@ func (m *MCPServerOptions) Validate() error {
262262
klog.Warningf("authorization-url is using http://, this is not recommended production use")
263263
}
264264
}
265+
// Validate that certificate_authority is a valid file
266+
if caValue := strings.TrimSpace(m.StaticConfig.CertificateAuthority); caValue != "" {
267+
if _, err := os.Stat(caValue); err != nil {
268+
return fmt.Errorf("certificate-authority must be a valid file path: %w", err)
269+
}
270+
}
265271
return nil
266272
}
267273

0 commit comments

Comments
 (0)