Skip to content

Commit fca93bf

Browse files
JAORMXclaude
andcommitted
Fix firmware download for private repos
Replace broken per-file .sha256 checksum URLs with sha256sums.txt parsed from a single download. Use GitHub API to resolve release asset URLs, fixing auth for private repos (GITHUB_TOKEN/GH_TOKEN). Fix Dir resolution to point to the subdirectory containing the lib instead of the cache root, so LD_LIBRARY_PATH works correctly. Fix Taskfile HOST_ARCH_ALT sed chain that broke on arm64, glob expansion in fetch-firmware status check, and add --pattern to fetch tasks to avoid downloading unrelated assets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a058a2c commit fca93bf

5 files changed

Lines changed: 321 additions & 94 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ internal/infra/vm/runtimebin/libkrunfw.dylib
1010
internal/infra/vm/runtimebin/VERSION
1111
internal/infra/vm/runtimebin/LICENSE-GPL
1212
internal/infra/vm/runtimebin/propolis-firmware-*.tar.gz
13+
internal/infra/vm/runtimebin/propolis-runtime-*.tar.gz
1314
internal/infra/vm/runtimebin/sha256sums.txt
1415

1516
# Test coverage

Taskfile.yaml

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ vars:
1515
HOST_ARCH:
1616
sh: uname -m
1717
HOST_ARCH_ALT:
18-
sh: uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/;s/arm64/aarch64/'
18+
sh: |
19+
case $(uname -m) in
20+
x86_64) echo amd64 ;;
21+
aarch64) echo arm64 ;;
22+
arm64) echo aarch64 ;;
23+
amd64) echo x86_64 ;;
24+
*) uname -m ;;
25+
esac
1926
HOST_OS:
2027
sh: uname -s | tr '[:upper:]' '[:lower:]'
2128
HOST_OS_ALT:
@@ -112,7 +119,7 @@ tasks:
112119
cmds:
113120
- |
114121
mkdir -p internal/infra/vm/runtimebin
115-
gh release download {{.PROPOLIS_VERSION}} --repo stacklok/propolis --dir internal/infra/vm/runtimebin/ --clobber
122+
gh release download {{.PROPOLIS_VERSION}} --repo stacklok/propolis --dir internal/infra/vm/runtimebin/ --clobber --pattern 'propolis-runtime-*.tar.gz'
116123
archive=""
117124
for os in "{{.HOST_OS}}" "{{.HOST_OS_ALT}}"; do
118125
for arch in "{{.HOST_ARCH}}" "{{.HOST_ARCH_ALT}}"; do
@@ -134,11 +141,11 @@ tasks:
134141
fetch-firmware:
135142
desc: Download pre-built propolis firmware from GitHub Release
136143
status:
137-
- test -f internal/infra/vm/runtimebin/libkrunfw.*
144+
- ls internal/infra/vm/runtimebin/libkrunfw.* 1>/dev/null 2>&1
138145
cmds:
139146
- |
140147
mkdir -p internal/infra/vm/runtimebin
141-
gh release download {{.PROPOLIS_VERSION}} --repo stacklok/propolis --dir internal/infra/vm/runtimebin/ --clobber
148+
gh release download {{.PROPOLIS_VERSION}} --repo stacklok/propolis --dir internal/infra/vm/runtimebin/ --clobber --pattern 'propolis-firmware-*.tar.gz'
142149
archive=""
143150
for os in "{{.HOST_OS}}" "{{.HOST_OS_ALT}}"; do
144151
for arch in "{{.HOST_ARCH}}" "{{.HOST_ARCH_ALT}}"; do
@@ -213,6 +220,7 @@ tasks:
213220
- rm -f internal/infra/vm/runtimebin/libkrun.so.1 internal/infra/vm/runtimebin/libkrun.dylib
214221
- rm -f internal/infra/vm/runtimebin/libkrunfw.so.5 internal/infra/vm/runtimebin/libkrunfw.dylib
215222
- rm -f internal/infra/vm/runtimebin/VERSION internal/infra/vm/runtimebin/LICENSE-GPL
223+
- rm -f internal/infra/vm/runtimebin/sha256sums.txt
216224

217225
# --- OCI guest images ---
218226

cmd/bbox/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,12 @@ func run(parentCtx context.Context, agentName string, flags runFlags) error {
408408
if fwErr != nil {
409409
return fmt.Errorf("resolving firmware: %w", fwErr)
410410
}
411+
logger.Debug("resolved firmware",
412+
"source", firmwareRes.Source,
413+
"dir", firmwareRes.Dir,
414+
"version", firmwareRes.Version,
415+
"url", firmwareRes.URL,
416+
)
411417
dataDir, dataErr := infravm.VMDataDir(vmName)
412418
if dataErr != nil {
413419
return fmt.Errorf("resolving VM data directory: %w", dataErr)

internal/infra/vm/firmware.go

Lines changed: 142 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,16 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
144144
if manifest, ok := readFirmwareManifestRaw(manifestPath); ok && manifest.LibraryHash != "" {
145145
if fwPath, err := findFirmwareFile(cacheDir, osName); err == nil {
146146
if fileHash, hashErr := hashFile(fwPath); hashErr == nil && fileHash == manifest.LibraryHash {
147-
return manifestToResolution(manifestPath, manifest), nil
147+
slog.DebugContext(ctx, "firmware cache hit", "dir", filepath.Dir(fwPath), "version", version)
148+
return FirmwareResolution{
149+
Dir: filepath.Dir(fwPath),
150+
Version: manifest.Version,
151+
OS: manifest.OS,
152+
Arch: manifest.Arch,
153+
Source: manifest.Source,
154+
URL: manifest.URL,
155+
Timestamp: manifest.Timestamp,
156+
}, nil
148157
}
149158
}
150159
// Hash mismatch, missing file, or legacy manifest — invalidate and re-download.
@@ -154,11 +163,35 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
154163
return FirmwareResolution{}, fmt.Errorf("clear firmware cache: %w", err)
155164
}
156165

166+
// Fetch release asset metadata and checksums via GitHub API.
167+
assets, err := fetchReleaseAssets(ctx, version)
168+
if err != nil {
169+
return FirmwareResolution{}, fmt.Errorf("fetch release assets: %w", err)
170+
}
171+
checksumURL, ok := assets["sha256sums.txt"]
172+
if !ok {
173+
return FirmwareResolution{}, errors.New("sha256sums.txt not found in release")
174+
}
175+
checksums, err := downloadChecksums(ctx, checksumURL)
176+
if err != nil {
177+
return FirmwareResolution{}, fmt.Errorf("download firmware checksums: %w", err)
178+
}
179+
157180
archCandidates := firmwareArchCandidates(arch)
158181
var lastErr error
159182
for _, candidate := range archCandidates {
183+
archiveName := fmt.Sprintf("propolis-firmware-%s-%s.tar.gz", osName, candidate)
184+
checksum, ok := checksums[archiveName]
185+
if !ok {
186+
lastErr = fmt.Errorf("no checksum for %s", archiveName)
187+
continue
188+
}
189+
archiveURL, ok := assets[archiveName]
190+
if !ok {
191+
lastErr = fmt.Errorf("no release asset for %s", archiveName)
192+
continue
193+
}
160194
url := firmwareURL(version, osName, candidate)
161-
checksumURL := url + ".sha256"
162195

163196
tmpArchive, err := os.CreateTemp(cacheRoot, "firmware-*.tar.gz")
164197
if err != nil {
@@ -170,13 +203,7 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
170203
_ = os.Remove(tmpArchivePath)
171204
}
172205

173-
checksum, err := downloadChecksum(ctx, checksumURL)
174-
if err != nil {
175-
cleanupArchive()
176-
lastErr = err
177-
continue
178-
}
179-
archiveHash, err := downloadToFile(ctx, url, tmpArchive, maxFirmwareArchiveSize)
206+
archiveHash, err := downloadToFile(ctx, archiveURL, tmpArchive, maxFirmwareArchiveSize)
180207
if err != nil {
181208
cleanupArchive()
182209
lastErr = err
@@ -206,20 +233,12 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
206233
lastErr = fmt.Errorf("extract firmware archive: %w", err)
207234
continue
208235
}
209-
fwPath, err := findFirmwareFile(tmpDir, osName)
210-
if err != nil {
236+
if _, err := findFirmwareFile(tmpDir, osName); err != nil {
211237
cleanupDir()
212238
cleanupArchive()
213239
lastErr = errors.New("firmware archive missing libkrunfw")
214240
continue
215241
}
216-
fwHash, err := hashFile(fwPath)
217-
if err != nil {
218-
cleanupDir()
219-
cleanupArchive()
220-
lastErr = fmt.Errorf("hash firmware library: %w", err)
221-
continue
222-
}
223242

224243
if err := os.MkdirAll(filepath.Dir(cacheDir), 0o700); err != nil {
225244
cleanupDir()
@@ -232,6 +251,17 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
232251
lastErr = fmt.Errorf("finalize firmware cache: %w", err)
233252
continue
234253
}
254+
_ = os.Remove(tmpArchivePath)
255+
256+
// Find firmware in the final location to get the correct Dir.
257+
finalFwPath, err := findFirmwareFile(cacheDir, osName)
258+
if err != nil {
259+
return FirmwareResolution{}, fmt.Errorf("find firmware in cache: %w", err)
260+
}
261+
fwHash, err := hashFile(finalFwPath)
262+
if err != nil {
263+
return FirmwareResolution{}, fmt.Errorf("hash firmware library: %w", err)
264+
}
235265

236266
manifest := FirmwareManifest{
237267
Version: version,
@@ -246,8 +276,9 @@ func downloadFirmware(ctx context.Context, cacheRoot, version, osName, arch stri
246276
return FirmwareResolution{}, err
247277
}
248278

279+
slog.DebugContext(ctx, "firmware downloaded", "dir", filepath.Dir(finalFwPath), "version", version, "arch", candidate)
249280
return FirmwareResolution{
250-
Dir: cacheDir,
281+
Dir: filepath.Dir(finalFwPath),
251282
Version: version,
252283
OS: osName,
253284
Arch: arch,
@@ -300,11 +331,71 @@ func firmwareURL(version, osName, arch string) string {
300331
return fmt.Sprintf("https://github.com/stacklok/propolis/releases/download/%s/propolis-firmware-%s-%s.tar.gz", version, osName, arch)
301332
}
302333

334+
// setGitHubAuth adds a Bearer token to the request if GITHUB_TOKEN or GH_TOKEN
335+
// is set. Required for downloading release assets from private repositories.
336+
func setGitHubAuth(req *http.Request) {
337+
token := os.Getenv("GITHUB_TOKEN")
338+
if token == "" {
339+
token = os.Getenv("GH_TOKEN")
340+
}
341+
if token != "" {
342+
req.Header.Set("Authorization", "Bearer "+token)
343+
}
344+
}
345+
346+
type releaseAsset struct {
347+
Name string `json:"name"`
348+
URL string `json:"url"`
349+
BrowserDownloadURL string `json:"browser_download_url"`
350+
}
351+
352+
type releaseResponse struct {
353+
Assets []releaseAsset `json:"assets"`
354+
}
355+
356+
// fetchReleaseAssets queries the GitHub API to get release asset metadata.
357+
// Returns a map of asset name → API download URL.
358+
func fetchReleaseAssets(ctx context.Context, version string) (map[string]string, error) {
359+
apiURL := fmt.Sprintf("https://api.github.com/repos/stacklok/propolis/releases/tags/%s", version)
360+
return fetchReleaseAssetsFromURL(ctx, apiURL)
361+
}
362+
363+
func fetchReleaseAssetsFromURL(ctx context.Context, apiURL string) (map[string]string, error) {
364+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
365+
if err != nil {
366+
return nil, fmt.Errorf("create release request: %w", err)
367+
}
368+
setGitHubAuth(req)
369+
req.Header.Set("Accept", "application/vnd.github+json")
370+
371+
client := &http.Client{Timeout: 30 * time.Second}
372+
resp, err := client.Do(req)
373+
if err != nil {
374+
return nil, fmt.Errorf("fetch release: %w", err)
375+
}
376+
defer func() { _ = resp.Body.Close() }()
377+
378+
if resp.StatusCode != http.StatusOK {
379+
return nil, fmt.Errorf("fetch release: unexpected status %s", resp.Status)
380+
}
381+
var release releaseResponse
382+
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&release); err != nil {
383+
return nil, fmt.Errorf("decode release: %w", err)
384+
}
385+
assets := make(map[string]string, len(release.Assets))
386+
for _, a := range release.Assets {
387+
assets[a.Name] = a.URL
388+
}
389+
return assets, nil
390+
}
391+
303392
func downloadToFile(ctx context.Context, url string, dst *os.File, maxBytes int64) (string, error) {
304393
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
305394
if err != nil {
306395
return "", fmt.Errorf("create firmware request: %w", err)
307396
}
397+
setGitHubAuth(req)
398+
req.Header.Set("Accept", "application/octet-stream")
308399
client := &http.Client{Timeout: 2 * time.Minute}
309400
resp, err := client.Do(req)
310401
if err != nil {
@@ -479,18 +570,6 @@ func readFirmwareManifestRaw(path string) (FirmwareManifest, bool) {
479570
return manifest, true
480571
}
481572

482-
func manifestToResolution(manifestPath string, m FirmwareManifest) FirmwareResolution {
483-
return FirmwareResolution{
484-
Dir: filepath.Dir(manifestPath),
485-
Version: m.Version,
486-
OS: m.OS,
487-
Arch: m.Arch,
488-
Source: m.Source,
489-
URL: m.URL,
490-
Timestamp: m.Timestamp,
491-
}
492-
}
493-
494573
func writeFirmwareManifest(path string, manifest FirmwareManifest) error {
495574
data, err := json.MarshalIndent(manifest, "", " ")
496575
if err != nil {
@@ -531,38 +610,53 @@ func firmwareArchCandidates(arch string) []string {
531610
}
532611
}
533612

534-
func downloadChecksum(ctx context.Context, url string) (string, error) {
613+
func parseChecksumMap(text string) (map[string]string, error) {
614+
result := make(map[string]string)
615+
for _, line := range strings.Split(text, "\n") {
616+
line = strings.TrimSpace(line)
617+
if line == "" {
618+
continue
619+
}
620+
fields := strings.Fields(line)
621+
if len(fields) != 2 {
622+
return nil, fmt.Errorf("invalid checksum line: %q", line)
623+
}
624+
hash := fields[0]
625+
filename := fields[1]
626+
if len(hash) != 64 {
627+
return nil, fmt.Errorf("invalid checksum length %d for %s", len(hash), filename)
628+
}
629+
if _, err := hex.DecodeString(hash); err != nil {
630+
return nil, fmt.Errorf("invalid checksum hex for %s: %w", filename, err)
631+
}
632+
result[filename] = hash
633+
}
634+
return result, nil
635+
}
636+
637+
func downloadChecksums(ctx context.Context, url string) (map[string]string, error) {
535638
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
536639
if err != nil {
537-
return "", fmt.Errorf("create firmware checksum request: %w", err)
640+
return nil, fmt.Errorf("create checksums request: %w", err)
538641
}
642+
setGitHubAuth(req)
643+
req.Header.Set("Accept", "application/octet-stream")
539644
client := &http.Client{Timeout: 30 * time.Second}
540645
resp, err := client.Do(req)
541646
if err != nil {
542-
return "", fmt.Errorf("download firmware checksum: %w", err)
647+
return nil, fmt.Errorf("download checksums: %w", err)
543648
}
544649
defer func() {
545650
_ = resp.Body.Close()
546651
}()
547652
if resp.StatusCode != http.StatusOK {
548-
return "", fmt.Errorf("download firmware checksum: unexpected status %s", resp.Status)
653+
return nil, fmt.Errorf("download checksums: unexpected status %s", resp.Status)
549654
}
550655
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
551656
if err != nil {
552-
return "", fmt.Errorf("read firmware checksum: %w", err)
553-
}
554-
fields := strings.Fields(string(data))
555-
if len(fields) == 0 {
556-
return "", errors.New("firmware checksum is empty")
557-
}
558-
checksum := fields[0]
559-
if len(checksum) != 64 {
560-
return "", fmt.Errorf("invalid firmware checksum length: %d", len(checksum))
561-
}
562-
if _, err := hex.DecodeString(checksum); err != nil {
563-
return "", fmt.Errorf("invalid firmware checksum: %w", err)
657+
return nil, fmt.Errorf("read checksums: %w", err)
564658
}
565-
return checksum, nil
659+
return parseChecksumMap(string(data))
566660
}
567661

568662
func safeFileMode(mode int64) os.FileMode {

0 commit comments

Comments
 (0)