Skip to content

Commit c50764b

Browse files
committed
feat: add agent self-update mechanism
1 parent 8374e3a commit c50764b

43 files changed

Lines changed: 8088 additions & 7 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.goreleaser.yaml

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,24 +19,40 @@ before:
1919
- make test-it
2020

2121
builds:
22-
- env:
22+
- id: hostlink
23+
env:
2324
- CGO_ENABLED=0
2425
goos:
2526
- linux
27+
goarch:
28+
- amd64
29+
- arm64
30+
ldflags:
31+
- -s -w -X hostlink/version.Version={{.Version}}
32+
33+
- id: hostlink-updater
34+
main: ./cmd/updater
35+
binary: hostlink-updater
36+
env:
37+
- CGO_ENABLED=0
38+
goos:
39+
- linux
40+
goarch:
41+
- amd64
42+
- arm64
2643
ldflags:
2744
- -s -w -X hostlink/version.Version={{.Version}}
2845

2946
archives:
30-
- formats: [tar.gz]
31-
# this name template makes the OS and Arch compatible with the results of `uname`.
47+
- id: hostlink-archive
48+
builds: [hostlink]
49+
formats: [tar.gz]
3250
name_template: >-
3351
{{ .ProjectName }}_
3452
{{- title .Os }}_
3553
{{- if eq .Arch "amd64" }}x86_64
36-
{{- else if eq .Arch "386" }}i386
3754
{{- else }}{{ .Arch }}{{ end }}
3855
{{- if .Arm }}v{{ .Arm }}{{ end }}
39-
# use zip for windows archives
4056
format_overrides:
4157
- goos: windows
4258
formats: [zip]
@@ -45,6 +61,18 @@ archives:
4561
dst: scripts
4662
strip_parent: true
4763

64+
- id: updater-archive
65+
builds: [hostlink-updater]
66+
formats: [tar.gz]
67+
name_template: >-
68+
hostlink-updater_
69+
{{- title .Os }}_
70+
{{- if eq .Arch "amd64" }}x86_64
71+
{{- else }}{{ .Arch }}{{ end }}
72+
{{- if .Arm }}v{{ .Arm }}{{ end }}
73+
files:
74+
- none*
75+
4876
changelog:
4977
sort: asc
5078
filters:

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- ALWAYS USE PARALLEL TASKS SUBAGENTS FOR CODE EXPLORATION, DEEP DIVES, AND SO ON
2+
- I use jj instead of git
3+
- ALWAYS FOLLOW TDD, red phase to green phase
4+
- Use ripgrep instead of grep, use fd instead of find
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
package selfupdatejob
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
"sync"
8+
"time"
9+
10+
log "github.com/sirupsen/logrus"
11+
12+
"hostlink/app/services/updatecheck"
13+
"hostlink/app/services/updatedownload"
14+
"hostlink/app/services/updatepreflight"
15+
"hostlink/internal/update"
16+
)
17+
18+
const (
19+
// defaultRequiredSpace is the fallback disk space requirement (50MB) when the
20+
// control plane does not provide download sizes.
21+
defaultRequiredSpace = 50 * 1024 * 1024
22+
)
23+
24+
// TriggerFunc is the function type for the job's scheduling strategy.
25+
type TriggerFunc func(context.Context, func() error)
26+
27+
// UpdateCheckerInterface abstracts the update check client.
28+
type UpdateCheckerInterface interface {
29+
Check(currentVersion string) (*updatecheck.UpdateInfo, error)
30+
}
31+
32+
// DownloaderInterface abstracts the download and verify functionality.
33+
type DownloaderInterface interface {
34+
DownloadAndVerify(ctx context.Context, url, destPath, sha256 string) (*updatedownload.DownloadResult, error)
35+
}
36+
37+
// PreflightCheckerInterface abstracts pre-flight checks.
38+
type PreflightCheckerInterface interface {
39+
Check(requiredSpace int64) *updatepreflight.PreflightResult
40+
}
41+
42+
// LockManagerInterface abstracts the lock manager.
43+
type LockManagerInterface interface {
44+
TryLockWithRetry(expiration time.Duration, retries int, interval time.Duration) error
45+
Unlock() error
46+
}
47+
48+
// StateWriterInterface abstracts the state writer.
49+
type StateWriterInterface interface {
50+
Write(data update.StateData) error
51+
}
52+
53+
// SpawnFunc is a function that spawns the updater binary.
54+
type SpawnFunc func(updaterPath string, args []string) error
55+
56+
// InstallUpdaterFunc is a function that extracts and installs the updater binary from a tarball.
57+
type InstallUpdaterFunc func(tarPath, destPath string) error
58+
59+
// SelfUpdateJobConfig holds the configuration for the SelfUpdateJob.
60+
type SelfUpdateJobConfig struct {
61+
Trigger TriggerFunc
62+
UpdateChecker UpdateCheckerInterface
63+
Downloader DownloaderInterface
64+
PreflightChecker PreflightCheckerInterface
65+
LockManager LockManagerInterface
66+
StateWriter StateWriterInterface
67+
Spawn SpawnFunc
68+
InstallUpdater InstallUpdaterFunc
69+
CurrentVersion string
70+
UpdaterPath string // Where to install the extracted updater binary
71+
StagingDir string // Where to download tarballs
72+
BaseDir string // Base update directory (for -base-dir flag to updater)
73+
}
74+
75+
// SelfUpdateJob periodically checks for and applies updates.
76+
type SelfUpdateJob struct {
77+
config SelfUpdateJobConfig
78+
cancel context.CancelFunc
79+
wg sync.WaitGroup
80+
}
81+
82+
// New creates a SelfUpdateJob with default configuration.
83+
func New() *SelfUpdateJob {
84+
return &SelfUpdateJob{
85+
config: SelfUpdateJobConfig{
86+
Trigger: Trigger,
87+
},
88+
}
89+
}
90+
91+
// NewWithConfig creates a SelfUpdateJob with the given configuration.
92+
func NewWithConfig(cfg SelfUpdateJobConfig) *SelfUpdateJob {
93+
if cfg.Trigger == nil {
94+
cfg.Trigger = Trigger
95+
}
96+
return &SelfUpdateJob{
97+
config: cfg,
98+
}
99+
}
100+
101+
// Register starts the job goroutine and returns a cancel function.
102+
func (j *SelfUpdateJob) Register(ctx context.Context) context.CancelFunc {
103+
ctx, cancel := context.WithCancel(ctx)
104+
j.cancel = cancel
105+
106+
j.wg.Add(1)
107+
go func() {
108+
defer j.wg.Done()
109+
j.config.Trigger(ctx, func() error {
110+
return j.runUpdate(ctx)
111+
})
112+
}()
113+
114+
return cancel
115+
}
116+
117+
// Shutdown cancels the job and waits for the goroutine to exit.
118+
func (j *SelfUpdateJob) Shutdown() {
119+
if j.cancel != nil {
120+
j.cancel()
121+
}
122+
j.wg.Wait()
123+
}
124+
125+
// runUpdate performs a single update check and apply cycle.
126+
func (j *SelfUpdateJob) runUpdate(ctx context.Context) error {
127+
// Step 1: Check for updates
128+
info, err := j.config.UpdateChecker.Check(j.config.CurrentVersion)
129+
if err != nil {
130+
return fmt.Errorf("update check failed: %w", err)
131+
}
132+
if !info.UpdateAvailable {
133+
return nil
134+
}
135+
136+
log.Infof("update available: %s -> %s", j.config.CurrentVersion, info.TargetVersion)
137+
138+
// Step 2: Pre-flight checks
139+
requiredSpace := info.AgentSize + info.UpdaterSize
140+
if requiredSpace == 0 {
141+
requiredSpace = defaultRequiredSpace
142+
}
143+
result := j.config.PreflightChecker.Check(requiredSpace)
144+
if !result.Passed {
145+
return fmt.Errorf("preflight checks failed: %v", result.Errors)
146+
}
147+
148+
// Step 3: Acquire lock
149+
if err := j.config.LockManager.TryLockWithRetry(5*time.Minute, 3, 5*time.Second); err != nil {
150+
return fmt.Errorf("failed to acquire update lock: %w", err)
151+
}
152+
153+
// From here on, we must release the lock on any failure
154+
locked := true
155+
defer func() {
156+
if locked {
157+
j.config.LockManager.Unlock()
158+
}
159+
}()
160+
161+
// Step 4: Write initialized state
162+
if err := j.config.StateWriter.Write(update.StateData{
163+
State: update.StateInitialized,
164+
SourceVersion: j.config.CurrentVersion,
165+
TargetVersion: info.TargetVersion,
166+
}); err != nil {
167+
return fmt.Errorf("failed to write initialized state: %w", err)
168+
}
169+
170+
if err := ctx.Err(); err != nil {
171+
return err
172+
}
173+
174+
// Step 5: Download agent tarball
175+
agentDest := filepath.Join(j.config.StagingDir, updatedownload.AgentTarballName)
176+
if _, err := j.config.Downloader.DownloadAndVerify(ctx, info.AgentURL, agentDest, info.AgentSHA256); err != nil {
177+
return fmt.Errorf("failed to download agent: %w", err)
178+
}
179+
180+
if err := ctx.Err(); err != nil {
181+
return err
182+
}
183+
184+
// Step 6: Download updater tarball
185+
updaterDest := filepath.Join(j.config.StagingDir, updatedownload.UpdaterTarballName)
186+
if _, err := j.config.Downloader.DownloadAndVerify(ctx, info.UpdaterURL, updaterDest, info.UpdaterSHA256); err != nil {
187+
return fmt.Errorf("failed to download updater: %w", err)
188+
}
189+
190+
if err := ctx.Err(); err != nil {
191+
return err
192+
}
193+
194+
// Step 7: Write staged state
195+
if err := j.config.StateWriter.Write(update.StateData{
196+
State: update.StateStaged,
197+
SourceVersion: j.config.CurrentVersion,
198+
TargetVersion: info.TargetVersion,
199+
}); err != nil {
200+
return fmt.Errorf("failed to write staged state: %w", err)
201+
}
202+
203+
if err := ctx.Err(); err != nil {
204+
return err
205+
}
206+
207+
// Step 8: Extract updater binary from tarball
208+
if err := j.config.InstallUpdater(updaterDest, j.config.UpdaterPath); err != nil {
209+
return fmt.Errorf("failed to install updater binary: %w", err)
210+
}
211+
212+
// Step 9: Release lock before spawning updater
213+
j.config.LockManager.Unlock()
214+
locked = false
215+
216+
// Step 10: Spawn updater in its own process group
217+
args := []string{"-version", info.TargetVersion, "-base-dir", j.config.BaseDir}
218+
if err := j.config.Spawn(j.config.UpdaterPath, args); err != nil {
219+
return fmt.Errorf("failed to spawn updater: %w", err)
220+
}
221+
222+
log.Infof("updater spawned for version %s", info.TargetVersion)
223+
return nil
224+
}

0 commit comments

Comments
 (0)