@@ -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+
303392func 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-
494573func 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
568662func safeFileMode (mode int64 ) os.FileMode {
0 commit comments