Skip to content

Commit 78eefe8

Browse files
Full cache for offline mode (#497)
* full cache feature * remove test code * add logic to auto update cache * fix cache clean up logic error * split initrd cache to sub dir * fix unit test * fix cache checking for package pinning * fix cache on deb multirepo * fix code error * chore: auto-update coverage threshold to 66.6% (was 66.3%) * fix lint error * changes based on copilot review * fix unit test error * fix merge error * chore: auto-update coverage threshold to 67.1% (was 66.8%) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent d28cbea commit 78eefe8

22 files changed

Lines changed: 1828 additions & 162 deletions

.coverage-threshold

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
66.8
1+
67.1

cmd/image-composer-tool/cache.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ caches or to restrict cleanup to a specific provider.`,
9393

9494
writer := cmd.OutOrStdout()
9595
for _, line := range output {
96-
fmt.Fprintln(writer, line)
96+
if _, err := fmt.Fprintln(writer, line); err != nil {
97+
return fmt.Errorf("failed to write cache output: %w", err)
98+
}
9799
}
98100

99101
return nil

cmd/image-composer-tool/completion.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,13 @@ func dirWritable(p string) bool {
172172
if err != nil {
173173
return false
174174
}
175-
tf.Close()
176-
_ = os.Remove(tf.Name())
175+
name := tf.Name()
176+
if err := tf.Close(); err != nil {
177+
_ = os.Remove(name)
178+
return false
179+
}
180+
if err := os.Remove(name); err != nil {
181+
return false
182+
}
177183
return true
178184
}

cmd/image-composer-tool/version_test.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ func captureOutput(t *testing.T, fn func()) string {
2020
if err != nil {
2121
t.Fatalf("pipe: %v", err)
2222
}
23-
defer pr.Close()
23+
defer func() {
24+
if err := pr.Close(); err != nil {
25+
t.Errorf("close read pipe: %v", err)
26+
}
27+
}()
2428

2529
oldOut := os.Stdout
2630
oldErr := os.Stderr
@@ -43,7 +47,9 @@ func captureOutput(t *testing.T, fn func()) string {
4347
fn()
4448

4549
// Close writer to unblock reader goroutine
46-
_ = pw.Close()
50+
if err := pw.Close(); err != nil {
51+
t.Fatalf("close write pipe: %v", err)
52+
}
4753

4854
// Wait for the captured output
4955
return <-done

docs/release-notes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
- Ability to set priority for repositories to manage conflicts.
1414
- Ability to prioritize specific packages to manage conflicts.
1515
- Caching for consistent and faster composition.
16+
- Debian repository GPG keys are now cached in `cache_dir/gpg-keys` and reused on rebuilds to avoid re-downloading.
17+
- RPM repository metadata is now cached in `cache_dir/rpm-metadata` and reused on rebuilds to avoid network fetches.
1618
- Native support for Debian and RPM based distributions.
1719
- Support for building immutable OS images with DM-Verity and read-only file
1820
system support.

internal/chroot/chrootbuild/chrootbuild.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,14 @@ func (chrootBuilder *ChrootBuilder) GetChrootEnvPackageList() ([]string, error)
230230
return pkgList, nil
231231
}
232232

233+
// chrootenvPkgCacheDir returns the isolated subdirectory used to download and install
234+
// the chroot-build packages (e.g. mmdebstrap, grub). Keeping it separate from
235+
// ChrootPkgCacheDir (the image-package cache) prevents the stale-cache check from
236+
// wiping the image packages when the two package sets do not overlap.
237+
func (chrootBuilder *ChrootBuilder) chrootenvPkgCacheDir() string {
238+
return filepath.Join(chrootBuilder.ChrootPkgCacheDir, "chrootenv")
239+
}
240+
233241
func (chrootBuilder *ChrootBuilder) downloadChrootEnvPackages() ([]string, []string, error) {
234242
var pkgsList []string
235243
var allPkgsList []string
@@ -245,23 +253,24 @@ func (chrootBuilder *ChrootBuilder) downloadChrootEnvPackages() ([]string, []str
245253
}
246254
pkgsList = append(essentialPkgsList, pkgsList...)
247255

248-
if _, err := os.Stat(chrootBuilder.ChrootPkgCacheDir); os.IsNotExist(err) {
249-
if err := os.MkdirAll(chrootBuilder.ChrootPkgCacheDir, 0700); err != nil {
256+
downloadDir := chrootBuilder.chrootenvPkgCacheDir()
257+
if _, err := os.Stat(downloadDir); os.IsNotExist(err) {
258+
if err := os.MkdirAll(downloadDir, 0700); err != nil {
250259
log.Errorf("Failed to create chroot package cache directory: %v", err)
251260
return pkgsList, allPkgsList, fmt.Errorf("failed to create chroot package cache directory: %w", err)
252261
}
253262
}
254263

255-
dotFilePath := filepath.Join(chrootBuilder.ChrootPkgCacheDir, "chrootpkgs.dot")
264+
dotFilePath := filepath.Join(downloadDir, "chrootpkgs.dot")
256265

257266
if pkgType == "rpm" {
258-
allPkgsList, err = rpmutils.DownloadPackages(pkgsList, chrootBuilder.ChrootPkgCacheDir, dotFilePath, nil, false)
267+
allPkgsList, err = rpmutils.DownloadPackages(pkgsList, downloadDir, dotFilePath, nil, false)
259268
if err != nil {
260269
return pkgsList, allPkgsList, fmt.Errorf("failed to download chroot environment packages: %w", err)
261270
}
262271
return pkgsList, allPkgsList, nil
263272
} else if pkgType == "deb" {
264-
allPkgsList, err = debutils.DownloadPackages(pkgsList, chrootBuilder.ChrootPkgCacheDir, dotFilePath, nil, false)
273+
allPkgsList, err = debutils.DownloadPackages(pkgsList, downloadDir, dotFilePath, nil, false)
265274
if err != nil {
266275
return pkgsList, allPkgsList, fmt.Errorf("failed to download chroot environment packages: %w", err)
267276
}
@@ -294,19 +303,21 @@ func (chrootBuilder *ChrootBuilder) BuildChrootEnv(targetOs string, targetDist s
294303
}
295304
log.Infof("Downloaded %d packages for chroot environment", len(allPkgsList))
296305

297-
chrootPkgCacheDir := chrootBuilder.GetChrootPkgCacheDir()
306+
// Use the isolated chrootenv download dir (not the shared image-package cache dir)
307+
// so that UpdateLocalDebRepo and InstallDebPkg operate on the chrootenv-specific packages.
308+
chrootenvDir := chrootBuilder.chrootenvPkgCacheDir()
298309
if pkgType == "rpm" {
299310
if err := chrootBuilder.RpmInstaller.InstallRpmPkg(targetOs, chrootEnvPath,
300-
chrootPkgCacheDir, allPkgsList); err != nil {
311+
chrootenvDir, allPkgsList); err != nil {
301312
return fmt.Errorf("failed to install packages in chroot environment: %w", err)
302313
}
303314
} else if pkgType == "deb" {
304-
if err = chrootBuilder.DebInstaller.UpdateLocalDebRepo(chrootPkgCacheDir, targetArch, false); err != nil {
315+
if err = chrootBuilder.DebInstaller.UpdateLocalDebRepo(chrootenvDir, targetArch, false); err != nil {
305316
return fmt.Errorf("failed to create debian local repository: %w", err)
306317
}
307318

308319
if err := chrootBuilder.DebInstaller.InstallDebPkg(chrootBuilder.TargetOsConfigDir,
309-
chrootEnvPath, chrootPkgCacheDir, pkgsList); err != nil {
320+
chrootEnvPath, chrootenvDir, pkgsList); err != nil {
310321
return fmt.Errorf("failed to install packages in chroot environment: %w", err)
311322
}
312323
} else {

internal/config/apt_sources.go

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package config
22

33
import (
44
"bytes"
5+
"crypto/sha256"
6+
"encoding/hex"
57
"fmt"
68
"io"
79
"net/http"
@@ -391,9 +393,8 @@ func (t *ImageTemplate) downloadAndAddGPGKeys(repos []PackageRepository) error {
391393
return fmt.Errorf("failed to read local GPG key from %s: %w", repo.PKey, err)
392394
}
393395
} else {
394-
// For URLs, download the GPG key
395-
log.Infof("Downloading GPG key for repository %s from %s", getRepositoryName(repo), repo.PKey)
396-
keyData, err = downloadGPGKey(repo.PKey)
396+
// For URLs, prefer persistent cache and download only on cache miss
397+
keyData, err = getCachedOrDownloadGPGKey(repo.PKey)
397398
if err != nil {
398399
return fmt.Errorf("failed to download GPG key from %s: %w", repo.PKey, err)
399400
}
@@ -431,6 +432,58 @@ func (t *ImageTemplate) downloadAndAddGPGKeys(repos []PackageRepository) error {
431432
return nil
432433
}
433434

435+
// gpgKeyCacheFilePath returns the persistent cache path for a repository GPG key URL.
436+
func gpgKeyCacheFilePath(keyURL string) (string, error) {
437+
cacheDir, err := CacheDir()
438+
if err != nil {
439+
return "", fmt.Errorf("resolve cache directory: %w", err)
440+
}
441+
442+
gpgCacheDir := filepath.Join(cacheDir, "gpg-keys")
443+
if err := os.MkdirAll(gpgCacheDir, 0755); err != nil {
444+
return "", fmt.Errorf("create GPG cache directory %s: %w", gpgCacheDir, err)
445+
}
446+
447+
hash := sha256.Sum256([]byte(keyURL))
448+
hashHex := hex.EncodeToString(hash[:])
449+
450+
return filepath.Join(gpgCacheDir, fmt.Sprintf("%s.gpg", hashHex)), nil
451+
}
452+
453+
// getCachedOrDownloadGPGKey loads a key from persistent cache, downloading only on cache miss.
454+
func getCachedOrDownloadGPGKey(keyURL string) ([]byte, error) {
455+
log := logger.Logger()
456+
457+
cacheFilePath, err := gpgKeyCacheFilePath(keyURL)
458+
if err == nil {
459+
if cachedData, readErr := os.ReadFile(cacheFilePath); readErr == nil {
460+
log.Infof("Using cached GPG key (%d bytes) for %s", len(cachedData), keyURL)
461+
return cachedData, nil
462+
}
463+
} else {
464+
// Cache path failures should not block build; fall back to direct download.
465+
log.Warnf("Failed to initialize GPG key cache for %s: %v", keyURL, err)
466+
}
467+
468+
log.Infof("Downloading GPG key from %s", keyURL)
469+
keyData, err := downloadGPGKey(keyURL)
470+
if err != nil {
471+
return nil, err
472+
}
473+
474+
if cacheFilePath == "" {
475+
return keyData, nil
476+
}
477+
478+
if writeErr := os.WriteFile(cacheFilePath, keyData, 0644); writeErr != nil {
479+
log.Warnf("Failed to persist GPG key cache for %s at %s: %v", keyURL, cacheFilePath, writeErr)
480+
return keyData, nil
481+
}
482+
483+
log.Infof("Cached GPG key at %s", cacheFilePath)
484+
return keyData, nil
485+
}
486+
434487
// downloadGPGKey downloads a GPG key from the given URL
435488
func downloadGPGKey(keyURL string) ([]byte, error) {
436489
log := logger.Logger()

internal/config/apt_sources_integration_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func resolveAdditionalFilePath(relativePath string) (string, error) {
6666
func TestIntegrationAptSourcesGeneration(t *testing.T) {
6767
sedKeyPath := createLocalTestGPGKey(t, "sed-test-key-*.gpg")
6868
openvinoKeyPath := createLocalTestGPGKey(t, "openvino-test-key-*.gpg")
69+
6970
// Create a realistic test template similar to the example
7071
template := &ImageTemplate{
7172
Image: ImageInfo{
@@ -162,6 +163,7 @@ func TestIntegrationAptSourcesGeneration(t *testing.T) {
162163
func TestIntegrationAptPreferencesGeneration(t *testing.T) {
163164
sedKeyPath := createLocalTestGPGKey(t, "sed-test-key-*.gpg")
164165
openvinoKeyPath := createLocalTestGPGKey(t, "openvino-test-key-*.gpg")
166+
165167
// Create a realistic test template with priorities
166168
template := &ImageTemplate{
167169
Image: ImageInfo{

internal/config/apt_sources_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,3 +764,60 @@ func TestCreateTempAptPreferencesFile(t *testing.T) {
764764
t.Errorf("File content mismatch. Expected:\n%s\nGot:\n%s", content, string(fileContent))
765765
}
766766
}
767+
768+
func TestGPGKeyCacheFilePath_StableForSameURL(t *testing.T) {
769+
oldCacheDir := Global().CacheDir
770+
Global().CacheDir = t.TempDir()
771+
t.Cleanup(func() {
772+
Global().CacheDir = oldCacheDir
773+
})
774+
775+
keyURL := "https://example.com/keys/repo-key.gpg"
776+
777+
path1, err := gpgKeyCacheFilePath(keyURL)
778+
if err != nil {
779+
t.Fatalf("gpgKeyCacheFilePath failed: %v", err)
780+
}
781+
782+
path2, err := gpgKeyCacheFilePath(keyURL)
783+
if err != nil {
784+
t.Fatalf("gpgKeyCacheFilePath failed on second call: %v", err)
785+
}
786+
787+
if path1 != path2 {
788+
t.Errorf("Expected stable cache path, got %q and %q", path1, path2)
789+
}
790+
791+
if !strings.HasSuffix(path1, ".gpg") {
792+
t.Errorf("Expected cache path to end with .gpg, got %q", path1)
793+
}
794+
}
795+
796+
func TestGetCachedOrDownloadGPGKey_UsesCacheOnHit(t *testing.T) {
797+
oldCacheDir := Global().CacheDir
798+
Global().CacheDir = t.TempDir()
799+
t.Cleanup(func() {
800+
Global().CacheDir = oldCacheDir
801+
})
802+
803+
keyURL := "https://invalid.example.test/non-routable-key.gpg"
804+
want := []byte("cached-key-data")
805+
806+
cachePath, err := gpgKeyCacheFilePath(keyURL)
807+
if err != nil {
808+
t.Fatalf("gpgKeyCacheFilePath failed: %v", err)
809+
}
810+
811+
if err := os.WriteFile(cachePath, want, 0644); err != nil {
812+
t.Fatalf("failed to seed cached key: %v", err)
813+
}
814+
815+
got, err := getCachedOrDownloadGPGKey(keyURL)
816+
if err != nil {
817+
t.Fatalf("getCachedOrDownloadGPGKey should use cache hit, got error: %v", err)
818+
}
819+
820+
if string(got) != string(want) {
821+
t.Errorf("cache hit returned wrong key data: got %q want %q", string(got), string(want))
822+
}
823+
}

internal/image/imageos/imageos.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,13 +491,21 @@ func (imageOs *ImageOs) initDebLocalRepoWithinInstallRoot(installRoot string) er
491491
}
492492

493493
// from local.list
494-
repoPath := filepath.Join(chrootInstallRoot, "/cdrom/cache-repo")
494+
repoPath := filepath.Join(chrootInstallRoot, "cdrom", "cache-repo")
495495
chrootPkgCacheDir := imageOs.chrootEnv.GetChrootPkgCacheDir()
496496
if err := imageOs.chrootEnv.MountChrootPath(chrootPkgCacheDir, repoPath, "--bind"); err != nil {
497497
return fmt.Errorf("failed to mount package cache directory %s to chroot repo directory %s: %w",
498498
chrootPkgCacheDir, repoPath, err)
499499
}
500500

501+
if err := imageOs.chrootEnv.UpdateChrootLocalRepoMetadata(
502+
repoPath,
503+
imageOs.template.Target.Arch,
504+
false,
505+
); err != nil {
506+
return fmt.Errorf("failed to refresh local debian repository metadata: %w", err)
507+
}
508+
501509
imageRepoCongfigPath := filepath.Join(installRoot, "/etc/apt/sources.list.d/", "*")
502510
if _, err := shell.ExecCmd("rm -f "+imageRepoCongfigPath, true, shell.HostPath, nil); err != nil {
503511
log.Errorf("Failed to remove existing local repo config files: %v", err)

0 commit comments

Comments
 (0)