Skip to content

Commit 4816c0d

Browse files
committed
fix: address review comments on telemetry TLS and endpoint config
Assisted-by: Claude <claude@anthropic.com> Signed-off-by: Bella Khizgiyaev <bkhizgiy@redhat.com>
1 parent 477b094 commit 4816c0d

5 files changed

Lines changed: 145 additions & 67 deletions

File tree

controller/cmd/telemetry/main.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,16 @@ func main() {
9191
Signer: signer,
9292
}
9393

94+
// Register signal handler before starting the service so no signal
95+
// is missed in the window between goroutine start and Notify.
96+
sigs := make(chan os.Signal, 1)
97+
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
98+
9499
errCh := make(chan error, 1)
95100
go func() {
96101
errCh <- svc.Start(ctx)
97102
}()
98103

99-
sigs := make(chan os.Signal, 1)
100-
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
101-
102104
select {
103105
case sig := <-sigs:
104106
logger.Info("received signal, shutting down", "signal", sig)

controller/internal/service/controller_service.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,9 @@ func (s *ControllerService) GetServiceEndpoints(
330330
// Prefer the explicit ConfigMap endpoint; fall back to GRPC_TELEMETRY_ENDPOINT
331331
// so the operator can pass the address via env var without touching the ConfigMap.
332332
ep := cmp.Or(s.TelemetryConfig.Endpoint, telemetryEndpoint())
333+
if ep == "" {
334+
return nil, status.Error(codes.Internal, "telemetry is enabled but no endpoint is configured; set telemetry.endpoint in the ConfigMap or GRPC_TELEMETRY_ENDPOINT on the controller pod")
335+
}
333336
resp.TelemetryEndpoints = append(resp.TelemetryEndpoints, &pb.TelemetryEndpoint{
334337
Endpoint: ep,
335338
Certificate: s.TelemetryConfig.Certificate,

controller/internal/service/endpoints.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,14 @@ func routerEndpoint() string {
2222
}
2323

2424
func telemetryEndpoint() string {
25-
return os.Getenv("GRPC_TELEMETRY_ENDPOINT")
25+
ep := os.Getenv("GRPC_TELEMETRY_ENDPOINT")
26+
if ep == "" {
27+
return ""
28+
}
29+
if _, _, err := net.SplitHostPort(ep); err != nil {
30+
return ""
31+
}
32+
return ep
2633
}
2734

2835
func endpointToSAN(endpoint string) ([]string, []net.IP, error) {

controller/internal/service/telemetry_service.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,10 @@ func (s *TelemetryService) loadTLSCredentials() (creds credentials.TransportCred
257257
Bytes: cert.Certificate[0],
258258
}))
259259
}
260-
return credentials.NewServerTLSFromCert(cert), selfSignedPEM, nil
260+
return credentials.NewTLS(&tls.Config{
261+
Certificates: []tls.Certificate{*cert},
262+
MinVersion: tls.VersionTLS12,
263+
}), selfSignedPEM, nil
261264
}
262265

263266
// Start starts the TelemetryService gRPC server and blocks until ctx is cancelled.
@@ -280,6 +283,7 @@ func (s *TelemetryService) Start(ctx context.Context) error {
280283
if err != nil {
281284
return fmt.Errorf("telemetry: listen %s: %w", s.BindAddr, err)
282285
}
286+
defer func() { _ = lis.Close() }()
283287

284288
srv := grpc.NewServer(grpc.Creds(creds))
285289
pb.RegisterTelemetryServiceServer(srv, s)
@@ -300,6 +304,7 @@ func (s *TelemetryService) Start(ctx context.Context) error {
300304
}
301305
return nil
302306
case err := <-errCh:
307+
srv.Stop()
303308
return err
304309
}
305310
}

controller/internal/service/telemetry_service_test.go

Lines changed: 123 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package service
1818

1919
import (
20+
"cmp"
2021
"context"
2122
"crypto/x509"
2223
"encoding/pem"
@@ -28,7 +29,9 @@ import (
2829
"github.com/jumpstarter-dev/jumpstarter/controller/internal/config"
2930
"github.com/jumpstarter-dev/jumpstarter/controller/internal/oidc"
3031
pb "github.com/jumpstarter-dev/jumpstarter/controller/internal/protocol/jumpstarter/v1"
32+
"google.golang.org/grpc/codes"
3133
"google.golang.org/grpc/metadata"
34+
"google.golang.org/grpc/status"
3235
)
3336

3437
// testSigner returns a deterministic Signer built from a fixed seed for use in tests.
@@ -85,51 +88,57 @@ func TestGetServiceEndpoints_RequiresAuthentication(t *testing.T) {
8588
}
8689
}
8790

88-
// buildTelemetryEndpointsResponse exercises the response-building logic
89-
// without going through the auth gate, for isolated unit testing.
90-
func buildTelemetryEndpointsResponse(cfg *config.Telemetry) *pb.GetServiceEndpointsResponse {
91+
// buildTelemetryEndpointsResponse mirrors the production GetServiceEndpoints
92+
// response-building logic without going through the auth gate.
93+
// It returns an error when no endpoint can be resolved, matching production.
94+
func buildTelemetryEndpointsResponse(cfg *config.Telemetry) (*pb.GetServiceEndpointsResponse, error) {
9195
resp := &pb.GetServiceEndpointsResponse{}
9296
if cfg != nil && cfg.Enabled {
93-
minSev := cfg.Logging.Filter.MinSeverity
94-
if minSev == "" {
95-
minSev = "info"
96-
}
97-
ep := cfg.Endpoint
97+
ep := cmp.Or(cfg.Endpoint, telemetryEndpoint())
9898
if ep == "" {
99-
ep = telemetryEndpoint()
99+
return nil, status.Error(codes.Internal, "telemetry is enabled but no endpoint is configured")
100100
}
101101
resp.TelemetryEndpoints = append(resp.TelemetryEndpoints, &pb.TelemetryEndpoint{
102102
Endpoint: ep,
103103
Certificate: cfg.Certificate,
104-
MinSeverity: minSev,
104+
MinSeverity: cmp.Or(cfg.Logging.Filter.MinSeverity, "info"),
105105
})
106106
}
107-
return resp
107+
return resp, nil
108108
}
109109

110110
func TestGetServiceEndpoints_NilConfig_ReturnsEmptyList(t *testing.T) {
111-
resp := buildTelemetryEndpointsResponse(nil)
111+
resp, err := buildTelemetryEndpointsResponse(nil)
112+
if err != nil {
113+
t.Fatalf("unexpected error: %v", err)
114+
}
112115
if len(resp.TelemetryEndpoints) != 0 {
113116
t.Errorf("expected empty telemetry_endpoints, got %d", len(resp.TelemetryEndpoints))
114117
}
115118
}
116119

117120
func TestGetServiceEndpoints_DisabledConfig_ReturnsEmptyList(t *testing.T) {
118-
resp := buildTelemetryEndpointsResponse(&config.Telemetry{Enabled: false, Endpoint: "telemetry:9093"})
121+
resp, err := buildTelemetryEndpointsResponse(&config.Telemetry{Enabled: false, Endpoint: "telemetry:9093"})
122+
if err != nil {
123+
t.Fatalf("unexpected error: %v", err)
124+
}
119125
if len(resp.TelemetryEndpoints) != 0 {
120126
t.Errorf("expected empty telemetry_endpoints when disabled, got %d", len(resp.TelemetryEndpoints))
121127
}
122128
}
123129

124130
func TestGetServiceEndpoints_WithEndpoint_ReturnsEndpoint(t *testing.T) {
125-
resp := buildTelemetryEndpointsResponse(&config.Telemetry{
131+
resp, err := buildTelemetryEndpointsResponse(&config.Telemetry{
126132
Enabled: true,
127133
Endpoint: "telemetry.jumpstarter.svc:9093",
128134
Certificate: "--- PEM ---",
129135
Logging: config.TelemetryLogging{
130136
Filter: config.TelemetryLoggingFilter{MinSeverity: "warning"},
131137
},
132138
})
139+
if err != nil {
140+
t.Fatalf("unexpected error: %v", err)
141+
}
133142

134143
if len(resp.TelemetryEndpoints) != 1 {
135144
t.Fatalf("expected 1 telemetry endpoint, got %d", len(resp.TelemetryEndpoints))
@@ -147,10 +156,13 @@ func TestGetServiceEndpoints_WithEndpoint_ReturnsEndpoint(t *testing.T) {
147156
}
148157

149158
func TestGetServiceEndpoints_DefaultsMinSeverityToInfo(t *testing.T) {
150-
resp := buildTelemetryEndpointsResponse(&config.Telemetry{
159+
resp, err := buildTelemetryEndpointsResponse(&config.Telemetry{
151160
Enabled: true,
152161
Endpoint: "telemetry:9093",
153162
})
163+
if err != nil {
164+
t.Fatalf("unexpected error: %v", err)
165+
}
154166

155167
if len(resp.TelemetryEndpoints) != 1 {
156168
t.Fatalf("expected 1 endpoint, got %d", len(resp.TelemetryEndpoints))
@@ -163,10 +175,13 @@ func TestGetServiceEndpoints_DefaultsMinSeverityToInfo(t *testing.T) {
163175
func TestGetServiceEndpoints_UsesEnvVarWhenEndpointEmpty(t *testing.T) {
164176
t.Setenv("GRPC_TELEMETRY_ENDPOINT", "telemetry.jumpstarter.svc:9093")
165177

166-
resp := buildTelemetryEndpointsResponse(&config.Telemetry{
178+
resp, err := buildTelemetryEndpointsResponse(&config.Telemetry{
167179
Enabled: true,
168180
// Endpoint intentionally left empty — should fall back to env var.
169181
})
182+
if err != nil {
183+
t.Fatalf("unexpected error: %v", err)
184+
}
170185

171186
if len(resp.TelemetryEndpoints) != 1 {
172187
t.Fatalf("expected 1 endpoint, got %d", len(resp.TelemetryEndpoints))
@@ -176,30 +191,31 @@ func TestGetServiceEndpoints_UsesEnvVarWhenEndpointEmpty(t *testing.T) {
176191
}
177192
}
178193

179-
func TestGetServiceEndpoints_BothEndpointAndEnvVarEmpty_ReturnsEmptyEndpoint(t *testing.T) {
194+
func TestGetServiceEndpoints_BothEndpointAndEnvVarEmpty_ReturnsError(t *testing.T) {
180195
t.Setenv("GRPC_TELEMETRY_ENDPOINT", "")
181196

182-
resp := buildTelemetryEndpointsResponse(&config.Telemetry{
197+
_, err := buildTelemetryEndpointsResponse(&config.Telemetry{
183198
Enabled: true,
184199
// Both Endpoint and GRPC_TELEMETRY_ENDPOINT are empty.
185200
})
186-
187-
if len(resp.TelemetryEndpoints) != 1 {
188-
t.Fatalf("expected 1 endpoint entry, got %d", len(resp.TelemetryEndpoints))
201+
if err == nil {
202+
t.Fatal("expected error when no endpoint is configured, got nil")
189203
}
190-
// An empty endpoint is returned; the caller must handle this gracefully.
191-
if resp.TelemetryEndpoints[0].Endpoint != "" {
192-
t.Errorf("Endpoint = %q, want empty string when nothing is configured", resp.TelemetryEndpoints[0].Endpoint)
204+
if status.Code(err) != codes.Internal {
205+
t.Errorf("expected codes.Internal, got %v", status.Code(err))
193206
}
194207
}
195208

196209
func TestGetServiceEndpoints_ConfigEndpointTakesPrecedenceOverEnvVar(t *testing.T) {
197210
t.Setenv("GRPC_TELEMETRY_ENDPOINT", "env-telemetry.svc:9093")
198211

199-
resp := buildTelemetryEndpointsResponse(&config.Telemetry{
212+
resp, err := buildTelemetryEndpointsResponse(&config.Telemetry{
200213
Enabled: true,
201214
Endpoint: "config-telemetry.svc:9093",
202215
})
216+
if err != nil {
217+
t.Fatalf("unexpected error: %v", err)
218+
}
203219

204220
if len(resp.TelemetryEndpoints) != 1 {
205221
t.Fatalf("expected 1 endpoint, got %d", len(resp.TelemetryEndpoints))
@@ -242,33 +258,6 @@ func writeTLSPEMFiles(t *testing.T) (certPath, keyPath string) {
242258
return certPath, keyPath
243259
}
244260

245-
// selfSignedSANs generates a self-signed certificate with the same logic as
246-
// loadTLSCredentials for a given GRPC_TELEMETRY_ENDPOINT value, and returns
247-
// its DNS SANs for assertion.
248-
func selfSignedSANs(t *testing.T, advertised string) []string {
249-
t.Helper()
250-
var dnsnames []string
251-
if advertised != "" {
252-
dns, _, err := endpointToSAN(advertised)
253-
if err != nil {
254-
dnsnames = []string{"localhost"}
255-
} else {
256-
dnsnames = dns
257-
}
258-
} else {
259-
dnsnames = []string{"localhost"}
260-
}
261-
cert, err := NewSelfSignedCertificate("test", dnsnames, nil)
262-
if err != nil {
263-
t.Fatalf("NewSelfSignedCertificate: %v", err)
264-
}
265-
leaf, err := x509.ParseCertificate(cert.Certificate[0])
266-
if err != nil {
267-
t.Fatalf("ParseCertificate: %v", err)
268-
}
269-
return leaf.DNSNames
270-
}
271-
272261
func TestTelemetryService_LoadTLSCredentials_SelfSigned(t *testing.T) {
273262
t.Setenv("EXTERNAL_CERT_PEM", "")
274263
t.Setenv("EXTERNAL_KEY_PEM", "")
@@ -292,20 +281,56 @@ func TestTelemetryService_LoadTLSCredentials_SelfSigned(t *testing.T) {
292281
}
293282

294283
func TestTelemetryService_LoadTLSCredentials_SelfSignedUsesAdvertisedEndpointForSAN(t *testing.T) {
295-
// When GRPC_TELEMETRY_ENDPOINT is set, the self-signed cert SAN should derive
296-
// from the advertised hostname — not from the bind address.
297-
// We test the SAN derivation logic directly (same code path as loadTLSCredentials).
298-
sans := selfSignedSANs(t, "telemetry.jumpstarter.svc:9093")
299-
if len(sans) != 1 || sans[0] != "telemetry.jumpstarter.svc" {
300-
t.Errorf("expected SAN [telemetry.jumpstarter.svc], got %v", sans)
284+
// The self-signed cert SAN must match the advertised endpoint hostname so
285+
// that TLS hostname verification succeeds when exporters connect.
286+
t.Setenv("EXTERNAL_CERT_PEM", "")
287+
t.Setenv("EXTERNAL_KEY_PEM", "")
288+
t.Setenv("GRPC_TELEMETRY_ENDPOINT", "telemetry.jumpstarter.svc:9093")
289+
290+
svc := &TelemetryService{BindAddr: ":9093", Signer: testSigner(t)}
291+
_, selfSignedPEM, err := svc.loadTLSCredentials()
292+
if err != nil {
293+
t.Fatalf("loadTLSCredentials() failed: %v", err)
294+
}
295+
if selfSignedPEM == "" {
296+
t.Fatal("expected non-empty selfSignedPEM")
297+
}
298+
299+
block, _ := pem.Decode([]byte(selfSignedPEM))
300+
if block == nil {
301+
t.Fatal("selfSignedPEM is not valid PEM")
302+
}
303+
leaf, err := x509.ParseCertificate(block.Bytes)
304+
if err != nil {
305+
t.Fatalf("ParseCertificate: %v", err)
306+
}
307+
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "telemetry.jumpstarter.svc" {
308+
t.Errorf("expected SAN [telemetry.jumpstarter.svc], got %v", leaf.DNSNames)
301309
}
302310
}
303311

304312
func TestTelemetryService_LoadTLSCredentials_SelfSignedFallsBackToLocalhostWhenNoEndpoint(t *testing.T) {
305-
// When GRPC_TELEMETRY_ENDPOINT is empty, SAN defaults to "localhost".
306-
sans := selfSignedSANs(t, "")
307-
if len(sans) != 1 || sans[0] != "localhost" {
308-
t.Errorf("expected SAN [localhost], got %v", sans)
313+
// When GRPC_TELEMETRY_ENDPOINT is unset, the self-signed cert SAN defaults to "localhost".
314+
t.Setenv("EXTERNAL_CERT_PEM", "")
315+
t.Setenv("EXTERNAL_KEY_PEM", "")
316+
t.Setenv("GRPC_TELEMETRY_ENDPOINT", "")
317+
318+
svc := &TelemetryService{BindAddr: ":9093", Signer: testSigner(t)}
319+
_, selfSignedPEM, err := svc.loadTLSCredentials()
320+
if err != nil {
321+
t.Fatalf("loadTLSCredentials() failed: %v", err)
322+
}
323+
324+
block, _ := pem.Decode([]byte(selfSignedPEM))
325+
if block == nil {
326+
t.Fatal("selfSignedPEM is not valid PEM")
327+
}
328+
leaf, err := x509.ParseCertificate(block.Bytes)
329+
if err != nil {
330+
t.Fatalf("ParseCertificate: %v", err)
331+
}
332+
if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "localhost" {
333+
t.Errorf("expected SAN [localhost], got %v", leaf.DNSNames)
309334
}
310335
}
311336

@@ -384,6 +409,42 @@ func TestTelemetryService_LoadTLSCredentials_MissingCertFileReturnsError(t *test
384409
}
385410
}
386411

412+
func TestTelemetryService_LoadTLSCredentials_MissingKeyFileReturnsError(t *testing.T) {
413+
certPath, _ := writeTLSPEMFiles(t)
414+
t.Setenv("EXTERNAL_CERT_PEM", certPath)
415+
t.Setenv("EXTERNAL_KEY_PEM", "/does/not/exist/tls.key")
416+
417+
svc := &TelemetryService{BindAddr: ":9093", Signer: testSigner(t)}
418+
_, _, err := svc.loadTLSCredentials()
419+
if err == nil {
420+
t.Fatal("expected error reading missing key file")
421+
}
422+
if !strings.Contains(err.Error(), "key") {
423+
t.Errorf("expected 'key' in error message, got: %v", err)
424+
}
425+
}
426+
427+
func TestTelemetryService_LoadTLSCredentials_ValidCertInvalidKeyReturnsError(t *testing.T) {
428+
certPath, _ := writeTLSPEMFiles(t)
429+
430+
keyFile, err := os.CreateTemp(t.TempDir(), "tls-*.key")
431+
if err != nil {
432+
t.Fatalf("CreateTemp: %v", err)
433+
}
434+
if err := keyFile.Close(); err != nil {
435+
t.Fatalf("close: %v", err)
436+
}
437+
438+
t.Setenv("EXTERNAL_CERT_PEM", certPath)
439+
t.Setenv("EXTERNAL_KEY_PEM", keyFile.Name())
440+
441+
svc := &TelemetryService{BindAddr: ":9093", Signer: testSigner(t)}
442+
_, _, err = svc.loadTLSCredentials()
443+
if err == nil {
444+
t.Fatal("expected error parsing mismatched cert/key pair")
445+
}
446+
}
447+
387448
func TestTelemetryService_Start_FailsWhenExternalCertFileMissing(t *testing.T) {
388449
t.Setenv("EXTERNAL_CERT_PEM", "/no/such/cert.pem")
389450
t.Setenv("EXTERNAL_KEY_PEM", "/no/such/key.pem")

0 commit comments

Comments
 (0)