a73x

80b27101

feat(agent): the self-updater reads a release tarball

a73x   2026-08-07 15:12

Commit message
feat(agent): the self-updater reads a release tarball

An upgrade artifact is whatever the manifest names, and now that can be a
host bundle: the updater sniffs gzip magic in the downloaded bytes — never
the URL's suffix, which a mirror or redirect can dress up freely — verifies
the manifest sha over the whole archive, and swaps in the bundle's
eitri-agent member with the same .prev-then-rename-then-re-exec semantics a
bare binary gets. Members are matched on basename and read into a path the
updater chooses, so an entry naming itself out of the bundle steers nothing;
absolute, dot-dot, and non-regular entries are skipped outright. A tarball
carries no sha for the binary inside it, so the already-swapped case that
guards <exe>.prev on an exec-failure retry is spotted after extraction.

The release still ships bare eitri-agent_<os>_<arch> binaries beside the
bundles, and manifest.json still points at them, for exactly one more
release: an agent already in the field swaps in whatever bytes the manifest
names, and handing one a tarball would overwrite its binary with an archive.
This release is the last hop those agents need — the flip to tarball-only
/dl is a note at the emission site in scripts/release.sh.

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