Skip to content

Commit b662157

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 b662157

10 files changed

Lines changed: 537 additions & 230 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/config/config.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package config
22

33
import (
4+
"cmp"
45
"context"
56
"fmt"
7+
"net"
8+
"os"
69
"time"
710

811
"github.com/jumpstarter-dev/jumpstarter/controller/internal/oidc"
@@ -43,6 +46,34 @@ func LoadRouterConfiguration(
4346
return serverOptions, nil
4447
}
4548

49+
// resolveTelemetryConfig validates and resolves the telemetry endpoint for a
50+
// Telemetry config block. It prefers the explicit ConfigMap endpoint and falls
51+
// back to GRPC_TELEMETRY_ENDPOINT, matching the behaviour previously inlined in
52+
// LoadConfiguration. Returns nil when t is nil or disabled.
53+
func resolveTelemetryConfig(t *Telemetry) (*Telemetry, error) {
54+
if t == nil || !t.Enabled {
55+
return nil, nil
56+
}
57+
if err := t.Validate(); err != nil {
58+
return nil, err
59+
}
60+
// Prefer the explicit ConfigMap value; fall back to the env var so the
61+
// operator can set the endpoint without touching the ConfigMap. Resolving
62+
// here ensures LoadedConfig.Telemetry.Endpoint is always the complete value
63+
// — callers don't need to re-check the env var.
64+
t.Endpoint = cmp.Or(t.Endpoint, os.Getenv("GRPC_TELEMETRY_ENDPOINT"))
65+
if ep := t.Endpoint; ep != "" {
66+
host, _, err := net.SplitHostPort(ep)
67+
if err != nil {
68+
return nil, fmt.Errorf("telemetry endpoint %q is not a valid host:port: %w", ep, err)
69+
}
70+
if host == "" {
71+
return nil, fmt.Errorf("telemetry endpoint %q has no host", ep)
72+
}
73+
}
74+
return t, nil
75+
}
76+
4677
func LoadConfiguration(
4778
ctx context.Context,
4879
client client.Reader,
@@ -122,12 +153,9 @@ func LoadConfiguration(
122153
return nil, err
123154
}
124155

125-
var telemetry *Telemetry
126-
if config.Telemetry != nil && config.Telemetry.Enabled {
127-
if err := config.Telemetry.Validate(); err != nil {
128-
return nil, err
129-
}
130-
telemetry = config.Telemetry
156+
telemetry, err := resolveTelemetryConfig(config.Telemetry)
157+
if err != nil {
158+
return nil, err
131159
}
132160

133161
return &LoadedConfig{

controller/internal/config/types_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,78 @@ func TestDeprecatedLabelsOmitEmpty(t *testing.T) {
276276
}
277277
}
278278

279+
func TestTelemetryEndpointResolution(t *testing.T) {
280+
tests := []struct {
281+
name string
282+
configValue string
283+
envValue string
284+
wantEndpoint string
285+
wantErr bool
286+
}{
287+
{
288+
name: "ConfigMap value takes precedence",
289+
configValue: "telemetry.ns.svc:9093",
290+
envValue: "env-telemetry:9093",
291+
wantEndpoint: "telemetry.ns.svc:9093",
292+
},
293+
{
294+
name: "env var fallback when ConfigMap is empty",
295+
envValue: "env-telemetry.svc:9093",
296+
wantEndpoint: "env-telemetry.svc:9093",
297+
},
298+
{
299+
name: "both empty yields empty endpoint (no error)",
300+
wantEndpoint: "",
301+
},
302+
{
303+
name: "malformed ConfigMap value is rejected",
304+
configValue: "no-port",
305+
wantErr: true,
306+
},
307+
{
308+
name: "malformed env var is rejected",
309+
envValue: "garbage-no-port",
310+
wantErr: true,
311+
},
312+
{
313+
name: "port-only ConfigMap value is rejected",
314+
configValue: ":9093",
315+
wantErr: true,
316+
},
317+
{
318+
name: "port-only env var is rejected",
319+
envValue: ":9093",
320+
wantErr: true,
321+
},
322+
}
323+
324+
for _, tt := range tests {
325+
t.Run(tt.name, func(t *testing.T) {
326+
t.Setenv("GRPC_TELEMETRY_ENDPOINT", tt.envValue)
327+
328+
cfg := &Telemetry{Enabled: true, Endpoint: tt.configValue}
329+
resolved, err := resolveTelemetryConfig(cfg)
330+
331+
if tt.wantErr {
332+
if err == nil {
333+
t.Fatalf("expected validation error, got nil (resolved=%+v)", resolved)
334+
}
335+
return
336+
}
337+
if err != nil {
338+
t.Fatalf("unexpected error: %v", err)
339+
}
340+
var gotEndpoint string
341+
if resolved != nil {
342+
gotEndpoint = resolved.Endpoint
343+
}
344+
if gotEndpoint != tt.wantEndpoint {
345+
t.Errorf("resolved.Endpoint = %q, want %q", gotEndpoint, tt.wantEndpoint)
346+
}
347+
})
348+
}
349+
}
350+
279351
func TestParseDuration(t *testing.T) {
280352
tests := []struct {
281353
input string

controller/internal/service/controller_service.go

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -327,11 +327,13 @@ func (s *ControllerService) GetServiceEndpoints(
327327
resp := &pb.GetServiceEndpointsResponse{}
328328

329329
if s.TelemetryConfig != nil && s.TelemetryConfig.Enabled {
330-
// Prefer the explicit ConfigMap endpoint; fall back to GRPC_TELEMETRY_ENDPOINT
331-
// so the operator can pass the address via env var without touching the ConfigMap.
332-
ep := cmp.Or(s.TelemetryConfig.Endpoint, telemetryEndpoint())
330+
// Endpoint is resolved at config-load time (ConfigMap value or GRPC_TELEMETRY_ENDPOINT
331+
// env var fallback), so TelemetryConfig.Endpoint is always the complete value here.
332+
if s.TelemetryConfig.Endpoint == "" {
333+
return nil, status.Error(codes.FailedPrecondition, "telemetry is enabled but no endpoint is configured; set telemetry.endpoint in the ConfigMap or GRPC_TELEMETRY_ENDPOINT on the controller pod")
334+
}
333335
resp.TelemetryEndpoints = append(resp.TelemetryEndpoints, &pb.TelemetryEndpoint{
334-
Endpoint: ep,
336+
Endpoint: s.TelemetryConfig.Endpoint,
335337
Certificate: s.TelemetryConfig.Certificate,
336338
MinSeverity: cmp.Or(s.TelemetryConfig.Logging.Filter.MinSeverity, "info"),
337339
})
@@ -1199,32 +1201,9 @@ func (s *ControllerService) Start(ctx context.Context) error {
11991201
return err
12001202
}
12011203

1202-
// Load external certificate if provided via environment variables.
1203-
// Environment variables EXTERNAL_CERT_PEM and EXTERNAL_KEY_PEM should contain the PEM-encoded
1204-
// certificate and private key respectively. If both are set, they are used; otherwise
1205-
// a self-signed certificate is generated.
1206-
var cert *tls.Certificate
1207-
certPEMPath := os.Getenv("EXTERNAL_CERT_PEM")
1208-
keyPEMPath := os.Getenv("EXTERNAL_KEY_PEM")
1209-
if certPEMPath != "" && keyPEMPath != "" {
1210-
certPEMBytes, err := os.ReadFile(certPEMPath)
1211-
if err != nil {
1212-
return fmt.Errorf("failed to read external certificate file: %w", err)
1213-
}
1214-
keyPEMBytes, err := os.ReadFile(keyPEMPath)
1215-
if err != nil {
1216-
return fmt.Errorf("failed to read external key file: %w", err)
1217-
}
1218-
parsedCert, err := tls.X509KeyPair(certPEMBytes, keyPEMBytes)
1219-
if err != nil {
1220-
return fmt.Errorf("failed to parse external certificate: %w", err)
1221-
}
1222-
cert = &parsedCert
1223-
} else {
1224-
cert, err = NewSelfSignedCertificate("jumpstarter controller", dnsnames, ipaddresses)
1225-
if err != nil {
1226-
return err
1227-
}
1204+
cert, _, err := LoadTLSCertificate("jumpstarter controller", dnsnames, ipaddresses)
1205+
if err != nil {
1206+
return err
12281207
}
12291208

12301209
opts := append(s.ServerOptions,
@@ -1267,8 +1246,11 @@ func (s *ControllerService) Start(ctx context.Context) error {
12671246
// Register gRPC gateway
12681247
gwmux := gwruntime.NewServeMux()
12691248

1249+
// The controller multiplexes gRPC (h2) and REST (http/1.1) on a single port,
1250+
// so it needs NextProtos — which LoadTLSCredentials doesn't expose.
12701251
listener, err := tls.Listen("tcp", ":8082", &tls.Config{
12711252
Certificates: []tls.Certificate{*cert},
1253+
MinVersion: tls.VersionTLS12,
12721254
NextProtos: []string{"http/1.1", "h2"},
12731255
})
12741256
if err != nil {

controller/internal/service/controller_service_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030

3131
"github.com/golang-jwt/jwt/v5"
3232
jumpstarterdevv1alpha1 "github.com/jumpstarter-dev/jumpstarter/controller/api/v1alpha1"
33+
"github.com/jumpstarter-dev/jumpstarter/controller/internal/config"
3334
jlog "github.com/jumpstarter-dev/jumpstarter/controller/internal/log"
3435
pb "github.com/jumpstarter-dev/jumpstarter/controller/internal/protocol/jumpstarter/v1"
3536
"google.golang.org/grpc"
@@ -39,11 +40,13 @@ import (
3940
"google.golang.org/grpc/status"
4041
"k8s.io/apimachinery/pkg/api/meta"
4142
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
43+
k8sruntime "k8s.io/apimachinery/pkg/runtime"
4244
"k8s.io/apiserver/pkg/authentication/authenticator"
4345
"k8s.io/apiserver/pkg/authentication/user"
4446
"k8s.io/apiserver/pkg/authorization/authorizer"
4547
logf "sigs.k8s.io/controller-runtime/pkg/log"
4648
ctrlzap "sigs.k8s.io/controller-runtime/pkg/log/zap"
49+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
4750
)
4851

4952
const testRouterToken = "tok"
@@ -2061,6 +2064,57 @@ func (noopAuthorizer) Authorize(_ context.Context, _ authorizer.Attributes) (aut
20612064
return authorizer.DecisionNoOpinion, "", nil
20622065
}
20632066

2067+
// passingAuthenticator always authenticates successfully with a fixed user name.
2068+
type passingAuthenticator struct{ userName string }
2069+
2070+
func (p *passingAuthenticator) AuthenticateContext(_ context.Context) (*authenticator.Response, bool, error) {
2071+
return &authenticator.Response{User: &user.DefaultInfo{Name: p.userName}}, true, nil
2072+
}
2073+
2074+
// exporterAttributesGetter returns attributes that identify a fixed Exporter object.
2075+
type exporterAttributesGetter struct{ namespace, name string }
2076+
2077+
func (e *exporterAttributesGetter) ContextAttributes(_ context.Context, u user.Info) (authorizer.Attributes, error) {
2078+
return authorizer.AttributesRecord{
2079+
User: u,
2080+
Namespace: e.namespace,
2081+
Resource: "Exporter",
2082+
Name: e.name,
2083+
}, nil
2084+
}
2085+
2086+
// passingAuthorizer always allows.
2087+
type passingAuthorizer struct{}
2088+
2089+
func (passingAuthorizer) Authorize(_ context.Context, _ authorizer.Attributes) (authorizer.Decision, string, error) {
2090+
return authorizer.DecisionAllow, "", nil
2091+
}
2092+
2093+
// authSuccessServiceCtx builds a ControllerService whose authentication always
2094+
// succeeds. A pre-populated Exporter object is stored in the fake client so
2095+
// that VerifyExporterObjectToken can fetch it.
2096+
func authSuccessServiceCtx(t *testing.T, cfg *config.Telemetry) (*ControllerService, context.Context) {
2097+
t.Helper()
2098+
2099+
scheme := k8sruntime.NewScheme()
2100+
if err := jumpstarterdevv1alpha1.AddToScheme(scheme); err != nil {
2101+
t.Fatalf("failed to add scheme: %v", err)
2102+
}
2103+
exporter := &jumpstarterdevv1alpha1.Exporter{
2104+
ObjectMeta: metav1.ObjectMeta{Name: "test-exporter", Namespace: "default"},
2105+
}
2106+
fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(exporter).Build()
2107+
2108+
svc := &ControllerService{
2109+
Client: fakeClient,
2110+
Authn: &passingAuthenticator{userName: "test-user"},
2111+
Authz: passingAuthorizer{},
2112+
Attr: &exporterAttributesGetter{namespace: "default", name: "test-exporter"},
2113+
TelemetryConfig: cfg,
2114+
}
2115+
return svc, context.Background()
2116+
}
2117+
20642118
// authFailureServiceCtx builds a ControllerService whose authentication always
20652119
// fails, plus a context carrying a peer address, a captured logger, and the
20662120
// jlog.LogContext enrichment applied by the gRPC interceptors in production.

controller/internal/service/endpoints.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package service
22

33
import (
4+
"fmt"
45
"net"
56
"os"
7+
8+
ctrl "sigs.k8s.io/controller-runtime"
69
)
710

811
func controllerEndpoint() string {
@@ -22,14 +25,35 @@ func routerEndpoint() string {
2225
}
2326

2427
func telemetryEndpoint() string {
25-
return os.Getenv("GRPC_TELEMETRY_ENDPOINT")
28+
ep := os.Getenv("GRPC_TELEMETRY_ENDPOINT")
29+
if ep == "" {
30+
return ""
31+
}
32+
if err := validateHostPort(ep); err != nil {
33+
ctrl.Log.WithName("telemetry").Error(err, "GRPC_TELEMETRY_ENDPOINT is not a valid host:port; ignoring",
34+
"value", ep)
35+
return ""
36+
}
37+
return ep
2638
}
2739

28-
func endpointToSAN(endpoint string) ([]string, []net.IP, error) {
29-
host, _, err := net.SplitHostPort(endpoint)
40+
// validateHostPort checks that s is a valid "host:port" with a non-empty host.
41+
func validateHostPort(s string) error {
42+
host, _, err := net.SplitHostPort(s)
3043
if err != nil {
44+
return err
45+
}
46+
if host == "" {
47+
return fmt.Errorf("endpoint %q has no host", s)
48+
}
49+
return nil
50+
}
51+
52+
func endpointToSAN(endpoint string) ([]string, []net.IP, error) {
53+
if err := validateHostPort(endpoint); err != nil {
3154
return nil, nil, err
3255
}
56+
host, _, _ := net.SplitHostPort(endpoint)
3357
ip := net.ParseIP(host)
3458
if ip != nil {
3559
return []string{}, []net.IP{ip}, nil

0 commit comments

Comments
 (0)