Skip to content

Commit e6d7b4d

Browse files
Copilotbchogithub-code-quality[bot]
authored
Add blue-green AgentUpgrade for host daemon binary (#127)
* Initial plan * Implement AgentUpgrade blue green daemon flow Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/09199dde-d498-4fd1-a4d0-d878fdf19099 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Render daemon recovery asset paths Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/09199dde-d498-4fd1-a4d0-d878fdf19099 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address AgentUpgrade review feedback Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/c95ee777-56f9-4c29-9f15-326834a9cec4 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Apply validation review cleanups Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/c95ee777-56f9-4c29-9f15-326834a9cec4 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address sequential AgentUpgrade comments Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/7a8119d9-3cfb-4aa3-821d-921a7ea952cd Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Apply final AgentUpgrade review fixes Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/7a8119d9-3cfb-4aa3-821d-921a7ea952cd Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Use AgentUpgrade goal state Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/112cde94-4091-4914-b11f-82988ba56a14 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Clean up AgentUpgrade path resolution Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/112cde94-4091-4914-b11f-82988ba56a14 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Move daemon binary link bootstrap into agent Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/2b499ab5-34e0-4c2a-b02d-5b5eb6a55429 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Cover daemon binary bootstrap helpers Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/2b499ab5-34e0-4c2a-b02d-5b5eb6a55429 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Reject broken AgentUpgrade binaries before switch Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/08325264-b1c3-4e9d-b447-486a9efad21f Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address AgentUpgrade validation review cleanup Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/08325264-b1c3-4e9d-b447-486a9efad21f Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Publish AgentUpgrade daemon rollback failures Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/edc4c352-13dc-484e-bdea-4b161949f179 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address AgentUpgrade rollback review cleanup Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/edc4c352-13dc-484e-bdea-4b161949f179 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address AgentUpgrade utilio path review Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Clean up AgentUpgrade helper refactor Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address helper validation feedback Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Clarify symlink helper cleanup Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Move env fallback helper to utilio Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Align resolve symlink parameter naming Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address final helper review nits Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Cover empty env fallback helper Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Keep path resolution in goalstates Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/ac48a39d-eabb-4a09-b1ef-bb94b02a8f7c Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Merge main into AgentUpgrade branch Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/def5036d-70af-4f08-b578-a8983e5131b4 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Clean up MachineOperation merge resolution Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/def5036d-70af-4f08-b578-a8983e5131b4 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address AgentUpgrade signal and validation feedback Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/9efcbd8b-30d7-4b49-a22d-dcbf0bd0d8b7 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Harden AgentUpgrade review updates Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/9efcbd8b-30d7-4b49-a22d-dcbf0bd0d8b7 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Move recovery signal writing into agent command Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/d9377198-b9dc-41bd-9011-8d1f797f1b35 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Preserve pending signal on helper failure Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/d9377198-b9dc-41bd-9011-8d1f797f1b35 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Refactor AgentUpgrade signal handling Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/210b8bc3-61ce-49ce-bbd4-00b30cb5d811 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Document AgentUpgrade signal helpers Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/210b8bc3-61ce-49ce-bbd4-00b30cb5d811 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Simplify AgentUpgrade state handling Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/4b19be89-cdcd-4468-912d-c9434a0ae545 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Clarify AgentUpgrade helper names Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/4b19be89-cdcd-4468-912d-c9434a0ae545 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Document AgentUpgrade state machine Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/3c6d7854-c651-4217-9887-6ddef30c67c6 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Address AgentUpgrade review feedback Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/449c899b-b874-449d-b613-8c99409731d1 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Log ignored AgentUpgrade signals Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/80a53899-c9be-4e4c-97b2-72d1caf02b7e Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Remove blue-green install script checks Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/2ad3ccca-4671-4562-a84a-19317ddd0fbe Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Move daemon binary link setup to agentbinary Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/920acaad-c356-47e2-ba72-5f74a6615c80 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Clarify agentbinary test path setup Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/920acaad-c356-47e2-ba72-5f74a6615c80 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> * Consolidate AgentUpgrade signal publishing Agent-Logs-Url: https://github.com/Azure/unbounded/sessions/103e2b54-b156-48a4-879b-c4dafbee3331 Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bcho <1975118+bcho@users.noreply.github.com> Co-authored-by: hbc <bahe@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
1 parent 409be1a commit e6d7b4d

27 files changed

Lines changed: 2166 additions & 24 deletions

.github/workflows/agent-e2e-kind.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ jobs:
179179
- name: Validate node restart operation
180180
run: python3 ./hack/agent/e2e-kind/e2e.py --verbose validate-node-reboot-operation
181181

182+
- name: Validate agent upgrade operation
183+
run: python3 ./hack/agent/e2e-kind/e2e.py --verbose validate-agent-upgrade-operation
184+
185+
- name: Validate agent upgrade rollback
186+
run: python3 ./hack/agent/e2e-kind/e2e.py --verbose validate-agent-upgrade-rollback
187+
182188
- name: Validate workload on agent node
183189
run: python3 ./hack/agent/e2e-kind/e2e.py --verbose validate-workload
184190

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"github.com/spf13/cobra"
8+
9+
"github.com/Azure/unbounded/cmd/agent/internal/daemon"
10+
)
11+
12+
func newCmdRecordAgentUpgradeFailureSignal() *cobra.Command {
13+
var message string
14+
15+
cmd := &cobra.Command{
16+
Use: "record-agent-upgrade-failure-signal",
17+
Short: "Record AgentUpgrade daemon recovery failure signal",
18+
Hidden: true,
19+
Args: cobra.NoArgs,
20+
RunE: func(*cobra.Command, []string) error {
21+
return daemon.RecordAgentUpgradeFailureSignal(message)
22+
},
23+
}
24+
25+
cmd.Flags().StringVar(&message, "message", "", "failure message to record")
26+
27+
return cmd
28+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package cmd
5+
6+
import (
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/Azure/unbounded/pkg/agent/goalstates"
15+
)
16+
17+
func TestRecordAgentUpgradeFailureSignalCommand(t *testing.T) {
18+
dir := t.TempDir()
19+
signalPath := filepath.Join(dir, "agent-upgrade-signal")
20+
t.Setenv(goalstates.EnvDaemonAgentUpgradeSignalPath, signalPath)
21+
require.NoError(t, os.WriteFile(signalPath, []byte(`{"operationName":"op-1"}`+"\n"), 0o600))
22+
23+
cmd := newCmdRecordAgentUpgradeFailureSignal()
24+
cmd.SetArgs([]string{
25+
"--message", "rolled back to last good",
26+
})
27+
require.NoError(t, cmd.Execute())
28+
29+
data, err := os.ReadFile(signalPath)
30+
require.NoError(t, err)
31+
assert.JSONEq(t, `{"operationName":"op-1","failureMessage":"rolled back to last good"}`, string(data))
32+
}

cmd/agent/internal/cmd/cmd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func Run() {
2929
newCmdDaemon(cmdCtx),
3030
newCmdReset(cmdCtx),
3131
newCmdVersion(),
32+
newCmdRecordAgentUpgradeFailureSignal(),
3233
)
3334

3435
if err := root.Execute(); err != nil {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package daemon
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"log/slog"
12+
"os"
13+
"strings"
14+
15+
"github.com/Azure/unbounded/pkg/agent/agentbinary"
16+
"github.com/Azure/unbounded/pkg/agent/goalstates"
17+
)
18+
19+
const (
20+
agentUpgradeDownloadURLParameter = "downloadURL"
21+
agentUpgradeBinaryMode = 0o755
22+
)
23+
24+
// agentUpgradeSignal is the JSON payload for pending and failure signals.
25+
type agentUpgradeSignal struct {
26+
OperationName string `json:"operationName"`
27+
ObservedMachineGeneration int64 `json:"observedMachineGeneration,omitempty"`
28+
// FailureMessage is set only after recovery reports a failed upgraded daemon.
29+
FailureMessage string `json:"failureMessage,omitempty"`
30+
}
31+
32+
// agentUpgradeSignalOperator manages persistent AgentUpgrade signal files.
33+
type agentUpgradeSignalOperator interface {
34+
RecordPending(operationName string, observedMachineGeneration int64) error
35+
RecordFailure(message string) error
36+
Read() (*agentUpgradeSignal, error)
37+
Clear() error
38+
}
39+
40+
// fileAgentUpgradeSignalOperator stores AgentUpgrade signals on disk.
41+
type fileAgentUpgradeSignalOperator struct {
42+
path string
43+
}
44+
45+
func agentUpgradeDownloadURL(parameters map[string]string) (string, error) {
46+
downloadURL := strings.TrimSpace(parameters[agentUpgradeDownloadURLParameter])
47+
if downloadURL == "" {
48+
return "", fmt.Errorf("missing required parameter %q", agentUpgradeDownloadURLParameter)
49+
}
50+
51+
return downloadURL, nil
52+
}
53+
54+
func upgradeDaemonBinary(ctx context.Context, log *slog.Logger, downloadURL string) error {
55+
paths, err := goalstates.ResolvedAgentUpgradePaths()
56+
if err != nil {
57+
return fmt.Errorf("resolve current daemon binary symlink: %w", err)
58+
}
59+
targetPath := paths.NextTargetPath()
60+
if err := agentbinary.InstallAndSwitchFromTarGz(ctx, downloadURL, paths, agentUpgradeBinaryMode); err != nil {
61+
return err
62+
}
63+
64+
log.Info("staged upgraded daemon binary",
65+
"url", downloadURL,
66+
"previous", paths.CurrentTargetPath,
67+
"current", targetPath,
68+
)
69+
70+
return nil
71+
}
72+
73+
func newAgentUpgradeSignalOperator() (agentUpgradeSignalOperator, error) {
74+
paths, err := goalstates.ResolvedAgentUpgradePaths()
75+
if err != nil {
76+
return nil, fmt.Errorf("resolve AgentUpgrade signal path: %w", err)
77+
}
78+
79+
return newAgentUpgradeSignalOperatorForPath(paths.SignalPath), nil
80+
}
81+
82+
func newAgentUpgradeSignalOperatorForPath(path string) agentUpgradeSignalOperator {
83+
return fileAgentUpgradeSignalOperator{path: path}
84+
}
85+
86+
func (o fileAgentUpgradeSignalOperator) RecordPending(operationName string, observedMachineGeneration int64) error {
87+
return o.write(agentUpgradeSignal{
88+
OperationName: operationName,
89+
ObservedMachineGeneration: observedMachineGeneration,
90+
})
91+
}
92+
93+
func (o fileAgentUpgradeSignalOperator) RecordFailure(message string) error {
94+
pending, err := o.Read()
95+
if err != nil {
96+
return fmt.Errorf("read pending AgentUpgrade operation signal: %w", err)
97+
}
98+
if pending == nil {
99+
slog.Warn("no pending AgentUpgrade operation signal found; skipping failure signal", "path", o.path)
100+
return nil
101+
}
102+
103+
message = strings.TrimSpace(message)
104+
if message == "" {
105+
message = "AgentUpgrade daemon failed after switching binary"
106+
}
107+
108+
return o.write(agentUpgradeSignal{
109+
OperationName: pending.OperationName,
110+
FailureMessage: message,
111+
})
112+
}
113+
114+
func (o fileAgentUpgradeSignalOperator) Clear() error {
115+
if err := os.Remove(o.path); err != nil && !errors.Is(err, os.ErrNotExist) {
116+
return err
117+
}
118+
119+
return nil
120+
}
121+
122+
func (o fileAgentUpgradeSignalOperator) write(signal agentUpgradeSignal) error {
123+
data, err := json.Marshal(signal)
124+
if err != nil {
125+
return err
126+
}
127+
128+
return writeFile(o.path, append(data, '\n'), 0o600)
129+
}
130+
131+
func (o fileAgentUpgradeSignalOperator) Read() (*agentUpgradeSignal, error) {
132+
data, err := os.ReadFile(o.path)
133+
if err != nil {
134+
if errors.Is(err, os.ErrNotExist) {
135+
return nil, nil
136+
}
137+
138+
return nil, err
139+
}
140+
141+
var signal agentUpgradeSignal
142+
if err := json.Unmarshal(data, &signal); err != nil {
143+
return nil, fmt.Errorf("decode AgentUpgrade signal %s: %w", o.path, err)
144+
}
145+
signal.OperationName = strings.TrimSpace(signal.OperationName)
146+
signal.FailureMessage = strings.TrimSpace(signal.FailureMessage)
147+
if signal.OperationName == "" {
148+
slog.Warn("AgentUpgrade signal missing operation name; ignoring signal", "path", o.path)
149+
return nil, nil
150+
}
151+
152+
return &signal, nil
153+
}
154+
155+
// RecordAgentUpgradeFailureSignal records that the daemon failed after an
156+
// AgentUpgrade.
157+
func RecordAgentUpgradeFailureSignal(message string) error {
158+
signals, err := newAgentUpgradeSignalOperator()
159+
if err != nil {
160+
return err
161+
}
162+
163+
return signals.RecordFailure(message)
164+
}

0 commit comments

Comments
 (0)