Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/images-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,20 @@ jobs:
run: |
pip install -r infrastructure/network/vyos/tests/requirements.txt

# Cache for downloaded ISOs and hook artifacts (rootfs.tar)
- name: Setup labctl cache
uses: actions/cache@v4
with:
path: ~/.cache/labctl
key: labctl-images-${{ hashFiles('images/images.yaml') }}
restore-keys: |
labctl-images-

# PR: run full sync without upload (tests hooks, no credentials needed)
- name: Sync Images (PR - no upload)
if: github.event_name == 'pull_request'
run: |
./labctl images sync --no-upload
./labctl images sync --no-upload --cache-dir ~/.cache/labctl

# Push/dispatch: full sync with credentials
- name: Install SOPS
Expand All @@ -120,6 +129,7 @@ jobs:
./labctl images sync \
--credentials images/e2.sops.yaml \
--sops-age-key-file /tmp/age-key.txt \
--cache-dir ~/.cache/labctl \
$FLAGS

- name: Create PR if files changed
Expand Down
49 changes: 37 additions & 12 deletions infrastructure/network/vyos/scripts/iso-to-container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOCKERFILE="${SCRIPT_DIR}/../Dockerfile.containerlab"

# Cache directory for rootfs.tar (set by labctl via LABCTL_HOOK_CACHE)
CACHE_DIR="${LABCTL_HOOK_CACHE:-}"

usage() {
echo "Usage: $0 <iso-path> [image-name:tag]"
echo ""
Expand Down Expand Up @@ -66,21 +69,43 @@ trap cleanup EXIT

mkdir -p "${WORK_DIR}"

echo "Extracting squashfs from ISO..."
7z x -o"${WORK_DIR}" "${ISO_PATH}" "live/filesystem.squashfs" -y >/dev/null
# Check for cached rootfs.tar
CACHED_ROOTFS=""
if [[ -n "${CACHE_DIR}" ]]; then
# Use first 12 chars of ISO sha256 as cache key
ISO_HASH=$(sha256sum "${ISO_PATH}" | cut -d' ' -f1 | head -c 12)
CACHED_ROOTFS="${CACHE_DIR}/rootfs-${ISO_HASH}.tar"

SQUASHFS="${WORK_DIR}/live/filesystem.squashfs"
if [[ ! -f "${SQUASHFS}" ]]; then
echo "ERROR: filesystem.squashfs not found in ISO"
echo "Contents of ${WORK_DIR}:"
find "${WORK_DIR}" -type f
exit 1
if [[ -f "${CACHED_ROOTFS}" ]]; then
echo "Using cached rootfs.tar: ${CACHED_ROOTFS}"
ROOTFS_TAR="${CACHED_ROOTFS}"
fi
fi

echo "Converting squashfs to rootfs.tar..."
ROOTFS_TAR="${WORK_DIR}/rootfs.tar"
sqfs2tar "${SQUASHFS}" > "${ROOTFS_TAR}"
echo "rootfs.tar size: $(ls -lh "${ROOTFS_TAR}" | awk '{print $5}')"
# Extract and convert if not cached
if [[ -z "${ROOTFS_TAR:-}" ]]; then
echo "Extracting squashfs from ISO..."
7z x -o"${WORK_DIR}" "${ISO_PATH}" "live/filesystem.squashfs" -y >/dev/null

SQUASHFS="${WORK_DIR}/live/filesystem.squashfs"
if [[ ! -f "${SQUASHFS}" ]]; then
echo "ERROR: filesystem.squashfs not found in ISO"
echo "Contents of ${WORK_DIR}:"
find "${WORK_DIR}" -type f
exit 1
fi

echo "Converting squashfs to rootfs.tar..."
ROOTFS_TAR="${WORK_DIR}/rootfs.tar"
sqfs2tar "${SQUASHFS}" > "${ROOTFS_TAR}"
echo "rootfs.tar size: $(ls -lh "${ROOTFS_TAR}" | awk '{print $5}')"

# Cache the rootfs.tar for future use
if [[ -n "${CACHED_ROOTFS}" ]]; then
echo "Caching rootfs.tar to: ${CACHED_ROOTFS}"
cp "${ROOTFS_TAR}" "${CACHED_ROOTFS}"
fi
fi

echo "Building container image: ${IMAGE_TAG}..."
BUILD_CONTEXT="${WORK_DIR}/build"
Expand Down
117 changes: 98 additions & 19 deletions tools/labctl/cmd/images/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/spf13/cobra"
"github.com/ulikunitz/xz"

"github.com/GilmanLab/lab/tools/labctl/internal/cache"
"github.com/GilmanLab/lab/tools/labctl/internal/config"
"github.com/GilmanLab/lab/tools/labctl/internal/credentials"
"github.com/GilmanLab/lab/tools/labctl/internal/hooks"
Expand Down Expand Up @@ -50,6 +51,7 @@ var (
syncForce bool
syncSkipHooks bool
syncNoUpload bool
syncCacheDir string
)

func init() {
Expand All @@ -60,6 +62,7 @@ func init() {
syncCmd.Flags().BoolVar(&syncForce, "force", false, "Force re-upload even if checksums match")
syncCmd.Flags().BoolVar(&syncSkipHooks, "skip-hooks", false, "Skip pre-upload hooks")
syncCmd.Flags().BoolVar(&syncNoUpload, "no-upload", false, "Download and run hooks but skip upload (for testing)")
syncCmd.Flags().StringVar(&syncCacheDir, "cache-dir", "", "Local cache directory for downloads and hooks")
}

func runSync(_ *cobra.Command, _ []string) error {
Expand All @@ -74,6 +77,17 @@ func runSync(_ *cobra.Command, _ []string) error {
fmt.Printf("Syncing images from manifest: %s\n", syncManifest)
fmt.Printf("Found %d image(s)\n\n", len(manifest.Spec.Images))

// Set up local cache if configured
var cacheManager *cache.Manager
if syncCacheDir != "" {
var err error
cacheManager, err = cache.NewManager(syncCacheDir)
if err != nil {
return fmt.Errorf("create cache manager: %w", err)
}
fmt.Printf("Using cache directory: %s\n\n", syncCacheDir)
}

// Skip credentials and S3 client setup in dry-run or no-upload mode
var client *store.S3Client
var hookExecutor *hooks.Executor
Expand All @@ -95,19 +109,19 @@ func runSync(_ *cobra.Command, _ []string) error {

// Create hook executor with caching (unless skipped)
if !syncSkipHooks {
hookExecutor = hooks.NewExecutor(client)
hookExecutor = hooks.NewExecutor(client, syncCacheDir)
}
} else if syncNoUpload && !syncSkipHooks {
// In no-upload mode, create hook executor without caching
hookExecutor = hooks.NewExecutor(nil)
// In no-upload mode, create hook executor without S3 caching
hookExecutor = hooks.NewExecutor(nil, syncCacheDir)
}

// Track if any files were changed (for GitHub Actions output)
filesChanged := false

// Process each image
for _, img := range manifest.Spec.Images {
changed, err := syncImageWithHTTP(ctx, client, http.DefaultClient, hookExecutor, img, syncDryRun, syncForce, syncNoUpload)
changed, err := syncImageWithHTTP(ctx, client, http.DefaultClient, hookExecutor, cacheManager, img, syncDryRun, syncForce, syncNoUpload)
if err != nil {
return fmt.Errorf("sync image %q: %w", img.Name, err)
}
Expand All @@ -132,13 +146,13 @@ func runSync(_ *cobra.Command, _ []string) error {

// syncImage syncs an image using the default HTTP client.
// This is a convenience wrapper for syncImageWithHTTP.
func syncImage(ctx context.Context, client store.Client, hookExecutor *hooks.Executor, img config.Image, dryRun, force, noUpload bool) (bool, error) {
return syncImageWithHTTP(ctx, client, http.DefaultClient, hookExecutor, img, dryRun, force, noUpload)
func syncImage(ctx context.Context, client store.Client, hookExecutor *hooks.Executor, cacheManager *cache.Manager, img config.Image, dryRun, force, noUpload bool) (bool, error) {
return syncImageWithHTTP(ctx, client, http.DefaultClient, hookExecutor, cacheManager, img, dryRun, force, noUpload)
}

// syncImageWithHTTP syncs an image using the provided HTTP and store clients.
// This function enables dependency injection for testing.
func syncImageWithHTTP(ctx context.Context, client store.Client, httpClient HTTPClient, hookExecutor *hooks.Executor, img config.Image, dryRun, force, noUpload bool) (bool, error) {
func syncImageWithHTTP(ctx context.Context, client store.Client, httpClient HTTPClient, hookExecutor *hooks.Executor, cacheManager *cache.Manager, img config.Image, dryRun, force, noUpload bool) (bool, error) {
fmt.Printf("Processing: %s\n", img.Name)

effectiveChecksum := img.EffectiveChecksum()
Expand All @@ -164,24 +178,89 @@ func syncImageWithHTTP(ctx context.Context, client store.Client, httpClient HTTP
return false, nil
}

// Download source image to temp file
fmt.Printf(" Downloading from: %s\n", img.Source.URL)
tempFile, size, err := downloadToTempWithClient(ctx, httpClient, img.Source.URL)
if err != nil {
return false, fmt.Errorf("download: %w", err)
// Download source image (with cache check)
var tempFile *os.File
var size int64
var fromCache bool

if cacheManager != nil {
if cachePath, ok := cacheManager.Get(img.Source.Checksum); ok {
// Found in cache - verify checksum before using
fmt.Printf(" Using cached: %s\n", cachePath)
f, err := os.Open(cachePath) //nolint:gosec // G304: Path from trusted cache manager
if err != nil {
// Cache file not accessible, fall through to download
fmt.Printf(" Cache error, will download: %v\n", err)
} else {
// Verify cached file checksum
if err := verifyChecksum(f, img.Source.Checksum); err != nil {
fmt.Printf(" Cache checksum mismatch, will download: %v\n", err)
_ = f.Close()
_ = cacheManager.Remove(img.Source.Checksum)
} else {
if _, err := f.Seek(0, 0); err != nil {
_ = f.Close()
return false, fmt.Errorf("seek cached file: %w", err)
}
stat, _ := f.Stat()
tempFile = f
size = stat.Size()
fromCache = true
}
}
}
}

if tempFile == nil {
// Not in cache or cache disabled - download
fmt.Printf(" Downloading from: %s\n", img.Source.URL)
var err error
tempFile, size, err = downloadToTempWithClient(ctx, httpClient, img.Source.URL)
if err != nil {
return false, fmt.Errorf("download: %w", err)
}

// Verify source checksum
fmt.Printf(" Verifying source checksum...\n")
if _, err := tempFile.Seek(0, 0); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
return false, fmt.Errorf("seek temp file: %w", err)
}
if err := verifyChecksum(tempFile, img.Source.Checksum); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
return false, fmt.Errorf("source checksum verification: %w", err)
}

// Store in cache for future use
if cacheManager != nil {
if _, err := tempFile.Seek(0, 0); err != nil {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
return false, fmt.Errorf("seek temp file for cache: %w", err)
}
cachePath, err := cacheManager.Put(img.Source.Checksum, tempFile)
if err != nil {
// Log but don't fail - caching is optional
fmt.Printf(" Warning: failed to cache: %v\n", err)
} else {
fmt.Printf(" Cached to: %s\n", cachePath)
}
}
}

// Set up cleanup - only remove temp files, not cached files
defer func() {
_ = tempFile.Close()
_ = os.Remove(tempFile.Name())
if !fromCache {
_ = os.Remove(tempFile.Name())
}
}()

// Verify source checksum
fmt.Printf(" Verifying source checksum...\n")
// Reset file position after checksum verification
if _, err := tempFile.Seek(0, 0); err != nil {
return false, fmt.Errorf("seek temp file: %w", err)
}
if err := verifyChecksum(tempFile, img.Source.Checksum); err != nil {
return false, fmt.Errorf("source checksum verification: %w", err)
return false, fmt.Errorf("seek file: %w", err)
}

// Decompress if needed
Expand Down
26 changes: 13 additions & 13 deletions tools/labctl/cmd/images/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ func TestSyncImage(t *testing.T) {
},
}

changed, err := syncImage(context.Background(), client, nil, img, false, false, false)
changed, err := syncImage(context.Background(), client, nil, nil, img, false, false, false)

require.NoError(t, err)
assert.False(t, changed)
Expand All @@ -244,7 +244,7 @@ func TestSyncImage(t *testing.T) {
},
}

changed, err := syncImage(context.Background(), client, nil, img, true, false, false)
changed, err := syncImage(context.Background(), client, nil, nil, img, true, false, false)

require.NoError(t, err)
assert.False(t, changed)
Expand Down Expand Up @@ -273,7 +273,7 @@ func TestSyncImage(t *testing.T) {

// With force=true and dryRun=true, it should show what would be done
// without checking checksum
_, err := syncImage(context.Background(), client, nil, img, true, true, false)
_, err := syncImage(context.Background(), client, nil, nil, img, true, true, false)

require.NoError(t, err)
assert.False(t, checksumChecked) // Should not check checksum with force
Expand All @@ -295,7 +295,7 @@ func TestSyncImage(t *testing.T) {
},
}

_, err := syncImage(context.Background(), client, nil, img, false, false, false)
_, err := syncImage(context.Background(), client, nil, nil, img, false, false, false)

assert.Error(t, err)
assert.Contains(t, err.Error(), "check existing image")
Expand All @@ -321,7 +321,7 @@ func TestSyncImage(t *testing.T) {

// With noUpload=true, should skip checksum check (client is nil for noUpload)
// and also skip upload - this test verifies the skip behavior
changed, err := syncImage(context.Background(), client, nil, img, true, false, false)
changed, err := syncImage(context.Background(), client, nil, nil, img, true, false, false)

require.NoError(t, err)
assert.False(t, changed)
Expand Down Expand Up @@ -379,7 +379,7 @@ func TestSyncImageWithHTTP(t *testing.T) {
},
}

changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, img, false, false, false)
changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false)

require.NoError(t, err)
assert.False(t, changed) // No updateFile, so no file changes
Expand Down Expand Up @@ -449,7 +449,7 @@ func TestSyncImageWithHTTP(t *testing.T) {
},
}

changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, img, false, false, false)
changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false)

require.NoError(t, err)
assert.False(t, changed)
Expand Down Expand Up @@ -484,7 +484,7 @@ func TestSyncImageWithHTTP(t *testing.T) {
},
}

_, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, img, false, false, false)
_, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false)

assert.Error(t, err)
assert.Contains(t, err.Error(), "download")
Expand Down Expand Up @@ -514,7 +514,7 @@ func TestSyncImageWithHTTP(t *testing.T) {
},
}

_, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, img, false, false, false)
_, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false)

assert.Error(t, err)
assert.Contains(t, err.Error(), "source checksum verification")
Expand Down Expand Up @@ -548,7 +548,7 @@ func TestSyncImageWithHTTP(t *testing.T) {
},
}

_, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, img, false, false, false)
_, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false)

assert.Error(t, err)
assert.Contains(t, err.Error(), "upload")
Expand Down Expand Up @@ -585,7 +585,7 @@ func TestSyncImageWithHTTP(t *testing.T) {
},
}

_, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, img, false, false, false)
_, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false)

assert.Error(t, err)
assert.Contains(t, err.Error(), "write metadata")
Expand Down Expand Up @@ -628,7 +628,7 @@ func TestSyncImageWithHTTP(t *testing.T) {
},
}

_, err = syncImageWithHTTP(context.Background(), client, server.Client(), nil, img, false, false, false)
_, err = syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false)

assert.Error(t, err)
assert.Contains(t, err.Error(), "decompressed checksum verification")
Expand Down Expand Up @@ -676,7 +676,7 @@ func TestSyncImageWithHTTP(t *testing.T) {
}

// noUpload=true should download, verify, but skip upload
changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, img, false, false, true)
changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, true)

require.NoError(t, err)
assert.False(t, changed)
Expand Down
Loading