Skip to content

Commit 6079acd

Browse files
committed
feat(ha): wire opt-in production runtime
1 parent 06e9753 commit 6079acd

5 files changed

Lines changed: 242 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: 15 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,21 @@ 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,
679+
executionService.IsRunning,
680+
)
673681
if err != nil {
674682
return fmt.Errorf("create Fleet runtime: %w", err)
675683
}
684+
defer func() {
685+
if err := closeHA(); err != nil {
686+
slog.Error("Failed to close HA services", "error", err)
687+
}
688+
}()
676689
defer func() {
677690
stopRuntimeJobGroup(runtimeJobGroup, executionService, shutdownTimeout)
678691
}()

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: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
reaper activationReaper,
43+
healthy func() bool,
44+
) (*Runtime, func() error, error) {
45+
if !config.Enabled {
46+
runtime, err := NewStandaloneRuntime(group, healthy)
47+
return runtime, func() error { return nil }, err
48+
}
49+
if err := config.Validate(); err != nil {
50+
return nil, nil, err
51+
}
52+
53+
password, err := readRuntimeSecret(config.EtcdPasswordFile)
54+
if err != nil {
55+
return nil, nil, fmt.Errorf("read HA etcd password: %w", err)
56+
}
57+
tlsConfig, err := loadServiceTLS(config.ServiceCAFile)
58+
if err != nil {
59+
return nil, nil, err
60+
}
61+
etcd, err := NewEtcdClient(clientv3.Config{
62+
Endpoints: config.EtcdEndpoints,
63+
Username: config.EtcdUsername,
64+
Password: password,
65+
TLS: tlsConfig.Clone(),
66+
DialTimeout: config.DialTimeout,
67+
})
68+
if err != nil {
69+
return nil, nil, fmt.Errorf("create HA etcd client: %w", err)
70+
}
71+
72+
transport, ok := http.DefaultTransport.(*http.Transport)
73+
if !ok {
74+
_ = etcd.Close()
75+
return nil, nil, errors.New("default HTTP transport is not configurable for HA")
76+
}
77+
patroniTransport := transport.Clone()
78+
patroniTransport.TLSClientConfig = tlsConfig.Clone()
79+
cleanup := func() error {
80+
patroniTransport.CloseIdleConnections()
81+
return etcd.Close()
82+
}
83+
patroni := NewPatroniHTTPClient(&http.Client{
84+
Transport: patroniTransport,
85+
Timeout: defaultHAHTTPTimeout,
86+
})
87+
queries := db.NewFailoverResettingQuerier(db.NewRetryDB(conn))
88+
observer, err := NewObserver(config.ClusterPath, etcd, queries, patroni)
89+
if err != nil {
90+
_ = cleanup()
91+
return nil, nil, err
92+
}
93+
coordinator, err := NewCoordinator(observer, NewLeaseStore(conn), CoordinatorConfig{
94+
LeaseDuration: config.LeaseDuration,
95+
RenewInterval: config.RenewInterval,
96+
RetryInterval: config.RetryInterval,
97+
})
98+
if err != nil {
99+
_ = cleanup()
100+
return nil, nil, err
101+
}
102+
runtime, err := NewRuntime(coordinator, group, reaper, healthy)
103+
if err != nil {
104+
_ = cleanup()
105+
return nil, nil, err
106+
}
107+
return runtime, cleanup, nil
108+
}
109+
110+
func (config Config) Validate() error {
111+
if !config.Enabled {
112+
return nil
113+
}
114+
if config.ClusterPath == "" || len(config.EtcdEndpoints) == 0 || config.EtcdUsername == "" ||
115+
config.EtcdPasswordFile == "" || config.ServiceCAFile == "" || config.DialTimeout <= 0 {
116+
return errors.New("enabled HA requires cluster path, etcd endpoints and credentials, service CA, and a positive dial timeout")
117+
}
118+
for _, endpoint := range config.EtcdEndpoints {
119+
parsed, err := url.Parse(endpoint)
120+
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
121+
return fmt.Errorf("HA etcd endpoint must be an HTTPS URL: %q", endpoint)
122+
}
123+
}
124+
return nil
125+
}
126+
127+
func readRuntimeSecret(path string) (string, error) {
128+
contents, err := os.ReadFile(path)
129+
if err != nil {
130+
return "", fmt.Errorf("read secret file: %w", err)
131+
}
132+
secret := strings.TrimSpace(string(contents))
133+
if secret == "" {
134+
return "", errors.New("secret file is empty")
135+
}
136+
return secret, nil
137+
}
138+
139+
func loadServiceTLS(path string) (*tls.Config, error) {
140+
contents, err := os.ReadFile(path)
141+
if err != nil {
142+
return nil, fmt.Errorf("read HA service CA: %w", err)
143+
}
144+
roots := x509.NewCertPool()
145+
if !roots.AppendCertsFromPEM(contents) {
146+
return nil, errors.New("HA service CA contains no certificates")
147+
}
148+
return &tls.Config{
149+
MinVersion: tls.VersionTLS13,
150+
RootCAs: roots,
151+
}, nil
152+
}

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, nil, 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)