Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ccbfec1
feat(ha): complete updates through failover
ankitgoswami Aug 7, 2026
346c8a2
Address complete update review feedback (#891)
ankitgoswami Aug 7, 2026
2b52af0
Persist interrupted HA recovery intent (#891)
ankitgoswami Aug 7, 2026
023c79f
Preserve passive update outcome coverage
ankitgoswami Aug 7, 2026
d451425
Keep HA updater available after recovery failure (#891)
ankitgoswami Aug 8, 2026
ffc1f2b
Recover HA updates without recreating live services (#891)
ankitgoswami Aug 8, 2026
0e64314
Converge interrupted HA updates safely
ankitgoswami Aug 8, 2026
e3a203f
Retry interrupted HA recovery on startup
ankitgoswami Aug 8, 2026
783a11f
Simplify HA update recovery
ankitgoswami Aug 8, 2026
5d413b4
Restore HA lifecycle command types
ankitgoswami Aug 8, 2026
1c610d7
Separate HA takeover deadline from SLO
ankitgoswami Aug 8, 2026
c7e9271
Repair update layout before HA startup
ankitgoswami Aug 8, 2026
5d01df9
Reconcile updater handoff before startup repair
ankitgoswami Aug 8, 2026
b1c16da
Persist pending HA application recovery
ankitgoswami Aug 8, 2026
95c84e0
Always recreate both HA application services
ankitgoswami Aug 8, 2026
c1fc3c8
Avoid duplicating HA migration contract
ankitgoswami Aug 8, 2026
54ff830
Preserve simplified HA stop contract
ankitgoswami Aug 8, 2026
d6c0fac
Recheck HA peer before active shutdown
ankitgoswami Aug 8, 2026
1c5f848
Bound HA completion outage timing
ankitgoswami Aug 8, 2026
0a9e278
Harden HA completion retries
ankitgoswami Aug 8, 2026
26bc5a4
Stop replaying completed HA failures
ankitgoswami Aug 8, 2026
a2fa1e9
Finish role-aware HA stop validation
ankitgoswami Aug 8, 2026
10902d9
Keep HA active shutdown within its hard deadline
ankitgoswami Aug 8, 2026
69e5ab0
Remove redundant HA update helpers
ankitgoswami Aug 8, 2026
a624b08
Require final HA update readiness
ankitgoswami Aug 8, 2026
872bbab
Start takeover timeout after shutdown
ankitgoswami Aug 8, 2026
e8d6949
Require complete update readiness
ankitgoswami Aug 8, 2026
ad3a177
Lock updater startup repair
ankitgoswami Aug 8, 2026
adb86e4
Preserve active update recovery behavior
ankitgoswami Aug 8, 2026
1211614
Add deterministic HA update qualification barrier
ankitgoswami Aug 8, 2026
aa8cba6
Reuse VIP probe connections during takeover
ankitgoswami Aug 8, 2026
02cb1e3
Keep HA role probes outside stop deadline
ankitgoswami Aug 8, 2026
0e97567
Integrate updater handoff recovery with startup repair
ankitgoswami Aug 8, 2026
1393c7d
Make HA update recovery retry-safe
ankitgoswami Aug 8, 2026
cfbefbb
Align HA recovery tests with startup repair
ankitgoswami Aug 8, 2026
1fc1887
Keep startup repair ownership singular
ankitgoswami Aug 8, 2026
2696227
Test recovery after HA substrate startup
ankitgoswami Aug 8, 2026
ab2f654
Retry failed HA rollback after restart
ankitgoswami Aug 8, 2026
ebfc10b
Retry failed activation recovery on startup
ankitgoswami Aug 8, 2026
1f7f24e
Keep HA application stopped during takeover
ankitgoswami Aug 8, 2026
1037548
Persist HA recovery before clearing marker
ankitgoswami Aug 8, 2026
8354d37
Make HA update qualification crash windows deterministic
ankitgoswami Aug 8, 2026
59cbc32
Align HA completion with local status API
ankitgoswami Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions deployment-files/ha/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,43 @@ The Docker repository setup follows the official instructions for
[Ubuntu](https://docs.docker.com/engine/install/ubuntu/), and
[64-bit Raspberry Pi OS](https://docs.docker.com/engine/install/raspberry-pi-os/).

## Update a passive Fleet host

HA disables application-triggered updates. On the passive database host, run:

```bash
sudo /opt/proto-fleet/deployment/ha/fleet-ha update v0.2.11
```

The local updater downloads the release from the fixed Proto Fleet GitHub
release origin, verifies its SHA256 checksum, builds and persists the staged
Fleet images, then rechecks that this host is passive. It stops and replaces
only `fleet-api` and `fleet-client`; etcd, Patroni, PostgreSQL, and keepalived
remain running. The command returns only after the target version is healthy
and passive.

After the peer is confirmed on the target release, complete the update from
the old active host:

```bash
sudo /opt/proto-fleet/deployment/ha/fleet-ha update v0.2.11 --complete
```

If the updated host has already become active, the old host is now passive.
Run the ordinary `fleet-ha update v0.2.11` command on that passive host instead.

The source release must already contain this HA update protocol. HA is being
introduced for new deployments, so an older experimental HA installation that
predates `update --complete` must be reinstalled at the supported baseline
rather than upgraded through this workflow.

The updater stages everything first, stops the local Fleet containers, and
waits for the updated peer to serve the VIP with the target version. Only then
does it swap and restart the local application as passive. If takeover does
not complete within 35 seconds, it restarts the old local release without
swapping. Qualification still requires a healthy takeover in less than 15
seconds. This is a bounded interruption, not a zero-downtime update.

## Qualification

The distributions above are installer-compatible targets. The HA profile is
Expand Down
56 changes: 42 additions & 14 deletions server/cmd/fleet-ha/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/google/uuid"

"github.com/block/proto-fleet/server/internal/ha"
"github.com/block/proto-fleet/server/internal/ha/deployment"
"github.com/block/proto-fleet/server/internal/updaterapi"
)
Expand Down Expand Up @@ -91,7 +92,7 @@ func run(ctx context.Context, args []string) error {
case "install":
return runInstall(ctx, args[1:])
case "update":
return runPassiveUpdate(ctx, args[1:], os.Stdout, deployment.ValidatePassiveUpdate, updaterapi.NewClient(defaultUpdaterSocket), deployment.Status)
return runPassiveUpdate(ctx, args[1:], os.Stdout, validateHAUpdate, updaterapi.NewClient(defaultUpdaterSocket), deployment.Status)
Comment thread
ankitgoswami marked this conversation as resolved.
case "start":
return runStart(ctx, args[1:])
case "stop":
Expand All @@ -104,6 +105,12 @@ func run(ctx context.Context, args []string) error {
return errors.New("usage: fleet-ha require-passive NODE_ENV VERSION")
}
return deployment.ValidatePassiveUpdate(ctx, args[1], args[2])
case "require-active":
if len(args) != 3 {
return errors.New("usage: fleet-ha require-active NODE_ENV VERSION")
}
_, err := deployment.ValidateActiveUpdate(ctx, args[1], args[2])
return err
case "update-preflight":
if len(args) != 1 {
return errors.New("usage: fleet-ha update-preflight")
Expand All @@ -114,24 +121,30 @@ func run(ctx context.Context, args []string) error {
}
return deployment.PrepareApplicationUpdate(ctx, root)
case "app-stop":
if len(args) != 1 {
return errors.New("usage: fleet-ha app-stop")
if len(args) != 2 || (args[1] != "passive" && args[1] != "active") {
return errors.New("usage: fleet-ha app-stop <passive|active>")
}
root, err := deployment.ReleaseRoot()
if err != nil {
return err
}
return deployment.StopApplication(ctx, root)
return deployment.StopApplication(ctx, root, ha.RuntimeRole(args[1]))
case "app-start":
if len(args) != 2 && (len(args) != 3 || (args[2] != "passive" && args[2] != "any")) {
return errors.New("usage: fleet-ha app-start VERSION <passive|any>")
if len(args) != 2 && (len(args) != 3 || (args[2] != "passive" && args[2] != "complete" && args[2] != "any")) {
return errors.New("usage: fleet-ha app-start VERSION <passive|complete|any>")
}
root, err := deployment.ReleaseRoot()
if err != nil {
return err
}
requirePassive := len(args) == 2 || args[2] == "passive"
return deployment.StartApplication(ctx, root, args[1], requirePassive)
requirePassive := len(args) == 2 || args[2] != "any"
requireFailoverReady := len(args) == 3 && args[2] == "complete"
return deployment.StartApplication(ctx, root, args[1], requirePassive, requireFailoverReady)
case "wait-takeover":
if len(args) != 2 {
return errors.New("usage: fleet-ha wait-takeover VERSION")
}
return deployment.WaitForVIPVersion(ctx, installedNodeEnv, args[1])
default:
return usageError()
}
Expand All @@ -149,6 +162,7 @@ const (
type updaterClient interface {
Status(ctx context.Context) (updaterapi.StatusResponse, error)
Trigger(ctx context.Context, operationID, targetVersion string) (updaterapi.Operation, error)
TriggerComplete(ctx context.Context, operationID, targetVersion string) (updaterapi.Operation, error)
}

const (
Expand All @@ -158,7 +172,15 @@ const (

type updateTrigger func(context.Context, string, string) (updaterapi.Operation, error)

type updatePreflight func(context.Context, string, string) error
type updatePreflight func(context.Context, string, string, bool) error

func validateHAUpdate(ctx context.Context, envPath, targetVersion string, complete bool) error {
if complete {
_, err := deployment.ValidateActiveUpdate(ctx, envPath, targetVersion)
return err
}
return deployment.ValidatePassiveUpdate(ctx, envPath, targetVersion)
}

func runPassiveUpdate(
ctx context.Context,
Expand All @@ -168,6 +190,7 @@ func runPassiveUpdate(
client updaterClient,
read statusReader,
) error {
complete := len(args) == 2 && args[1] == "--complete"
if err := runUpdate(ctx, args, output, preflight, client); err != nil {
return err
}
Expand All @@ -178,7 +201,7 @@ func runPassiveUpdate(
if report.Control != nil && report.Control.FailoverReady {
return nil
}
if deployment.ExpectedRollingVersionMismatch(report.Control) {
if !complete && deployment.ExpectedRollingVersionMismatch(report.Control) {
_, err = fmt.Fprintln(output, "Update succeeded; failover readiness will recover after the peer is updated.")
if err != nil {
return fmt.Errorf("write update outcome: %w", err)
Expand All @@ -198,14 +221,19 @@ func runUpdate(
preflight updatePreflight,
client updaterClient,
) error {
if len(args) != 1 {
return errors.New("usage: fleet-ha update VERSION")
complete := len(args) == 2 && args[1] == "--complete"
if len(args) != 1 && !complete {
return errors.New("usage: fleet-ha update VERSION [--complete]")
}
if err := preflight(ctx, installedNodeEnv, args[0]); err != nil {
if err := preflight(ctx, installedNodeEnv, args[0], complete); err != nil {
return err
}
operationID := uuid.NewString()
operation, err := triggerUpdate(ctx, operationID, args[0], client.Trigger)
trigger := updateTrigger(client.Trigger)
if complete {
trigger = client.TriggerComplete
}
operation, err := triggerUpdate(ctx, operationID, args[0], trigger)
if err != nil {
return err
}
Expand Down
68 changes: 62 additions & 6 deletions server/cmd/fleet-ha/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,20 @@ import (

type fakeUpdaterClient struct {
triggered bool
complete bool
triggerErr error
operation updaterapi.Operation
}

func (f *fakeUpdaterClient) TriggerComplete(_ context.Context, operationID, targetVersion string) (updaterapi.Operation, error) {
f.triggered = true
f.complete = true
if f.triggerErr != nil {
return updaterapi.Operation{}, f.triggerErr
}
return updaterapi.Operation{ID: operationID, TargetVersion: targetVersion, Phase: updaterapi.PhaseSucceeded}, nil
}

func (f *fakeUpdaterClient) Status(context.Context) (updaterapi.StatusResponse, error) {
return updaterapi.StatusResponse{}, nil
}
Expand Down Expand Up @@ -79,7 +89,7 @@ func TestUpdateRequiresPassiveBeforeTriggering(t *testing.T) {
client := &fakeUpdaterClient{}

// Act
err := runUpdate(t.Context(), []string{"v1.2.3"}, &bytes.Buffer{}, func(context.Context, string, string) error {
err := runUpdate(t.Context(), []string{"v1.2.3"}, &bytes.Buffer{}, func(context.Context, string, string, bool) error {
return errors.New("local Fleet is active")
}, client)

Expand All @@ -98,7 +108,7 @@ func TestUpdateReportsTerminalSuccess(t *testing.T) {
var output bytes.Buffer

// Act
err := runUpdate(t.Context(), []string{"v1.2.3"}, &output, func(context.Context, string, string) error { return nil }, client)
err := runUpdate(t.Context(), []string{"v1.2.3"}, &output, func(context.Context, string, string, bool) error { return nil }, client)

// Assert
require.NoError(t, err)
Expand All @@ -120,7 +130,7 @@ func TestPassiveUpdateReportsDegradedFailoverReadiness(t *testing.T) {
}

// Act
err := runPassiveUpdate(t.Context(), []string{"v1.2.3"}, &output, func(context.Context, string, string) error { return nil }, client, read)
err := runPassiveUpdate(t.Context(), []string{"v1.2.3"}, &output, func(context.Context, string, string, bool) error { return nil }, client, read)

// Assert
require.ErrorContains(t, err, "failover readiness is degraded")
Expand All @@ -138,18 +148,35 @@ func TestPassiveUpdateAllowsExpectedVersionMismatch(t *testing.T) {
}

// Act
err := runPassiveUpdate(t.Context(), []string{"v1.2.3"}, &bytes.Buffer{}, func(context.Context, string, string) error { return nil }, client, read)
err := runPassiveUpdate(t.Context(), []string{"v1.2.3"}, &bytes.Buffer{}, func(context.Context, string, string, bool) error { return nil }, client, read)

// Assert
require.NoError(t, err)
}

func TestCompleteUpdateRejectsExpectedVersionMismatch(t *testing.T) {
// Arrange
client := &fakeUpdaterClient{}
read := func(context.Context, string) (deployment.StatusReport, error) {
return deployment.StatusReport{Control: &deployment.ControlStatus{
ControlReady: true,
ReasonCodes: []deployment.ControlReasonCode{deployment.ReasonFleetVersionMismatch},
}}, nil
}

// Act
err := runPassiveUpdate(t.Context(), []string{"v1.2.3", "--complete"}, &bytes.Buffer{}, func(context.Context, string, string, bool) error { return nil }, client, read)

// Assert
require.ErrorContains(t, err, "failover readiness is degraded")
}

func TestUpdateReturnsWhenUpdaterIsUnavailable(t *testing.T) {
// Arrange
client := &fakeUpdaterClient{triggerErr: updaterapi.ErrUnavailable}

// Act
err := runUpdate(t.Context(), []string{"v1.2.3"}, &bytes.Buffer{}, func(context.Context, string, string) error { return nil }, client)
err := runUpdate(t.Context(), []string{"v1.2.3"}, &bytes.Buffer{}, func(context.Context, string, string, bool) error { return nil }, client)

// Assert
require.ErrorIs(t, err, updaterapi.ErrUnavailable)
Expand All @@ -164,10 +191,39 @@ func TestUpdateFailureIncludesRecoveryDetails(t *testing.T) {
var output bytes.Buffer

// Act
err := runUpdate(t.Context(), []string{"v1.2.3"}, &output, func(context.Context, string, string) error { return nil }, client)
err := runUpdate(t.Context(), []string{"v1.2.3"}, &output, func(context.Context, string, string, bool) error { return nil }, client)

// Assert
require.ErrorContains(t, err, "Recovery: fleet-ha app-start v1.2.3")
require.ErrorContains(t, err, "Log: /var/log/proto-fleet-updater/update.log")
require.Contains(t, output.String(), "Update operation")
}

func TestCompleteUpdateRequiresActiveAndUsesCompletionRequest(t *testing.T) {
// Arrange
client := &fakeUpdaterClient{}
activeChecked := false

// Act
err := runUpdate(
t.Context(), []string{"v1.2.3", "--complete"}, &bytes.Buffer{},
func(_ context.Context, _, _ string, complete bool) error {
activeChecked = complete
return nil
},
client,
)

// Assert
require.NoError(t, err)
require.True(t, activeChecked)
require.True(t, client.complete)
}

func TestAppStartAcceptsPreviousUpdaterInvocation(t *testing.T) {
// Act
err := run(t.Context(), []string{"app-start", "v1.2.3"})

// Assert
require.ErrorContains(t, err, "packaged release")
}
43 changes: 43 additions & 0 deletions server/internal/domain/updates/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,31 @@ func TestUnixExecutorClientTrigger(t *testing.T) {
assert.NoError(t, observation.decodeErr)
}

func TestUnixExecutorClientTriggerComplete(t *testing.T) {
// Arrange
operationID := "11111111-1111-4111-8111-111111111111"
observed := make(chan executorRequestObservation, 1)
client := startExecutorTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request updaterapi.TriggerRequest
decodeErr := json.NewDecoder(r.Body).Decode(&request)
observed <- executorRequestObservation{trigger: request, decodeErr: decodeErr}
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(updaterapi.TriggerResponse{Operation: updaterapi.Operation{
ID: operationID, TargetVersion: "v1.2.3", Complete: true, Phase: updaterapi.PhaseQueued,
}})
}))

// Act
operation, err := client.TriggerComplete(t.Context(), operationID, "v1.2.3")

// Assert
require.NoError(t, err)
require.Equal(t, operationID, operation.ID)
observation := <-observed
require.NoError(t, observation.decodeErr)
require.True(t, observation.trigger.Complete)
}

func TestUnixExecutorClientHTTPFailures(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -209,6 +234,24 @@ func TestUnixExecutorClientRejectsMismatchedTriggerIdentity(t *testing.T) {
assert.ErrorAs(t, err, &protocolErr)
}

func TestUnixExecutorClientRejectsMismatchedCompletionMode(t *testing.T) {
// Arrange
client := startExecutorTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(updaterapi.TriggerResponse{Operation: updaterapi.Operation{
ID: "11111111-1111-4111-8111-111111111111", TargetVersion: "v1.2.3",
}})
}))

// Act
_, err := client.TriggerComplete(t.Context(), "11111111-1111-4111-8111-111111111111", "v1.2.3")

// Assert
require.Error(t, err)
var protocolErr *updaterapi.ProtocolError
assert.ErrorAs(t, err, &protocolErr)
}

func TestUnixExecutorClientUnavailableSocket(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading