Skip to content

Commit 28a58d0

Browse files
authored
feat: Add TLS support for Go Feature Server (#6229)
* feat: Add HTTPs server support. Signed-off-by: Shuchu Han <[email protected]> * feat: Add serverTSL() method. Signed-off-by: Shuchu Han <[email protected]> * fix: Lint the Go code. Signed-off-by: Shuchu Han <[email protected]> * feat: Add TLS configuration. Signed-off-by: Shuchu Han <[email protected]> * fix: Add unit tests for HTTPS server. Signed-off-by: Shuchu Han <[email protected]> * fix: Format code. Fixed bad comments. Signed-off-by: Shuchu Han <[email protected]> * fix: Fix unit test error. Signed-off-by: Shuchu Han <[email protected]> * fix: use single StartHttpServer to cover HTTP and HTTPs cases. Signed-off-by: Shuchu Han <[email protected]> * fix: Change back the pyproject.toml file. Signed-off-by: Shuchu Han <[email protected]> * fix: Fix name convertion of https_enable. Signed-off-by: Shuchu Han <[email protected]> --------- Signed-off-by: Shuchu Han <[email protected]>
1 parent 463f16c commit 28a58d0

5 files changed

Lines changed: 218 additions & 14 deletions

File tree

‎go/internal/feast/metrics/metrics.go‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ var (
3030
TimeHistogramType = reflect.TypeOf((*TimeHistogram)(nil)).Elem()
3131
)
3232

33-
3433
func RegisterTimeHistogram(name, help, namespace string, labelNames []string, tag reflect.StructTag) (func(prometheus.Labels) interface{}, prometheus.Collector, error) {
3534
f, collector, err := prometheusvanilla.BuildHistogram(name, help, namespace, labelNames, tag)
3635
if err != nil {

‎go/internal/feast/onlinestore/postgresonlinestore.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,4 @@ func buildPostgresConnString(config map[string]interface{}) string {
194194
}
195195

196196
return connURL.String()
197-
}
197+
}

‎go/internal/feast/server/http_server.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package server
22

33
import (
44
"context"
5+
"crypto/tls"
56
"encoding/json"
67
"fmt"
78
"net/http"
@@ -396,6 +397,34 @@ func (s *httpServer) Serve(host string, port int) error {
396397
return err
397398
}
398399

400+
func (s *httpServer) ServeTLS(host string, port int, certFile string, keyFile string) error {
401+
mux := http.NewServeMux()
402+
mux.Handle("/get-online-features", metricsMiddleware(recoverMiddleware(http.HandlerFunc(s.getOnlineFeatures))))
403+
mux.Handle("/health", metricsMiddleware(http.HandlerFunc(healthCheckHandler)))
404+
s.server = &http.Server{
405+
Addr: fmt.Sprintf("%s:%d", host, port),
406+
Handler: mux,
407+
ReadTimeout: 5 * time.Second,
408+
WriteTimeout: 10 * time.Second,
409+
IdleTimeout: 15 * time.Second,
410+
TLSConfig: &tls.Config{
411+
MinVersion: tls.VersionTLS12,
412+
CurvePreferences: []tls.CurveID{
413+
tls.CurveP256,
414+
tls.X25519MLKEM768,
415+
//tls.SecP256r1MLKEM768, // Only available in Go 1.26
416+
},
417+
},
418+
}
419+
err := s.server.ListenAndServeTLS(certFile, keyFile)
420+
// Don't return the error if it's caused by graceful shutdown using Stop()
421+
if err == http.ErrServerClosed {
422+
return nil
423+
}
424+
log.Fatal().Stack().Err(err).Msg("Failed to start HTTPS server")
425+
return err
426+
}
427+
399428
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
400429
w.WriteHeader(http.StatusOK)
401430
fmt.Fprintf(w, "Healthy")

‎go/main.go‎

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212
"sync"
1313
"syscall"
14+
"time"
1415

1516
"github.com/feast-dev/feast/go/internal/feast"
1617
"github.com/feast-dev/feast/go/internal/feast/registry"
@@ -36,15 +37,28 @@ import (
3637

3738
var tracer trace.Tracer
3839

40+
var newSignalStopChannel = func() (chan os.Signal, func()) {
41+
stop := make(chan os.Signal, 1)
42+
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
43+
return stop, func() {
44+
signal.Stop(stop)
45+
}
46+
}
47+
3948
type ServerStarter interface {
4049
StartHttpServer(fs *feast.FeatureStore, host string, port int, metricsPort int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error
4150
StartGrpcServer(fs *feast.FeatureStore, host string, port int, metricsPort int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error
51+
StartHttpsServer(fs *feast.FeatureStore, host string, port int, metricsPort int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions, certFile string, keyFile string) error
4252
}
4353

4454
type RealServerStarter struct{}
4555

4656
func (s *RealServerStarter) StartHttpServer(fs *feast.FeatureStore, host string, port int, metricsPort int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error {
47-
return StartHttpServer(fs, host, port, metricsPort, writeLoggedFeaturesCallback, loggingOpts)
57+
return StartHttpServer(fs, host, port, metricsPort, writeLoggedFeaturesCallback, loggingOpts, false, "", "")
58+
}
59+
60+
func (s *RealServerStarter) StartHttpsServer(fs *feast.FeatureStore, host string, port int, metricsPort int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions, certFile string, keyFile string) error {
61+
return StartHttpServer(fs, host, port, metricsPort, writeLoggedFeaturesCallback, loggingOpts, true, certFile, keyFile)
4862
}
4963

5064
func (s *RealServerStarter) StartGrpcServer(fs *feast.FeatureStore, host string, port int, metricsPort int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error {
@@ -58,18 +72,22 @@ func main() {
5872
port := 8080
5973
metricsPort := 9090
6074
server := RealServerStarter{}
75+
certFile := ""
76+
keyFile := ""
6177
// Current Directory
6278
repoPath, err := os.Getwd()
6379
if err != nil {
6480
log.Error().Stack().Err(err).Msg("Failed to get current directory")
6581
}
6682

67-
flag.StringVar(&serverType, "type", serverType, "Specify the server type (http or grpc)")
83+
flag.StringVar(&serverType, "type", serverType, "Specify the server type (http, https or grpc)")
6884
flag.StringVar(&repoPath, "chdir", repoPath, "Repository path where feature store yaml file is stored")
6985

7086
flag.StringVar(&host, "host", host, "Specify a host for the server")
7187
flag.IntVar(&port, "port", port, "Specify a port for the server")
7288
flag.IntVar(&metricsPort, "metrics-port", metricsPort, "Specify a port for the metrics server")
89+
flag.StringVar(&certFile, "tls-cert-file", "", "Path to the TLS certificate file")
90+
flag.StringVar(&keyFile, "tls-key-file", "", "Path to the TLS key file")
7391
flag.Parse()
7492

7593
// Initialize tracer
@@ -119,8 +137,10 @@ func main() {
119137
err = server.StartHttpServer(fs, host, port, metricsPort, nil, loggingOptions)
120138
} else if serverType == "grpc" {
121139
err = server.StartGrpcServer(fs, host, port, metricsPort, nil, loggingOptions)
140+
} else if serverType == "https" {
141+
err = server.StartHttpsServer(fs, host, port, metricsPort, nil, loggingOptions, certFile, keyFile)
122142
} else {
123-
fmt.Println("Unknown server type. Please specify 'http' or 'grpc'.")
143+
fmt.Println("Unknown server type. Please specify 'http' or 'grpc' or 'https'.")
124144
}
125145

126146
if err != nil {
@@ -227,27 +247,34 @@ func StartGrpcServer(fs *feast.FeatureStore, host string, port int, metricsPort
227247
// StartHttpServerWithLogging starts HTTP server with enabled feature logging
228248
// Go does not allow direct assignment to package-level functions as a way to
229249
// mock them for tests
230-
func StartHttpServer(fs *feast.FeatureStore, host string, port int, metricsPort int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions) error {
250+
func StartHttpServer(fs *feast.FeatureStore, host string, port int, metricsPort int, writeLoggedFeaturesCallback logging.OfflineStoreWriteCallback, loggingOpts *logging.LoggingOptions, httpsEnable bool, certFile string, keyFile string) error {
251+
if httpsEnable && (certFile == "" || keyFile == "") {
252+
return fmt.Errorf("--tls-cert-file and --tls-key-file must be provided for HTTPS server.")
253+
}
254+
231255
loggingService, err := constructLoggingService(fs, writeLoggedFeaturesCallback, loggingOpts)
232256
if err != nil {
233257
return err
234258
}
235259
ser := server.NewHttpServer(fs, loggingService)
236260
log.Info().Msgf("Starting a HTTP server on host %s, port %d", host, port)
261+
237262
// Start metrics server
238-
metricsServer := &http.Server{Addr: fmt.Sprintf(":%d", metricsPort)}
263+
mux := http.NewServeMux()
264+
mux.Handle("/metrics", promhttp.Handler())
265+
metricsServer := &http.Server{
266+
Addr: fmt.Sprintf(":%d", metricsPort),
267+
Handler: mux,
268+
}
239269
go func() {
240270
log.Info().Msgf("Starting metrics server on port %d", metricsPort)
241-
mux := http.NewServeMux()
242-
mux.Handle("/metrics", promhttp.Handler())
243-
metricsServer.Handler = mux
244271
if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
245272
log.Error().Err(err).Msg("Failed to start metrics server")
246273
}
247274
}()
248275

249-
stop := make(chan os.Signal, 1)
250-
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
276+
stop, stopCleanup := newSignalStopChannel()
277+
defer stopCleanup()
251278

252279
var wg sync.WaitGroup
253280
wg.Add(1)
@@ -263,7 +290,9 @@ func StartHttpServer(fs *feast.FeatureStore, host string, port int, metricsPort
263290
log.Error().Err(err).Msg("Error when stopping the HTTP server")
264291
}
265292
log.Info().Msg("Stopping metrics server...")
266-
if err := metricsServer.Shutdown(context.Background()); err != nil {
293+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
294+
defer cancel()
295+
if err := metricsServer.Shutdown(ctx); err != nil {
267296
log.Error().Err(err).Msg("Error stopping metrics server")
268297
}
269298
if loggingService != nil {
@@ -279,7 +308,11 @@ func StartHttpServer(fs *feast.FeatureStore, host string, port int, metricsPort
279308
}
280309
}()
281310

282-
err = ser.Serve(host, port)
311+
if httpsEnable {
312+
err = ser.ServeTLS(host, port, certFile, keyFile)
313+
} else {
314+
err = ser.Serve(host, port)
315+
}
283316
close(serverExited)
284317
wg.Wait()
285318
return err

‎go/main_test.go‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
package main
22

33
import (
4+
"crypto/rand"
5+
"crypto/rsa"
6+
"crypto/tls"
7+
"crypto/x509"
8+
"crypto/x509/pkix"
9+
"encoding/pem"
10+
"fmt"
11+
"io"
12+
"math/big"
13+
"net"
14+
"net/http"
15+
"os"
16+
"strings"
17+
"syscall"
418
"testing"
19+
"time"
520

621
"github.com/feast-dev/feast/go/internal/feast"
722
"github.com/feast-dev/feast/go/internal/feast/server/logging"
823
"github.com/stretchr/testify/assert"
924
"github.com/stretchr/testify/mock"
25+
"github.com/stretchr/testify/require"
1026
)
1127

1228
// MockServerStarter is a mock of ServerStarter interface for testing
@@ -67,3 +83,130 @@ func TestConstructLoggingService(t *testing.T) {
6783
assert.NoError(t, err)
6884
// Further assertions can be added here based on the expected behavior of constructLoggingService
6985
}
86+
87+
func TestStartHttpsServerHealthEndpoint(t *testing.T) {
88+
certPath, keyPath := createSelfSignedTLSFiles(t)
89+
host := "127.0.0.1"
90+
port := getFreePort(t)
91+
metricsPort := getFreePort(t)
92+
93+
stop := make(chan os.Signal, 1)
94+
prevNewSignalStopChannel := newSignalStopChannel
95+
newSignalStopChannel = func() (chan os.Signal, func()) {
96+
return stop, func() {}
97+
}
98+
t.Cleanup(func() {
99+
newSignalStopChannel = prevNewSignalStopChannel
100+
})
101+
102+
errCh := make(chan error, 1)
103+
go func() {
104+
errCh <- StartHttpServer(&feast.FeatureStore{}, host, port, metricsPort, nil, &logging.LoggingOptions{}, true, certPath, keyPath)
105+
}()
106+
107+
httpsClient := &http.Client{
108+
Transport: &http.Transport{
109+
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
110+
},
111+
}
112+
t.Cleanup(httpsClient.CloseIdleConnections)
113+
114+
url := fmt.Sprintf("https://%s:%d/health", host, port)
115+
116+
var (
117+
resp *http.Response
118+
err error
119+
)
120+
require.Eventually(t, func() bool {
121+
resp, err = httpsClient.Get(url)
122+
if err != nil {
123+
return false
124+
}
125+
return true
126+
}, 5*time.Second, 100*time.Millisecond)
127+
require.NoError(t, err)
128+
t.Cleanup(func() {
129+
if resp != nil && resp.Body != nil {
130+
_ = resp.Body.Close()
131+
}
132+
})
133+
134+
body, readErr := io.ReadAll(resp.Body)
135+
require.NoError(t, readErr)
136+
137+
assert.Equal(t, http.StatusOK, resp.StatusCode)
138+
assert.Equal(t, "Healthy", strings.TrimSpace(string(body)))
139+
140+
stop <- syscall.SIGTERM
141+
142+
select {
143+
case startErr := <-errCh:
144+
require.NoError(t, startErr)
145+
case <-time.After(5 * time.Second):
146+
t.Fatal("StartHttpsServer did not shutdown within timeout")
147+
}
148+
}
149+
150+
func TestStartHttpsServerTLSFilesRequired(t *testing.T) {
151+
err := StartHttpServer(&feast.FeatureStore{}, "127.0.0.1", 0, 0, nil, &logging.LoggingOptions{}, true, "", "")
152+
require.Error(t, err)
153+
assert.Contains(t, err.Error(), "--tls-cert-file and --tls-key-file must be provided")
154+
}
155+
156+
func getFreePort(t *testing.T) int {
157+
t.Helper()
158+
159+
listener, err := net.Listen("tcp", "127.0.0.1:0")
160+
require.NoError(t, err)
161+
defer func() {
162+
_ = listener.Close()
163+
}()
164+
165+
addr, ok := listener.Addr().(*net.TCPAddr)
166+
require.True(t, ok)
167+
return addr.Port
168+
}
169+
170+
func createSelfSignedTLSFiles(t *testing.T) (string, string) {
171+
t.Helper()
172+
173+
priv, err := rsa.GenerateKey(rand.Reader, 2048)
174+
require.NoError(t, err)
175+
176+
tmpl := x509.Certificate{
177+
SerialNumber: big.NewInt(1),
178+
Subject: pkix.Name{
179+
CommonName: "localhost",
180+
},
181+
NotBefore: time.Now().Add(-1 * time.Hour),
182+
NotAfter: time.Now().Add(24 * time.Hour),
183+
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
184+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
185+
BasicConstraintsValid: true,
186+
DNSNames: []string{"localhost"},
187+
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
188+
}
189+
190+
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
191+
require.NoError(t, err)
192+
193+
certFile, err := os.CreateTemp(t.TempDir(), "feast-test-cert-*.pem")
194+
require.NoError(t, err)
195+
defer func() {
196+
_ = certFile.Close()
197+
}()
198+
199+
keyFile, err := os.CreateTemp(t.TempDir(), "feast-test-key-*.pem")
200+
require.NoError(t, err)
201+
defer func() {
202+
_ = keyFile.Close()
203+
}()
204+
205+
err = pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: der})
206+
require.NoError(t, err)
207+
208+
err = pem.Encode(keyFile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
209+
require.NoError(t, err)
210+
211+
return certFile.Name(), keyFile.Name()
212+
}

0 commit comments

Comments
 (0)