80b27101
feat(agent): the self-updater reads a release tarball
a73x 2026-08-07 15:12
Commit message
internal/agent/selfupdate/selfupdate.go
| Old | New | ||
|---|---|---|---|
| @@ -3,21 +3,35 @@ | |||
| 3 | // retry-safe: a failure leaves the current binary running and untouched, and | 3 | // retry-safe: a failure leaves the current binary running and untouched, and |
| 4 | // the next snapshot carrying the offer tries again. The previous binary is | 4 | // the next snapshot carrying the offer tries again. The previous binary is |
| 5 | // kept beside the new one as "<exe>.prev" for manual recovery. | 5 | // kept beside the new one as "<exe>.prev" for manual recovery. |
| 6 | // | ||
| 7 | // The artifact a manifest names is either a bare binary or a release tarball | ||
| 8 | // carrying the agent as <bundle-dir>/eitri-agent. Which one it is comes from | ||
| 9 | // the bytes — gzip magic — never from the URL's suffix, which a mirror or a | ||
| 10 | // redirect is free to dress up however it likes. | ||
| 6 | package selfupdate | 11 | package selfupdate |
| 7 | 12 | ||
| 8 | import ( | 13 | import ( |
| 14 | "archive/tar" | ||
| 15 | "compress/gzip" | ||
| 9 | "context" | 16 | "context" |
| 10 | "crypto/sha256" | 17 | "crypto/sha256" |
| 11 | "encoding/hex" | 18 | "encoding/hex" |
| 19 | "errors" | ||
| 12 | "fmt" | 20 | "fmt" |
| 13 | "io" | 21 | "io" |
| 14 | "net/http" | 22 | "net/http" |
| 15 | "os" | 23 | "os" |
| 24 | "path" | ||
| 16 | "path/filepath" | 25 | "path/filepath" |
| 26 | "strings" | ||
| 17 | "syscall" | 27 | "syscall" |
| 18 | "time" | 28 | "time" |
| 19 | ) | 29 | ) |
| 20 | 30 | ||
| 31 | // agentMember is the file a release tarball carries the agent as. Bundles lay | ||
| 32 | // it out under a versioned directory, so the member is matched on basename. | ||
| 33 | const agentMember = "eitri-agent" | ||
| 34 | |||
| 21 | // Update names the target binary: its version (for logging), artifact URL, | 35 | // Update names the target binary: its version (for logging), artifact URL, |
| 22 | // and expected sha256 (hex). | 36 | // and expected sha256 (hex). |
| 23 | type Update struct { | 37 | type Update struct { |
| @@ -43,6 +57,13 @@ func (a *Applier) httpClient() *http.Client { | |||
| 43 | return &http.Client{Timeout: 10 * time.Minute} | 57 | return &http.Client{Timeout: 10 * time.Minute} |
| 44 | } | 58 | } |
| 45 | 59 | ||
| 60 | func (a *Applier) execFn() func(argv0 string, argv, env []string) error { | ||
| 61 | if a.Exec != nil { | ||
| 62 | return a.Exec | ||
| 63 | } | ||
| 64 | return syscall.Exec | ||
| 65 | } | ||
| 66 | |||
| 46 | // Apply downloads, verifies, swaps, and re-execs. On any error before the | 67 | // Apply downloads, verifies, swaps, and re-execs. On any error before the |
| 47 | // final rename the running binary is untouched; after the rename the process | 68 | // final rename the running binary is untouched; after the rename the process |
| 48 | // re-execs (or returns the exec error — at that point <exe> is already the | 69 | // re-execs (or returns the exec error — at that point <exe> is already the |
| @@ -85,11 +106,7 @@ func (a *Applier) Apply(ctx context.Context, u Update) error { | |||
| 85 | // the actual previous-version recovery copy for no reason. | 106 | // the actual previous-version recovery copy for no reason. |
| 86 | if u.SHA256 != "" { | 107 | if u.SHA256 != "" { |
| 87 | if sum, sumErr := sha256File(exe); sumErr == nil && sum == u.SHA256 { | 108 | if sum, sumErr := sha256File(exe); sumErr == nil && sum == u.SHA256 { |
| 88 | execFn := a.Exec | 109 | return a.execFn()(exe, os.Args, os.Environ()) |
| 89 | if execFn == nil { | ||
| 90 | execFn = syscall.Exec | ||
| 91 | } | ||
| 92 | return execFn(exe, os.Args, os.Environ()) | ||
| 93 | } | 110 | } |
| 94 | } | 111 | } |
| 95 | 112 | ||
| @@ -136,7 +153,32 @@ func (a *Applier) Apply(ctx context.Context, u Update) error { | |||
| 136 | if got := hex.EncodeToString(h.Sum(nil)); got != u.SHA256 { | 153 | if got := hex.EncodeToString(h.Sum(nil)); got != u.SHA256 { |
| 137 | return fmt.Errorf("sha256 mismatch for %s: got %s want %s", u.URL, got, u.SHA256) | 154 | return fmt.Errorf("sha256 mismatch for %s: got %s want %s", u.URL, got, u.SHA256) |
| 138 | } | 155 | } |
| 139 | if err := os.Chmod(tmp.Name(), 0o755); err != nil { | 156 | |
| 157 | // The manifest's sha covers the artifact as served, so it is checked above | ||
| 158 | // over the whole download — a tarball is unpacked only once its bytes are | ||
| 159 | // known to be the ones the server named. | ||
| 160 | src := tmp.Name() | ||
| 161 | gzipped, err := isGzip(src) | ||
| 162 | if err != nil { | ||
| 163 | return err | ||
| 164 | } | ||
| 165 | if gzipped { | ||
| 166 | member, err := extractAgent(dir, src) | ||
| 167 | if err != nil { | ||
| 168 | return fmt.Errorf("extract %s from %s: %w", agentMember, u.URL, err) | ||
| 169 | } | ||
| 170 | defer os.Remove(member) // no-op after the successful rename | ||
| 171 | // The idempotent-resume check above cannot fire for a tarball: the sha | ||
| 172 | // it compares describes the archive, not the binary inside it. Spot the | ||
| 173 | // already-swapped case here instead, so a retry after a failed exec | ||
| 174 | // still leaves <exe>.prev pointing at the genuinely previous version. | ||
| 175 | if same, cmpErr := sameFile(exe, member); cmpErr == nil && same { | ||
| 176 | return a.execFn()(exe, os.Args, os.Environ()) | ||
| 177 | } | ||
| 178 | src = member | ||
| 179 | } | ||
| 180 | |||
| 181 | if err := os.Chmod(src, 0o755); err != nil { | ||
| 140 | return err | 182 | return err |
| 141 | } | 183 | } |
| 142 | // Copy (not rename) the current binary to .prev: a crash between the two | 184 | // Copy (not rename) the current binary to .prev: a crash between the two |
| @@ -144,7 +186,7 @@ func (a *Applier) Apply(ctx context.Context, u Update) error { | |||
| 144 | if err := copyFile(exe, exe+".prev"); err != nil { | 186 | if err := copyFile(exe, exe+".prev"); err != nil { |
| 145 | return fmt.Errorf("preserve .prev: %w", err) | 187 | return fmt.Errorf("preserve .prev: %w", err) |
| 146 | } | 188 | } |
| 147 | if err := os.Rename(tmp.Name(), exe); err != nil { | 189 | if err := os.Rename(src, exe); err != nil { |
| 148 | return fmt.Errorf("swap binary: %w", err) | 190 | return fmt.Errorf("swap binary: %w", err) |
| 149 | } | 191 | } |
| 150 | // Durability: fsync the directory entry itself, not just the file data — | 192 | // Durability: fsync the directory entry itself, not just the file data — |
| @@ -155,11 +197,105 @@ func (a *Applier) Apply(ctx context.Context, u Update) error { | |||
| 155 | _ = df.Sync() | 197 | _ = df.Sync() |
| 156 | _ = df.Close() | 198 | _ = df.Close() |
| 157 | } | 199 | } |
| 158 | execFn := a.Exec | 200 | return a.execFn()(exe, os.Args, os.Environ()) |
| 159 | if execFn == nil { | 201 | } |
| 160 | execFn = syscall.Exec | 202 | |
| 203 | // isGzip reports whether the file at path opens with the gzip magic bytes. A | ||
| 204 | // file too short to carry them is not an archive, which is answer enough. | ||
| 205 | func isGzip(path string) (bool, error) { | ||
| 206 | f, err := os.Open(path) | ||
| 207 | if err != nil { | ||
| 208 | return false, err | ||
| 209 | } | ||
| 210 | defer f.Close() | ||
| 211 | var magic [2]byte | ||
| 212 | if _, err := io.ReadFull(f, magic[:]); err != nil { | ||
| 213 | if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { | ||
| 214 | return false, nil | ||
| 215 | } | ||
| 216 | return false, err | ||
| 217 | } | ||
| 218 | return magic[0] == 0x1f && magic[1] == 0x8b, nil | ||
| 219 | } | ||
| 220 | |||
| 221 | // extractAgent writes the archive's agent member to a fresh temp file in dir | ||
| 222 | // and returns its path. The temp shares the sweep prefix, so an abandoned one | ||
| 223 | // is litter a later attempt collects rather than litter that stays. | ||
| 224 | // | ||
| 225 | // Member names steer nothing: the destination is a path this function chooses, | ||
| 226 | // and an entry claiming an absolute or dot-dot name is skipped rather than | ||
| 227 | // mapped onto a basename it does not own. Only regular files qualify, so a | ||
| 228 | // symlink named eitri-agent is not a way to make the swap read some other file. | ||
| 229 | func extractAgent(dir, archive string) (string, error) { | ||
| 230 | f, err := os.Open(archive) | ||
| 231 | if err != nil { | ||
| 232 | return "", err | ||
| 233 | } | ||
| 234 | defer f.Close() | ||
| 235 | gz, err := gzip.NewReader(f) | ||
| 236 | if err != nil { | ||
| 237 | return "", err | ||
| 238 | } | ||
| 239 | defer gz.Close() | ||
| 240 | tr := tar.NewReader(gz) | ||
| 241 | for { | ||
| 242 | hdr, err := tr.Next() | ||
| 243 | if errors.Is(err, io.EOF) { | ||
| 244 | return "", fmt.Errorf("no %s member in archive", agentMember) | ||
| 245 | } | ||
| 246 | if err != nil { | ||
| 247 | return "", err | ||
| 248 | } | ||
| 249 | if hdr.Typeflag != tar.TypeReg || !isAgentMember(hdr.Name) { | ||
| 250 | continue | ||
| 251 | } | ||
| 252 | out, err := os.CreateTemp(dir, ".eitri-agent-upgrade-*") | ||
| 253 | if err != nil { | ||
| 254 | return "", err | ||
| 255 | } | ||
| 256 | if _, err := io.Copy(out, tr); err != nil { | ||
| 257 | out.Close() | ||
| 258 | os.Remove(out.Name()) | ||
| 259 | return "", err | ||
| 260 | } | ||
| 261 | // Durability: same power-loss hole as the downloaded temp above. | ||
| 262 | if err := out.Sync(); err != nil { | ||
| 263 | out.Close() | ||
| 264 | os.Remove(out.Name()) | ||
| 265 | return "", err | ||
| 266 | } | ||
| 267 | if err := out.Close(); err != nil { | ||
| 268 | os.Remove(out.Name()) | ||
| 269 | return "", err | ||
| 270 | } | ||
| 271 | return out.Name(), nil | ||
| 272 | } | ||
| 273 | } | ||
| 274 | |||
| 275 | // isAgentMember reports whether a tar entry name is the bundle's agent binary. | ||
| 276 | func isAgentMember(name string) bool { | ||
| 277 | if strings.HasPrefix(name, "/") { | ||
| 278 | return false | ||
| 279 | } | ||
| 280 | for _, element := range strings.Split(name, "/") { | ||
| 281 | if element == ".." { | ||
| 282 | return false | ||
| 283 | } | ||
| 284 | } | ||
| 285 | return path.Base(name) == agentMember | ||
| 286 | } | ||
| 287 | |||
| 288 | // sameFile reports whether two paths hold identical bytes. | ||
| 289 | func sameFile(a, b string) (bool, error) { | ||
| 290 | sumA, err := sha256File(a) | ||
| 291 | if err != nil { | ||
| 292 | return false, err | ||
| 293 | } | ||
| 294 | sumB, err := sha256File(b) | ||
| 295 | if err != nil { | ||
| 296 | return false, err | ||
| 161 | } | 297 | } |
| 162 | return execFn(exe, os.Args, os.Environ()) | 298 | return sumA == sumB, nil |
| 163 | } | 299 | } |
| 164 | 300 | ||
| 165 | // sha256File hashes the file at path, hex-encoded. | 301 | // sha256File hashes the file at path, hex-encoded. |
internal/agent/selfupdate/selfupdate_test.go
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,9 @@ | |||
| 1 | package selfupdate | 1 | package selfupdate |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "archive/tar" | ||
| 5 | "bytes" | ||
| 6 | "compress/gzip" | ||
| 4 | "context" | 7 | "context" |
| 5 | "crypto/sha256" | 8 | "crypto/sha256" |
| 6 | "encoding/hex" | 9 | "encoding/hex" |
| @@ -21,6 +24,236 @@ func serveBinary(t *testing.T, body []byte) (url, sha string) { | |||
| 21 | return srv.URL, hex.EncodeToString(sum[:]) | 24 | return srv.URL, hex.EncodeToString(sum[:]) |
| 22 | } | 25 | } |
| 23 | 26 | ||
| 27 | // tarball builds a gzipped tar of the given name→content members, in the | ||
| 28 | // order listed, the way a release bundle lays them out. | ||
| 29 | func tarball(t *testing.T, members ...[2]string) []byte { | ||
| 30 | t.Helper() | ||
| 31 | var buf bytes.Buffer | ||
| 32 | gz := gzip.NewWriter(&buf) | ||
| 33 | tw := tar.NewWriter(gz) | ||
| 34 | for _, m := range members { | ||
| 35 | hdr := &tar.Header{ | ||
| 36 | Name: m[0], | ||
| 37 | Mode: 0o755, | ||
| 38 | Size: int64(len(m[1])), | ||
| 39 | Typeflag: tar.TypeReg, | ||
| 40 | } | ||
| 41 | if err := tw.WriteHeader(hdr); err != nil { | ||
| 42 | t.Fatal(err) | ||
| 43 | } | ||
| 44 | if _, err := tw.Write([]byte(m[1])); err != nil { | ||
| 45 | t.Fatal(err) | ||
| 46 | } | ||
| 47 | } | ||
| 48 | if err := tw.Close(); err != nil { | ||
| 49 | t.Fatal(err) | ||
| 50 | } | ||
| 51 | if err := gz.Close(); err != nil { | ||
| 52 | t.Fatal(err) | ||
| 53 | } | ||
| 54 | return buf.Bytes() | ||
| 55 | } | ||
| 56 | |||
| 57 | // hostBundle is the shape scripts/release.sh ships: a versioned directory | ||
| 58 | // holding the agent beside everything else a host installs. | ||
| 59 | func hostBundle(t *testing.T, agent string) []byte { | ||
| 60 | t.Helper() | ||
| 61 | return tarball(t, | ||
| 62 | [2]string{"eitri_v9_linux_amd64/eitri-server", "server-binary"}, | ||
| 63 | [2]string{"eitri_v9_linux_amd64/eitri-agent", agent}, | ||
| 64 | [2]string{"eitri_v9_linux_amd64/eitri-agent.service", "[Unit]"}, | ||
| 65 | ) | ||
| 66 | } | ||
| 67 | |||
| 68 | func TestApplyExtractsAgentFromTarball(t *testing.T) { | ||
| 69 | dir := t.TempDir() | ||
| 70 | exe := filepath.Join(dir, "eitri-agent") | ||
| 71 | if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | url, sha := serveBinary(t, hostBundle(t, "new-binary")) | ||
| 75 | |||
| 76 | var gotArgv0 string | ||
| 77 | a := &Applier{ | ||
| 78 | ExePath: func() (string, error) { return exe, nil }, | ||
| 79 | Exec: func(argv0 string, argv, env []string) error { | ||
| 80 | gotArgv0 = argv0 | ||
| 81 | return nil | ||
| 82 | }, | ||
| 83 | } | ||
| 84 | if err := a.Apply(context.Background(), Update{Version: "v9", URL: url, SHA256: sha}); err != nil { | ||
| 85 | t.Fatal(err) | ||
| 86 | } | ||
| 87 | if b, _ := os.ReadFile(exe); string(b) != "new-binary" { | ||
| 88 | t.Fatalf("bundle member not swapped in: %q", b) | ||
| 89 | } | ||
| 90 | if b, _ := os.ReadFile(exe + ".prev"); string(b) != "old" { | ||
| 91 | t.Fatalf(".prev not preserved: %q", b) | ||
| 92 | } | ||
| 93 | if gotArgv0 != exe { | ||
| 94 | t.Fatalf("exec argv0=%q want %q", gotArgv0, exe) | ||
| 95 | } | ||
| 96 | fi, _ := os.Stat(exe) | ||
| 97 | if fi.Mode().Perm()&0o111 == 0 { | ||
| 98 | t.Fatal("swapped binary not executable") | ||
| 99 | } | ||
| 100 | // The binary, its .prev, and nothing else: neither the download nor the | ||
| 101 | // extracted member is left behind. | ||
| 102 | if entries, _ := os.ReadDir(dir); len(entries) != 2 { | ||
| 103 | t.Fatalf("temp file leaked: %v", entries) | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | // TestApplyTarballShaCoversTheArchive pins which bytes the manifest sha | ||
| 108 | // describes: the artifact as served. A sha over the binary INSIDE the bundle | ||
| 109 | // is a mismatch, not a shortcut worth honouring. | ||
| 110 | func TestApplyTarballShaCoversTheArchive(t *testing.T) { | ||
| 111 | dir := t.TempDir() | ||
| 112 | exe := filepath.Join(dir, "eitri-agent") | ||
| 113 | os.WriteFile(exe, []byte("old"), 0o755) | ||
| 114 | url, _ := serveBinary(t, hostBundle(t, "new-binary")) | ||
| 115 | inner := sha256.Sum256([]byte("new-binary")) | ||
| 116 | |||
| 117 | execCalled := false | ||
| 118 | a := &Applier{ | ||
| 119 | ExePath: func() (string, error) { return exe, nil }, | ||
| 120 | Exec: func(string, []string, []string) error { execCalled = true; return nil }, | ||
| 121 | } | ||
| 122 | err := a.Apply(context.Background(), Update{URL: url, SHA256: hex.EncodeToString(inner[:])}) | ||
| 123 | if err == nil || execCalled { | ||
| 124 | t.Fatalf("want sha error without exec; err=%v execCalled=%v", err, execCalled) | ||
| 125 | } | ||
| 126 | if b, _ := os.ReadFile(exe); string(b) != "old" { | ||
| 127 | t.Fatal("binary must be untouched on sha mismatch") | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 131 | func TestApplyTarballWithoutAgentMember(t *testing.T) { | ||
| 132 | dir := t.TempDir() | ||
| 133 | exe := filepath.Join(dir, "eitri-agent") | ||
| 134 | os.WriteFile(exe, []byte("old"), 0o755) | ||
| 135 | body := tarball(t, | ||
| 136 | [2]string{"eitri_v9_linux_amd64/eitri-server", "server-binary"}, | ||
| 137 | [2]string{"eitri_v9_linux_amd64/eitri-agent.service", "[Unit]"}, | ||
| 138 | ) | ||
| 139 | url, sha := serveBinary(t, body) | ||
| 140 | |||
| 141 | execCalled := false | ||
| 142 | a := &Applier{ | ||
| 143 | ExePath: func() (string, error) { return exe, nil }, | ||
| 144 | Exec: func(string, []string, []string) error { execCalled = true; return nil }, | ||
| 145 | } | ||
| 146 | err := a.Apply(context.Background(), Update{URL: url, SHA256: sha}) | ||
| 147 | if err == nil || execCalled { | ||
| 148 | t.Fatalf("want extract error without exec; err=%v execCalled=%v", err, execCalled) | ||
| 149 | } | ||
| 150 | if b, _ := os.ReadFile(exe); string(b) != "old" { | ||
| 151 | t.Fatal("binary must be untouched when the archive carries no agent") | ||
| 152 | } | ||
| 153 | if _, err := os.Stat(exe + ".prev"); err == nil { | ||
| 154 | t.Fatal("no .prev should exist when the swap never happened") | ||
| 155 | } | ||
| 156 | if entries, _ := os.ReadDir(dir); len(entries) != 1 { | ||
| 157 | t.Fatalf("temp file leaked: %v", entries) | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | // TestApplyTarballHostileMemberNames: an entry that names itself out of the | ||
| 162 | // bundle is not the agent. It is skipped rather than matched on its basename, | ||
| 163 | // and nothing is written outside the directory the binary lives in. | ||
| 164 | func TestApplyTarballHostileMemberNames(t *testing.T) { | ||
| 165 | root := t.TempDir() | ||
| 166 | dir := filepath.Join(root, "bin") | ||
| 167 | if err := os.Mkdir(dir, 0o755); err != nil { | ||
| 168 | t.Fatal(err) | ||
| 169 | } | ||
| 170 | exe := filepath.Join(dir, "eitri-agent") | ||
| 171 | os.WriteFile(exe, []byte("old"), 0o755) | ||
| 172 | body := tarball(t, | ||
| 173 | [2]string{"../eitri-agent", "escaped"}, | ||
| 174 | [2]string{"/etc/eitri-agent", "absolute"}, | ||
| 175 | [2]string{"bundle/../../eitri-agent", "traversed"}, | ||
| 176 | ) | ||
| 177 | url, sha := serveBinary(t, body) | ||
| 178 | |||
| 179 | execCalled := false | ||
| 180 | a := &Applier{ | ||
| 181 | ExePath: func() (string, error) { return exe, nil }, | ||
| 182 | Exec: func(string, []string, []string) error { execCalled = true; return nil }, | ||
| 183 | } | ||
| 184 | err := a.Apply(context.Background(), Update{URL: url, SHA256: sha}) | ||
| 185 | if err == nil || execCalled { | ||
| 186 | t.Fatalf("want extract error without exec; err=%v execCalled=%v", err, execCalled) | ||
| 187 | } | ||
| 188 | if b, _ := os.ReadFile(exe); string(b) != "old" { | ||
| 189 | t.Fatalf("binary must be untouched: %q", b) | ||
| 190 | } | ||
| 191 | if _, err := os.Stat(filepath.Join(root, "eitri-agent")); err == nil { | ||
| 192 | t.Fatal("a member wrote outside the binary's directory") | ||
| 193 | } | ||
| 194 | if entries, _ := os.ReadDir(root); len(entries) != 1 { | ||
| 195 | t.Fatalf("wrote outside the binary's directory: %v", entries) | ||
| 196 | } | ||
| 197 | } | ||
| 198 | |||
| 199 | // TestApplyTarballResumeKeepsPrev is the exec-failure retry for a bundle: the | ||
| 200 | // sha the manifest carries describes the archive, so the duplicate is only | ||
| 201 | // visible once the member is out, and .prev must survive it. | ||
| 202 | func TestApplyTarballResumeKeepsPrev(t *testing.T) { | ||
| 203 | dir := t.TempDir() | ||
| 204 | exe := filepath.Join(dir, "eitri-agent") | ||
| 205 | if err := os.WriteFile(exe, []byte("already-swapped-binary"), 0o755); err != nil { | ||
| 206 | t.Fatal(err) | ||
| 207 | } | ||
| 208 | if err := os.WriteFile(exe+".prev", []byte("old"), 0o755); err != nil { | ||
| 209 | t.Fatal(err) | ||
| 210 | } | ||
| 211 | url, sha := serveBinary(t, hostBundle(t, "already-swapped-binary")) | ||
| 212 | |||
| 213 | var gotArgv0 string | ||
| 214 | a := &Applier{ | ||
| 215 | ExePath: func() (string, error) { return exe, nil }, | ||
| 216 | Exec: func(argv0 string, argv, env []string) error { | ||
| 217 | gotArgv0 = argv0 | ||
| 218 | return nil | ||
| 219 | }, | ||
| 220 | } | ||
| 221 | if err := a.Apply(context.Background(), Update{Version: "v9", URL: url, SHA256: sha}); err != nil { | ||
| 222 | t.Fatal(err) | ||
| 223 | } | ||
| 224 | if b, _ := os.ReadFile(exe + ".prev"); string(b) != "old" { | ||
| 225 | t.Fatalf(".prev must survive an exec-failure retry: %q", b) | ||
| 226 | } | ||
| 227 | if gotArgv0 != exe { | ||
| 228 | t.Fatalf("exec argv0=%q want %q", gotArgv0, exe) | ||
| 229 | } | ||
| 230 | if entries, _ := os.ReadDir(dir); len(entries) != 2 { | ||
| 231 | t.Fatalf("temp file leaked: %v", entries) | ||
| 232 | } | ||
| 233 | } | ||
| 234 | |||
| 235 | // TestApplyNonGzipArtifactSwapsWhole: the artifact type comes from the bytes, | ||
| 236 | // and bytes that merely start like an archive are still a bare binary. The | ||
| 237 | // manifest names it, its sha covers it, it lands whole. | ||
| 238 | func TestApplyNonGzipArtifactSwapsWhole(t *testing.T) { | ||
| 239 | dir := t.TempDir() | ||
| 240 | exe := filepath.Join(dir, "eitri-agent") | ||
| 241 | os.WriteFile(exe, []byte("old"), 0o755) | ||
| 242 | body := []byte{0x1f, 0x00, 'n', 'o', 't', '-', 'g', 'z'} | ||
| 243 | url, sha := serveBinary(t, body) | ||
| 244 | |||
| 245 | a := &Applier{ | ||
| 246 | ExePath: func() (string, error) { return exe, nil }, | ||
| 247 | Exec: func(string, []string, []string) error { return nil }, | ||
| 248 | } | ||
| 249 | if err := a.Apply(context.Background(), Update{URL: url, SHA256: sha}); err != nil { | ||
| 250 | t.Fatal(err) | ||
| 251 | } | ||
| 252 | if b, _ := os.ReadFile(exe); !bytes.Equal(b, body) { | ||
| 253 | t.Fatalf("bare artifact not swapped in whole: %q", b) | ||
| 254 | } | ||
| 255 | } | ||
| 256 | |||
| 24 | func TestApplySwapsAndExecs(t *testing.T) { | 257 | func TestApplySwapsAndExecs(t *testing.T) { |
| 25 | dir := t.TempDir() | 258 | dir := t.TempDir() |
| 26 | exe := filepath.Join(dir, "eitri-agent") | 259 | exe := filepath.Join(dir, "eitri-agent") |
scripts/release.sh
| Old | New | ||
|---|---|---|---|
| @@ -7,7 +7,9 @@ | |||
| 7 | # (optional sidecar; not in manifest.json) | 7 | # (optional sidecar; not in manifest.json) |
| 8 | # eitri-agent_{linux_amd64,linux_arm64,darwin_arm64} | 8 | # eitri-agent_{linux_amd64,linux_arm64,darwin_arm64} |
| 9 | # bare binaries — what the agent | 9 | # bare binaries — what the agent |
| 10 | # self-updater downloads and sha-verifies | 10 | # self-updater downloads and |
| 11 | # sha-verifies, for one more release | ||
| 12 | # (see "the bare-binary bridge" below) | ||
| 11 | # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream | 13 | # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream |
| 12 | # CLOUDHV.fd guest UEFI firmware (if FIRMWARE_SRC set) | 14 | # CLOUDHV.fd guest UEFI firmware (if FIRMWARE_SRC set) |
| 13 | # SHA256SUMS over everything above | 15 | # SHA256SUMS over everything above |
| @@ -48,6 +50,26 @@ mkdir -p "$OUT" | |||
| 48 | STAGE_ROOT="$(mktemp -d)" | 50 | STAGE_ROOT="$(mktemp -d)" |
| 49 | trap 'rm -rf "$STAGE_ROOT"' EXIT | 51 | trap 'rm -rf "$STAGE_ROOT"' EXIT |
| 50 | 52 | ||
| 53 | # ---- the bare-binary bridge: this release emits it, the next one does not ---- | ||
| 54 | # Each host bundle below is followed by a `cp` that drops the same eitri-agent | ||
| 55 | # bytes into dist/ a second time, bare. That copy is a compatibility hop, not an | ||
| 56 | # artifact anyone is meant to fetch by hand: manifest.json points the | ||
| 57 | # eitri-agent entries at it, and a fielded agent swaps in WHATEVER bytes the | ||
| 58 | # manifest names. Hand an agent from an older release a tarball and it verifies | ||
| 59 | # the sha, writes the archive over its own binary, and crash-loops. | ||
| 60 | # | ||
| 61 | # As of this release the self-updater reads a tarball — it sniffs gzip and | ||
| 62 | # extracts the bundle's eitri-agent member (internal/agent/selfupdate) — so | ||
| 63 | # every agent running this version or later can be pointed at the bundle. These | ||
| 64 | # bare copies exist only to carry the ones already in the field across. | ||
| 65 | # | ||
| 66 | # NEXT RELEASE, once no agent older than this one is still out there: point | ||
| 67 | # manifest.json at eitri_<v>_<os>_<arch>.tar.gz (widen the matcher in | ||
| 68 | # internal/site.BuildManifest) and delete the two `cp` lines below. /dl then | ||
| 69 | # serves tarballs and nothing is shipped twice. cloud-hypervisor and CLOUDHV.fd | ||
| 70 | # stay bare in either case — agents bootstrap those directly and they duplicate | ||
| 71 | # nothing. | ||
| 72 | # ------------------------------------------------------------------------------ | ||
| 51 | for arch in amd64 arm64; do | 73 | for arch in amd64 arm64; do |
| 52 | bundle="eitri_${VERSION}_linux_${arch}" | 74 | bundle="eitri_${VERSION}_linux_${arch}" |
| 53 | stage="$STAGE_ROOT/$arch" | 75 | stage="$STAGE_ROOT/$arch" |
| @@ -59,7 +81,7 @@ for arch in amd64 arm64; do | |||
| 59 | -o "$stage/$bundle/eitri-agent" ./cmd/eitri-agent | 81 | -o "$stage/$bundle/eitri-agent" ./cmd/eitri-agent |
| 60 | cp scripts/eitri-agent.service scripts/eitri-server.service "$stage/$bundle/" | 82 | cp scripts/eitri-agent.service scripts/eitri-server.service "$stage/$bundle/" |
| 61 | tar -C "$stage" -czf "$OUT/$bundle.tar.gz" "$bundle" | 83 | tar -C "$stage" -czf "$OUT/$bundle.tar.gz" "$bundle" |
| 62 | cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}" | 84 | cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}" # bridge; drop next release |
| 63 | done | 85 | done |
| 64 | 86 | ||
| 65 | # macOS host bundle: the agent and the script that installs it as a LaunchAgent. | 87 | # macOS host bundle: the agent and the script that installs it as a LaunchAgent. |
| @@ -84,7 +106,7 @@ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \ | |||
| 84 | -o "$mac_stage/$mac_bundle/eitri-agent" ./cmd/eitri-agent | 106 | -o "$mac_stage/$mac_bundle/eitri-agent" ./cmd/eitri-agent |
| 85 | cp scripts/eitri-agent-launchagent.sh "$mac_stage/$mac_bundle/" | 107 | cp scripts/eitri-agent-launchagent.sh "$mac_stage/$mac_bundle/" |
| 86 | tar -C "$mac_stage" -czf "$OUT/$mac_bundle.tar.gz" "$mac_bundle" | 108 | tar -C "$mac_stage" -czf "$OUT/$mac_bundle.tar.gz" "$mac_bundle" |
| 87 | cp "$mac_stage/$mac_bundle/eitri-agent" "$OUT/eitri-agent_darwin_arm64" | 109 | cp "$mac_stage/$mac_bundle/eitri-agent" "$OUT/eitri-agent_darwin_arm64" # bridge; drop next release |
| 88 | 110 | ||
| 89 | # Bundled OIDC issuer — its own tarball (binary + systemd unit) so running with | 111 | # Bundled OIDC issuer — its own tarball (binary + systemd unit) so running with |
| 90 | # or without local OIDC is a pure deployment choice: a fleet fronted by an | 112 | # or without local OIDC is a pure deployment choice: a fleet fronted by an |