Skip to content

Commit 20dd833

Browse files
committed
feat: implement update request file handling for privileged updates
1 parent d50e681 commit 20dd833

6 files changed

Lines changed: 219 additions & 65 deletions

File tree

internal/agentctl/commands.go

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,17 @@ const (
2727
serviceManagerSystemd = "systemd"
2828
serviceManagerLaunchd = "launchd"
2929

30-
linuxInstallDir = "/opt/noderax-agent"
31-
linuxBinaryPath = linuxInstallDir + "/noderax-agent"
32-
linuxSymlinkPath = "/usr/local/bin/noderax-agent"
33-
linuxPrivilegedUpdateHelperPath = "/usr/local/libexec/noderax-agent-self-update"
34-
linuxConfigPath = "/etc/noderax-agent/config.json"
35-
linuxStatePath = "/var/lib/noderax-agent/agent_identity.json"
36-
linuxServiceUnit = "/etc/systemd/system/noderax-agent.service"
37-
linuxServiceName = "noderax-agent.service"
38-
linuxServiceUser = "noderax"
39-
linuxServiceHome = "/var/lib/noderax-agent"
30+
linuxInstallDir = "/opt/noderax-agent"
31+
linuxBinaryPath = linuxInstallDir + "/noderax-agent"
32+
linuxSymlinkPath = "/usr/local/bin/noderax-agent"
33+
linuxPrivilegedUpdateHelperPath = "/usr/local/libexec/noderax-agent-self-update"
34+
linuxPrivilegedUpdateRequestPath = linuxServiceHome + "/update-request.json"
35+
linuxConfigPath = "/etc/noderax-agent/config.json"
36+
linuxStatePath = "/var/lib/noderax-agent/agent_identity.json"
37+
linuxServiceUnit = "/etc/systemd/system/noderax-agent.service"
38+
linuxServiceName = "noderax-agent.service"
39+
linuxServiceUser = "noderax"
40+
linuxServiceHome = "/var/lib/noderax-agent"
4041

4142
macOSInstallDir = "/usr/local/lib/noderax-agent"
4243
macOSBinaryPath = macOSInstallDir + "/noderax-agent"
@@ -1059,29 +1060,13 @@ func renderPrivilegedUpdateHelper(spec platformSpec) string {
10591060
return fmt.Sprintf(`#!/bin/sh
10601061
set -eu
10611062
1062-
usage() {
1063-
echo "usage: %s --target-version <version> --target-id <target-id> [--rollback]" >&2
1063+
if [ "$#" -ne 0 ]; then
1064+
echo "usage: %s" >&2
10641065
exit 64
1065-
}
1066-
1067-
if [ "$#" -ne 4 ] && [ "$#" -ne 5 ]; then
1068-
usage
1069-
fi
1070-
1071-
if [ "$1" != "--target-version" ] || [ -z "${2:-}" ] || [ "$3" != "--target-id" ] || [ -z "${4:-}" ]; then
1072-
usage
1073-
fi
1074-
1075-
if [ "$#" -eq 5 ] && [ "$5" != "--rollback" ]; then
1076-
usage
1077-
fi
1078-
1079-
if [ "$#" -eq 5 ]; then
1080-
exec %q update --target-version "$2" --target-id "$4" --rollback
10811066
fi
10821067
1083-
exec %q update --target-version "$2" --target-id "$4"
1084-
`, spec.PrivilegedUpdateHelperPath, spec.BinaryPath, spec.BinaryPath)
1068+
exec %q update --request-file %q
1069+
`, spec.PrivilegedUpdateHelperPath, spec.BinaryPath, linuxPrivilegedUpdateRequestPath)
10851070
}
10861071

10871072
func writeServiceUnit(path, content string) error {

internal/agentctl/summary_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,7 @@ func TestRenderPrivilegedUpdateHelperTargetsManagedBinary(t *testing.T) {
5757

5858
expectedSnippets := []string{
5959
"usage: " + linuxPrivilegedUpdateHelperPath,
60-
"exec \"" + linuxBinaryPath + "\" update --target-version \"$2\" --target-id \"$4\" --rollback",
61-
"exec \"" + linuxBinaryPath + "\" update --target-version \"$2\" --target-id \"$4\"",
60+
"exec \"" + linuxBinaryPath + "\" update --request-file \"" + linuxPrivilegedUpdateRequestPath + "\"",
6261
}
6362

6463
for _, snippet := range expectedSnippets {

internal/agentctl/update.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const (
3030
type updateOptions struct {
3131
TargetVersion string
3232
TargetID string
33+
RequestFile string
3334
Rollback bool
3435
ApplyNow bool
3536
}
@@ -108,6 +109,7 @@ func parseUpdateOptions(args []string) (updateOptions, error) {
108109
fs.SetOutput(io.Discard)
109110
fs.StringVar(&options.TargetVersion, "target-version", "", "")
110111
fs.StringVar(&options.TargetID, "target-id", "", "")
112+
fs.StringVar(&options.RequestFile, "request-file", "", "")
111113
fs.BoolVar(&options.Rollback, "rollback", false, "")
112114
fs.BoolVar(&options.ApplyNow, "apply-now", false, "")
113115

@@ -120,15 +122,60 @@ func parseUpdateOptions(args []string) (updateOptions, error) {
120122
strings.Join(fs.Args(), " "),
121123
)
122124
}
123-
if strings.TrimSpace(options.TargetVersion) == "" {
125+
options.TargetVersion = strings.TrimSpace(options.TargetVersion)
126+
options.TargetID = strings.TrimSpace(options.TargetID)
127+
options.RequestFile = strings.TrimSpace(options.RequestFile)
128+
129+
if options.RequestFile != "" {
130+
if options.TargetVersion != "" || options.TargetID != "" || options.Rollback {
131+
return updateOptions{}, fmt.Errorf(
132+
"update request-file cannot be combined with --target-version, --target-id, or --rollback",
133+
)
134+
}
135+
136+
requestOptions, err := consumeManagedUpdateRequest(options.RequestFile)
137+
if err != nil {
138+
return updateOptions{}, err
139+
}
140+
requestOptions.ApplyNow = options.ApplyNow
141+
return requestOptions, nil
142+
}
143+
144+
if options.TargetVersion == "" {
124145
return updateOptions{}, fmt.Errorf("update requires --target-version")
125146
}
126-
if strings.TrimSpace(options.TargetID) == "" {
147+
if options.TargetID == "" {
127148
return updateOptions{}, fmt.Errorf("update requires --target-id")
128149
}
129150

151+
return options, nil
152+
}
153+
154+
func consumeManagedUpdateRequest(path string) (updateOptions, error) {
155+
data, err := os.ReadFile(path)
156+
if err != nil {
157+
return updateOptions{}, fmt.Errorf("read update request file %s: %w", path, err)
158+
}
159+
if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
160+
return updateOptions{}, fmt.Errorf("remove update request file %s: %w", path, removeErr)
161+
}
162+
163+
var options updateOptions
164+
if err := json.Unmarshal(data, &options); err != nil {
165+
return updateOptions{}, fmt.Errorf("decode update request file %s: %w", path, err)
166+
}
167+
130168
options.TargetVersion = strings.TrimSpace(options.TargetVersion)
131169
options.TargetID = strings.TrimSpace(options.TargetID)
170+
options.RequestFile = ""
171+
options.ApplyNow = false
172+
173+
if options.TargetVersion == "" {
174+
return updateOptions{}, fmt.Errorf("update request file %s is missing targetVersion", path)
175+
}
176+
if options.TargetID == "" {
177+
return updateOptions{}, fmt.Errorf("update request file %s is missing targetId", path)
178+
}
132179

133180
return options, nil
134181
}
@@ -266,6 +313,9 @@ func (c CLI) applyManagedUpdate(
266313
return err
267314
}
268315
}
316+
if err := writePrivilegedUpdateHelper(spec); err != nil {
317+
return fmt.Errorf("refresh privileged update helper: %w", err)
318+
}
269319

270320
report(
271321
"restarting",

internal/agentctl/update_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package agentctl
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestParseUpdateOptionsConsumesRequestFile(t *testing.T) {
10+
t.Parallel()
11+
12+
requestPath := filepath.Join(t.TempDir(), "update-request.json")
13+
if err := os.WriteFile(
14+
requestPath,
15+
[]byte(`{"targetVersion":"1.0.1","targetId":"target-42","rollback":true}`),
16+
0o600,
17+
); err != nil {
18+
t.Fatalf("write request file: %v", err)
19+
}
20+
21+
options, err := parseUpdateOptions([]string{"--request-file", requestPath, "--apply-now"})
22+
if err != nil {
23+
t.Fatalf("parseUpdateOptions returned error: %v", err)
24+
}
25+
26+
if options.TargetVersion != "1.0.1" {
27+
t.Fatalf("target version mismatch: got %q", options.TargetVersion)
28+
}
29+
if options.TargetID != "target-42" {
30+
t.Fatalf("target id mismatch: got %q", options.TargetID)
31+
}
32+
if !options.Rollback {
33+
t.Fatal("expected rollback flag to be restored from request file")
34+
}
35+
if !options.ApplyNow {
36+
t.Fatal("expected apply-now to be preserved")
37+
}
38+
if _, err := os.Stat(requestPath); !os.IsNotExist(err) {
39+
t.Fatalf("expected request file to be consumed, stat err=%v", err)
40+
}
41+
}
42+
43+
func TestParseUpdateOptionsRejectsMixedRequestFileFlags(t *testing.T) {
44+
t.Parallel()
45+
46+
requestPath := filepath.Join(t.TempDir(), "update-request.json")
47+
if err := os.WriteFile(
48+
requestPath,
49+
[]byte(`{"targetVersion":"1.0.1","targetId":"target-42"}`),
50+
0o600,
51+
); err != nil {
52+
t.Fatalf("write request file: %v", err)
53+
}
54+
55+
if _, err := parseUpdateOptions([]string{
56+
"--request-file", requestPath,
57+
"--target-version", "1.0.2",
58+
}); err == nil {
59+
t.Fatal("expected mixed request-file flags to be rejected")
60+
}
61+
}

internal/tasks/executor.go

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"os"
1111
"os/exec"
12+
"path/filepath"
1213

1314
"runtime"
1415
"strings"
@@ -27,7 +28,8 @@ const (
2728
TaskTypePackageRemove = "packageRemove"
2829
TaskTypePackagePurge = "packagePurge"
2930

30-
linuxPrivilegedUpdateHelperPath = "/usr/local/libexec/noderax-agent-self-update"
31+
linuxPrivilegedUpdateHelperPath = "/usr/local/libexec/noderax-agent-self-update"
32+
linuxPrivilegedUpdateRequestPath = "/var/lib/noderax-agent/update-request.json"
3133
)
3234

3335
var (
@@ -125,12 +127,13 @@ func (r *execCommandRunner) Wait() error {
125127
}
126128

127129
type ShellExecutor struct {
128-
defaultTimeout time.Duration
129-
goos string
130-
lookPath func(string) (string, error)
131-
executablePath func() (string, error)
132-
fileExists func(string) bool
133-
newCommand func(context.Context, string, ...string) commandRunner
130+
defaultTimeout time.Duration
131+
goos string
132+
lookPath func(string) (string, error)
133+
executablePath func() (string, error)
134+
fileExists func(string) bool
135+
privilegedUpdateRequestPath string
136+
newCommand func(context.Context, string, ...string) commandRunner
134137
}
135138

136139
func NewShellExecutor(defaultTimeout time.Duration) *ShellExecutor {
@@ -143,7 +146,8 @@ func NewShellExecutor(defaultTimeout time.Duration) *ShellExecutor {
143146
_, err := os.Stat(path)
144147
return err == nil
145148
},
146-
newCommand: newExecCommandRunner,
149+
privilegedUpdateRequestPath: linuxPrivilegedUpdateRequestPath,
150+
newCommand: newExecCommandRunner,
147151
}
148152
}
149153

@@ -335,19 +339,17 @@ func (e *ShellExecutor) agentUpdateCommand(payload json.RawMessage) (commandSpec
335339
}
336340

337341
if e.goos == "linux" && e.fileExists(linuxPrivilegedUpdateHelperPath) {
338-
helperArgs := []string{
339-
"--target-version",
340-
targetVersion,
341-
"--target-id",
342-
targetID,
343-
}
344-
if parsed.Rollback {
345-
helperArgs = append(helperArgs, "--rollback")
342+
if err := writeManagedUpdateRequest(e.privilegedUpdateRequestPath, parsed); err != nil {
343+
return commandSpec{}, fmt.Errorf(
344+
"%w: write privileged update request: %v",
345+
ErrUnsupportedExecutionEnvironment,
346+
err,
347+
)
346348
}
347349

348350
commandName, commandArgs, err := e.wrapWithSudo(
349351
linuxPrivilegedUpdateHelperPath,
350-
helperArgs,
352+
nil,
351353
)
352354
if err != nil {
353355
return commandSpec{}, err
@@ -689,6 +691,47 @@ func formatCommandForLog(name string, args []string) string {
689691
return strings.Join(parts, " ")
690692
}
691693

694+
func writeManagedUpdateRequest(path string, payload agentUpdatePayload) error {
695+
cleanPath := filepath.Clean(strings.TrimSpace(path))
696+
if cleanPath == "" {
697+
return fmt.Errorf("request path is empty")
698+
}
699+
700+
if err := os.MkdirAll(filepath.Dir(cleanPath), 0o755); err != nil {
701+
return fmt.Errorf("create update request directory: %w", err)
702+
}
703+
704+
file, err := os.CreateTemp(filepath.Dir(cleanPath), ".noderax-agent-update-request-*.json")
705+
if err != nil {
706+
return fmt.Errorf("create update request file: %w", err)
707+
}
708+
709+
tempPath := file.Name()
710+
encoder := json.NewEncoder(file)
711+
encoder.SetEscapeHTML(false)
712+
if err := encoder.Encode(payload); err != nil {
713+
file.Close()
714+
_ = os.Remove(tempPath)
715+
return fmt.Errorf("write update request file: %w", err)
716+
}
717+
if err := file.Chmod(0o600); err != nil {
718+
file.Close()
719+
_ = os.Remove(tempPath)
720+
return fmt.Errorf("chmod update request file: %w", err)
721+
}
722+
if err := file.Close(); err != nil {
723+
_ = os.Remove(tempPath)
724+
return fmt.Errorf("close update request file: %w", err)
725+
}
726+
727+
if err := os.Rename(tempPath, cleanPath); err != nil {
728+
_ = os.Remove(tempPath)
729+
return fmt.Errorf("replace update request file: %w", err)
730+
}
731+
732+
return nil
733+
}
734+
692735
func strconvQuote(value string) string {
693736
escaped := strings.ReplaceAll(value, "\\", "\\\\")
694737
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")

0 commit comments

Comments
 (0)