Skip to content

Commit 12a5765

Browse files
committed
feat(ha): wire opt-in production runtime
1 parent ff3e92f commit 12a5765

5 files changed

Lines changed: 240 additions & 2 deletions

File tree

server/cmd/fleetd/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/block/proto-fleet/server/internal/domain/telemetry/scheduler"
1616
"github.com/block/proto-fleet/server/internal/domain/token"
1717
"github.com/block/proto-fleet/server/internal/domain/updates"
18+
"github.com/block/proto-fleet/server/internal/ha"
1819
"github.com/block/proto-fleet/server/internal/infrastructure/db"
1920
"github.com/block/proto-fleet/server/internal/infrastructure/encrypt"
2021
"github.com/block/proto-fleet/server/internal/infrastructure/files"
@@ -60,6 +61,7 @@ type Config struct {
6061
Files files.Config `embed:"" prefix:"files-" envprefix:"FILES_"`
6162
FleetTelemetry fleet_telemetry.Config `embed:"" prefix:"fleet-telemetry-" envprefix:"FLEET_TELEMETRY_"`
6263
Metrics metrics.Config `embed:"" prefix:"metrics-" envprefix:"FLEET_ALERTS_"`
64+
HA ha.Config `embed:"" prefix:"ha-" envprefix:"FLEET_HA_"`
6365

6466
SystemMonitoring sysmon.Config `embed:"" prefix:"system-monitoring-" envprefix:"FLEET_SYSTEM_MONITORING_"`
6567
}

server/cmd/fleetd/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ var reflectEnabledServices = []string{
179179
}
180180

181181
func start(config *Config) error {
182+
if err := config.HA.Validate(); err != nil {
183+
return fmt.Errorf("invalid HA configuration: %w", err)
184+
}
182185
// Construct one configured registry before starting services. The CRUD
183186
// service uses it now; the Phase 5 reconciler will share this same instance.
184187
infrastructureDriverRegistry, err := infrastructureDomain.NewConfiguredDriverRegistry(config.Infrastructure)
@@ -668,11 +671,20 @@ func start(config *Config) error {
668671
if err != nil {
669672
return fmt.Errorf("create runtime job group: %w", err)
670673
}
671-
// HA configuration is not exposed yet, so production stays standalone.
672-
fleetRuntime, err := ha.NewStandaloneRuntime(runtimeJobGroup, executionService.IsRunning)
674+
fleetRuntime, closeHA, err := ha.NewConfiguredRuntime(
675+
config.HA,
676+
conn,
677+
runtimeJobGroup,
678+
executionService.IsRunning,
679+
)
673680
if err != nil {
674681
return fmt.Errorf("create Fleet runtime: %w", err)
675682
}
683+
defer func() {
684+
if err := closeHA(); err != nil {
685+
slog.Error("Failed to close HA services", "error", err)
686+
}
687+
}()
676688
defer func() {
677689
stopRuntimeJobGroup(runtimeJobGroup, executionService, shutdownTimeout)
678690
}()

server/cmd/fleetd/main_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,33 @@ encrypt:
121121
require.Equal(t, explicitDSN, config.DB.ExplicitDSN)
122122
}
123123

124+
func TestFleetdParsesHAEnabledFromEnv(t *testing.T) {
125+
t.Setenv("FLEET_HA_ENABLED", "true")
126+
t.Setenv("FLEET_HA_ETCD_ENDPOINTS", "https://10.0.0.1:2379,https://10.0.0.2:2379")
127+
128+
configPath := writeFleetdConfigFile(t, `
129+
auth:
130+
client:
131+
expiration-period: "1h"
132+
secret-key: "test-client-secret"
133+
miner-token-expiration-period: "30m"
134+
encrypt:
135+
service-master-key: "test-master-key"
136+
`)
137+
config := &Config{}
138+
parser, err := kong.New(
139+
config,
140+
kong.Name("fleetd"),
141+
kong.Configuration(kongyaml.Loader, configPath),
142+
)
143+
require.NoError(t, err)
144+
_, err = parser.Parse(nil)
145+
require.NoError(t, err)
146+
require.True(t, config.HA.Enabled)
147+
require.Equal(t, []string{"https://10.0.0.1:2379", "https://10.0.0.2:2379"}, config.HA.EtcdEndpoints)
148+
require.NoError(t, config.HA.Validate())
149+
}
150+
124151
func TestFleetdInfrastructureOTControlSubnetsFlag(t *testing.T) {
125152
t.Parallel()
126153

server/internal/ha/config.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package ha
2+
3+
import (
4+
"crypto/tls"
5+
"crypto/x509"
6+
"database/sql"
7+
"errors"
8+
"fmt"
9+
"net/http"
10+
"net/url"
11+
"os"
12+
"strings"
13+
"time"
14+
15+
clientv3 "go.etcd.io/etcd/client/v3"
16+
17+
"github.com/block/proto-fleet/server/internal/infrastructure/db"
18+
"github.com/block/proto-fleet/server/internal/runtimejobs"
19+
)
20+
21+
// Config keeps HA opt-in so existing single-instance deployments remain
22+
// standalone and do not need etcd or Patroni credentials.
23+
type Config struct {
24+
Enabled bool `help:"Enable active/passive Fleet runtime ownership." default:"false" env:"ENABLED"`
25+
ClusterPath string `help:"Patroni DCS cluster path." default:"/service/proto-fleet" env:"CLUSTER_PATH"`
26+
EtcdEndpoints []string `help:"Comma-separated HTTPS etcd endpoints." env:"ETCD_ENDPOINTS" sep:","`
27+
EtcdUsername string `help:"Read-only etcd observer username." default:"fleet-observer" env:"ETCD_USERNAME"`
28+
EtcdPasswordFile string `help:"Path to the etcd observer password file." default:"/etc/proto-fleet/ha/fleet-etcd-password" env:"ETCD_PASSWORD_FILE" type:"path"`
29+
ServiceCAFile string `help:"Path to the CA that signs etcd and Patroni service certificates." default:"/etc/proto-fleet/ha/service-ca.crt" env:"SERVICE_CA_FILE" type:"path"`
30+
LeaseDuration time.Duration `help:"Fleet active lease duration." default:"10s" env:"LEASE_DURATION"`
31+
RenewInterval time.Duration `help:"Fleet active lease renewal interval." default:"3s" env:"RENEW_INTERVAL"`
32+
RetryInterval time.Duration `help:"Passive ownership retry interval." default:"1s" env:"RETRY_INTERVAL"`
33+
DialTimeout time.Duration `help:"etcd connection timeout." default:"5s" env:"DIAL_TIMEOUT"`
34+
}
35+
36+
// NewConfiguredRuntime creates a standalone runtime unless HA is explicitly
37+
// enabled. In HA mode, cleanup closes the etcd client.
38+
func NewConfiguredRuntime(
39+
config Config,
40+
conn *sql.DB,
41+
group *runtimejobs.Group,
42+
healthy func() bool,
43+
) (*Runtime, func() error, error) {
44+
if !config.Enabled {
45+
runtime, err := NewStandaloneRuntime(group, healthy)
46+
return runtime, func() error { return nil }, err
47+
}
48+
if err := config.Validate(); err != nil {
49+
return nil, nil, err
50+
}
51+
52+
password, err := readRuntimeSecret(config.EtcdPasswordFile)
53+
if err != nil {
54+
return nil, nil, fmt.Errorf("read HA etcd password: %w", err)
55+
}
56+
tlsConfig, err := loadServiceTLS(config.ServiceCAFile)
57+
if err != nil {
58+
return nil, nil, err
59+
}
60+
etcd, err := NewEtcdClient(clientv3.Config{
61+
Endpoints: config.EtcdEndpoints,
62+
Username: config.EtcdUsername,
63+
Password: password,
64+
TLS: tlsConfig.Clone(),
65+
DialTimeout: config.DialTimeout,
66+
})
67+
if err != nil {
68+
return nil, nil, fmt.Errorf("create HA etcd client: %w", err)
69+
}
70+
71+
transport, ok := http.DefaultTransport.(*http.Transport)
72+
if !ok {
73+
_ = etcd.Close()
74+
return nil, nil, errors.New("default HTTP transport is not configurable for HA")
75+
}
76+
patroniTransport := transport.Clone()
77+
patroniTransport.TLSClientConfig = tlsConfig.Clone()
78+
cleanup := func() error {
79+
patroniTransport.CloseIdleConnections()
80+
return etcd.Close()
81+
}
82+
patroni := NewPatroniHTTPClient(&http.Client{
83+
Transport: patroniTransport,
84+
Timeout: defaultHAHTTPTimeout,
85+
})
86+
queries := db.NewFailoverResettingQuerier(db.NewRetryDB(conn))
87+
observer, err := NewObserver(config.ClusterPath, etcd, queries, patroni)
88+
if err != nil {
89+
_ = cleanup()
90+
return nil, nil, err
91+
}
92+
coordinator, err := NewCoordinator(observer, NewLeaseStore(queries), CoordinatorConfig{
93+
LeaseDuration: config.LeaseDuration,
94+
RenewInterval: config.RenewInterval,
95+
RetryInterval: config.RetryInterval,
96+
})
97+
if err != nil {
98+
_ = cleanup()
99+
return nil, nil, err
100+
}
101+
runtime, err := NewRuntime(coordinator, group, healthy)
102+
if err != nil {
103+
_ = cleanup()
104+
return nil, nil, err
105+
}
106+
return runtime, cleanup, nil
107+
}
108+
109+
func (config Config) Validate() error {
110+
if !config.Enabled {
111+
return nil
112+
}
113+
if config.ClusterPath == "" || len(config.EtcdEndpoints) == 0 || config.EtcdUsername == "" ||
114+
config.EtcdPasswordFile == "" || config.ServiceCAFile == "" || config.DialTimeout <= 0 {
115+
return errors.New("enabled HA requires cluster path, etcd endpoints and credentials, service CA, and a positive dial timeout")
116+
}
117+
for _, endpoint := range config.EtcdEndpoints {
118+
parsed, err := url.Parse(endpoint)
119+
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
120+
return fmt.Errorf("HA etcd endpoint must be an HTTPS URL: %q", endpoint)
121+
}
122+
}
123+
return nil
124+
}
125+
126+
func readRuntimeSecret(path string) (string, error) {
127+
contents, err := os.ReadFile(path)
128+
if err != nil {
129+
return "", fmt.Errorf("read secret file: %w", err)
130+
}
131+
secret := strings.TrimSpace(string(contents))
132+
if secret == "" {
133+
return "", errors.New("secret file is empty")
134+
}
135+
return secret, nil
136+
}
137+
138+
func loadServiceTLS(path string) (*tls.Config, error) {
139+
contents, err := os.ReadFile(path)
140+
if err != nil {
141+
return nil, fmt.Errorf("read HA service CA: %w", err)
142+
}
143+
roots := x509.NewCertPool()
144+
if !roots.AppendCertsFromPEM(contents) {
145+
return nil, errors.New("HA service CA contains no certificates")
146+
}
147+
return &tls.Config{
148+
MinVersion: tls.VersionTLS13,
149+
RootCAs: roots,
150+
}, nil
151+
}

server/internal/ha/config_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package ha
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
"time"
7+
8+
"github.com/block/proto-fleet/server/internal/runtimejobs"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestNewConfiguredRuntimeUsesStandaloneModeWithoutReadingHAFiles(t *testing.T) {
13+
group, err := runtimejobs.NewGroup(nil)
14+
require.NoError(t, err)
15+
16+
runtime, cleanup, err := NewConfiguredRuntime(Config{
17+
EtcdPasswordFile: filepath.Join(t.TempDir(), "missing-password"),
18+
ServiceCAFile: filepath.Join(t.TempDir(), "missing-ca"),
19+
}, nil, group, alwaysHealthy)
20+
require.NoError(t, err)
21+
require.NotNil(t, runtime)
22+
require.Nil(t, runtime.owner)
23+
require.NoError(t, cleanup())
24+
}
25+
26+
func TestHAConfigValidation(t *testing.T) {
27+
valid := Config{
28+
Enabled: true,
29+
ClusterPath: "/service/proto-fleet",
30+
EtcdEndpoints: []string{"https://ha-a:2379", "https://ha-b:2379"},
31+
EtcdUsername: "fleet-observer",
32+
EtcdPasswordFile: "/run/secrets/etcd-password",
33+
ServiceCAFile: "/etc/proto-fleet/ha/service-ca.crt",
34+
LeaseDuration: 10 * time.Second,
35+
RenewInterval: 3 * time.Second,
36+
RetryInterval: time.Second,
37+
DialTimeout: 5 * time.Second,
38+
}
39+
require.NoError(t, valid.Validate())
40+
41+
require.Error(t, (Config{Enabled: true}).Validate())
42+
43+
insecure := valid
44+
insecure.EtcdEndpoints = []string{"http://ha-a:2379"}
45+
require.ErrorContains(t, insecure.Validate(), "must be an HTTPS URL")
46+
}

0 commit comments

Comments
 (0)