diff --git a/images/hooks/vyos-test.sh b/images/hooks/vyos-test.sh index 72d7e7a..f6d680b 100755 --- a/images/hooks/vyos-test.sh +++ b/images/hooks/vyos-test.sh @@ -144,18 +144,25 @@ wait_for_vyos() { sudo docker exec "${container}" modprobe br_netfilter 2>/dev/null || true sudo docker exec "${container}" modprobe 8021q 2>/dev/null || true - # Wait for VyOS config to be applied - # Config migration takes ~100 seconds in container environments - for i in {1..90}; do - if docker logs "${container}" 2>&1 | grep -q "migrate.*configure"; then - echo " VyOS config migration detected" - sleep 15 + # Wait for container to become healthy + # The Dockerfile healthcheck uses: systemctl is-system-running --quiet + echo " Waiting for container to become healthy..." + for i in {1..60}; do + health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown") + if [[ "${health}" == "healthy" ]]; then + echo " Container is healthy" break fi - echo " Waiting for VyOS config... ($i/90)" + if [[ $i -eq 60 ]]; then + echo " WARNING: Container did not become healthy within timeout" + fi sleep 2 done + # Wait briefly for VyOS services to fully initialize after systemd reports ready + echo " Waiting for VyOS services to initialize..." + sleep 5 + # Verify configuration loaded echo " Verifying configuration..." docker exec "${container}" /opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands | head -5 diff --git a/images/images.yaml b/images/images.yaml index 289a463..65e4642 100644 --- a/images/images.yaml +++ b/images/images.yaml @@ -41,3 +41,8 @@ spec: command: ./images/hooks/talos-embed-config.sh args: ["cp-1"] timeout: 10m + # Declare input files that affect the transform output. + # Changes to these files will trigger a re-sync. + inputs: + - "infrastructure/compute/talos/talconfig.yaml" + - "infrastructure/compute/talos/talsecret.sops.yaml" diff --git a/tools/labctl/cmd/images/sync.go b/tools/labctl/cmd/images/sync.go index 07cd0bc..be3fe71 100644 --- a/tools/labctl/cmd/images/sync.go +++ b/tools/labctl/cmd/images/sync.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "time" @@ -121,9 +122,19 @@ func runSync(_ *cobra.Command, _ []string) error { // Track if any files were changed (for GitHub Actions output) filesChanged := false + // Determine base directory for resolving hook input paths. + // Use the manifest's parent directory as the base. + manifestDir := filepath.Dir(syncManifest) + baseDir, err := filepath.Abs(manifestDir) + if err != nil { + return fmt.Errorf("resolve manifest directory: %w", err) + } + // Go up one level from images/ to repo root for input path resolution + baseDir = filepath.Dir(baseDir) + // Process each image for _, img := range manifest.Spec.Images { - changed, err := syncImageWithHTTP(ctx, client, http.DefaultClient, hookExecutor, cacheManager, img, syncDryRun, syncForce, syncNoUpload, syncSkipTransformHooks) + changed, err := syncImageWithHTTP(ctx, client, http.DefaultClient, hookExecutor, cacheManager, img, baseDir, syncDryRun, syncForce, syncNoUpload, syncSkipTransformHooks) if err != nil { return fmt.Errorf("sync image %q: %w", img.Name, err) } @@ -148,16 +159,21 @@ func runSync(_ *cobra.Command, _ []string) error { // syncImage syncs a single image using the default HTTP client. // This is a convenience wrapper for syncImageWithHTTP. -func syncImage(ctx context.Context, client store.Client, hookExecutor *hooks.Executor, cacheManager *cache.Manager, img config.Image, dryRun, force, noUpload, skipTransformHooks bool) (bool, error) { - return syncImageWithHTTP(ctx, client, http.DefaultClient, hookExecutor, cacheManager, img, dryRun, force, noUpload, skipTransformHooks) +func syncImage(ctx context.Context, client store.Client, hookExecutor *hooks.Executor, cacheManager *cache.Manager, img config.Image, baseDir string, dryRun, force, noUpload, skipTransformHooks bool) (bool, error) { + return syncImageWithHTTP(ctx, client, http.DefaultClient, hookExecutor, cacheManager, img, baseDir, dryRun, force, noUpload, skipTransformHooks) } // 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, cacheManager *cache.Manager, img config.Image, dryRun, force, noUpload, skipTransformHooks bool) (bool, error) { +// The baseDir parameter specifies the directory for resolving hook input paths. +func syncImageWithHTTP(ctx context.Context, client store.Client, httpClient HTTPClient, hookExecutor *hooks.Executor, cacheManager *cache.Manager, img config.Image, baseDir string, dryRun, force, noUpload, skipTransformHooks bool) (bool, error) { fmt.Printf("Processing: %s\n", img.Name) - effectiveChecksum := img.EffectiveChecksum() + // Compute effective checksum including hook input files + effectiveChecksum, err := img.EffectiveChecksumWithInputs(baseDir) + if err != nil { + return false, fmt.Errorf("compute effective checksum: %w", err) + } // Check if image already exists with matching checksum (skip in no-upload mode) if !dryRun && !force && !noUpload { diff --git a/tools/labctl/cmd/images/sync_test.go b/tools/labctl/cmd/images/sync_test.go index 93f4feb..f6df9b6 100644 --- a/tools/labctl/cmd/images/sync_test.go +++ b/tools/labctl/cmd/images/sync_test.go @@ -226,7 +226,7 @@ func TestSyncImage(t *testing.T) { }, } - changed, err := syncImage(context.Background(), client, nil, nil, img, false, false, false, false) + changed, err := syncImage(context.Background(), client, nil, nil, img, t.TempDir(), false, false, false, false) require.NoError(t, err) assert.False(t, changed) @@ -245,7 +245,7 @@ func TestSyncImage(t *testing.T) { }, } - changed, err := syncImage(context.Background(), client, nil, nil, img, true, false, false, false) + changed, err := syncImage(context.Background(), client, nil, nil, img, t.TempDir(), true, false, false, false) require.NoError(t, err) assert.False(t, changed) @@ -274,7 +274,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, nil, img, true, true, false, false) + _, err := syncImage(context.Background(), client, nil, nil, img, t.TempDir(), true, true, false, false) require.NoError(t, err) assert.False(t, checksumChecked) // Should not check checksum with force @@ -296,7 +296,7 @@ func TestSyncImage(t *testing.T) { }, } - _, err := syncImage(context.Background(), client, nil, nil, img, false, false, false, false) + _, err := syncImage(context.Background(), client, nil, nil, img, t.TempDir(), false, false, false, false) assert.Error(t, err) assert.Contains(t, err.Error(), "check existing image") @@ -322,7 +322,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, nil, img, true, false, false, false) + changed, err := syncImage(context.Background(), client, nil, nil, img, t.TempDir(), true, false, false, false) require.NoError(t, err) assert.False(t, changed) @@ -380,7 +380,7 @@ func TestSyncImageWithHTTP(t *testing.T) { }, } - changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false, false) + changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, t.TempDir(), false, false, false, false) require.NoError(t, err) assert.False(t, changed) // No updateFile, so no file changes @@ -450,7 +450,7 @@ func TestSyncImageWithHTTP(t *testing.T) { }, } - changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false, false) + changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, t.TempDir(), false, false, false, false) require.NoError(t, err) assert.False(t, changed) @@ -485,7 +485,7 @@ func TestSyncImageWithHTTP(t *testing.T) { }, } - _, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false, false) + _, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, t.TempDir(), false, false, false, false) assert.Error(t, err) assert.Contains(t, err.Error(), "download") @@ -515,7 +515,7 @@ func TestSyncImageWithHTTP(t *testing.T) { }, } - _, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false, false) + _, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, t.TempDir(), false, false, false, false) assert.Error(t, err) assert.Contains(t, err.Error(), "source checksum verification") @@ -549,7 +549,7 @@ func TestSyncImageWithHTTP(t *testing.T) { }, } - _, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false, false) + _, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, t.TempDir(), false, false, false, false) assert.Error(t, err) assert.Contains(t, err.Error(), "upload") @@ -586,7 +586,7 @@ func TestSyncImageWithHTTP(t *testing.T) { }, } - _, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false, false) + _, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, t.TempDir(), false, false, false, false) assert.Error(t, err) assert.Contains(t, err.Error(), "write metadata") @@ -629,7 +629,7 @@ func TestSyncImageWithHTTP(t *testing.T) { }, } - _, err = syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, false, false) + _, err = syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, t.TempDir(), false, false, false, false) assert.Error(t, err) assert.Contains(t, err.Error(), "decompressed checksum verification") @@ -677,7 +677,7 @@ func TestSyncImageWithHTTP(t *testing.T) { } // noUpload=true should download, verify, but skip upload - changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, false, false, true, false) + changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), nil, nil, img, t.TempDir(), false, false, true, false) require.NoError(t, err) assert.False(t, changed) @@ -741,7 +741,7 @@ func TestSyncImageWithHTTP(t *testing.T) { }, } - changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), hookExecutor, nil, img, false, false, false, false) + changed, err := syncImageWithHTTP(context.Background(), client, server.Client(), hookExecutor, nil, img, dir, false, false, false, false) require.NoError(t, err) assert.False(t, changed) diff --git a/tools/labctl/internal/config/manifest.go b/tools/labctl/internal/config/manifest.go index b5ab467..f5e30bf 100644 --- a/tools/labctl/internal/config/manifest.go +++ b/tools/labctl/internal/config/manifest.go @@ -2,9 +2,14 @@ package config import ( + "crypto/sha256" + "encoding/hex" "fmt" + "io" "os" + "path/filepath" "regexp" + "sort" "strings" "time" @@ -69,6 +74,11 @@ type Hook struct { // WorkDir is the working directory for the command. // If not specified, uses the current working directory. WorkDir string `yaml:"workDir,omitempty"` + // Inputs declares files/globs that affect the hook's output. + // When specified, changes to these files will trigger a re-sync + // even if the source checksum matches. Paths are relative to the + // repository root. Supports glob patterns (e.g., "config/*.yaml"). + Inputs []string `yaml:"inputs,omitempty"` } // Source defines where to download the image from. @@ -96,8 +106,10 @@ type Replacement struct { Value string `yaml:"value"` // Template: {{ .Source.URL }}, {{ .Source.Checksum }} } -// EffectiveChecksum returns the checksum to use for idempotency checks. +// EffectiveChecksum returns the base checksum to use for idempotency checks. // If validation.expected is set, use that; otherwise use source.checksum. +// Note: This does not include hook input files. Use EffectiveChecksumWithInputs +// for a checksum that incorporates transform hook input file changes. func (i *Image) EffectiveChecksum() string { if i.Validation != nil && i.Validation.Expected != "" { return i.Validation.Expected @@ -105,6 +117,96 @@ func (i *Image) EffectiveChecksum() string { return i.Source.Checksum } +// EffectiveChecksumWithInputs returns a checksum that incorporates both the +// base checksum and the hash of any input files declared by transform hooks. +// This ensures that changes to transform hook inputs trigger a re-sync. +// If no inputs are declared, returns the base EffectiveChecksum(). +// The baseDir parameter specifies the directory relative to which input +// paths are resolved (typically the repository root or manifest directory). +func (i *Image) EffectiveChecksumWithInputs(baseDir string) (string, error) { + baseChecksum := i.EffectiveChecksum() + + // Collect all inputs from transform hooks + var allInputs []string + if i.Hooks != nil { + for _, h := range i.Hooks.Transform { + allInputs = append(allInputs, h.Inputs...) + } + } + + // If no inputs, return base checksum + if len(allInputs) == 0 { + return baseChecksum, nil + } + + // Compute hash of all input files + inputsHash, err := hashInputFiles(baseDir, allInputs) + if err != nil { + return "", fmt.Errorf("hash input files: %w", err) + } + + // Combine base checksum with inputs hash + // Format: "base_checksum+inputs:hash" + return baseChecksum + "+inputs:" + inputsHash, nil +} + +// hashInputFiles computes a combined SHA256 hash of all files matching the +// given glob patterns. Files are processed in sorted order for determinism. +func hashInputFiles(baseDir string, patterns []string) (string, error) { + // Expand all globs and collect unique file paths + fileSet := make(map[string]struct{}) + for _, pattern := range patterns { + fullPattern := filepath.Join(baseDir, pattern) + matches, err := filepath.Glob(fullPattern) + if err != nil { + return "", fmt.Errorf("invalid glob pattern %q: %w", pattern, err) + } + for _, match := range matches { + // Only include regular files, not directories + info, err := os.Stat(match) + if err != nil { + return "", fmt.Errorf("stat %q: %w", match, err) + } + if info.Mode().IsRegular() { + fileSet[match] = struct{}{} + } + } + } + + // Sort file paths for deterministic hashing + var files []string + for f := range fileSet { + files = append(files, f) + } + sort.Strings(files) + + // Compute combined hash + h := sha256.New() + for _, file := range files { + // Include relative path in hash (so renames are detected) + relPath, err := filepath.Rel(baseDir, file) + if err != nil { + relPath = file + } + h.Write([]byte(relPath)) + h.Write([]byte{0}) // Separator + + // Hash file contents + f, err := os.Open(file) //nolint:gosec // G304: Paths come from trusted manifest + if err != nil { + return "", fmt.Errorf("open %q: %w", file, err) + } + if _, err := io.Copy(h, f); err != nil { + _ = f.Close() + return "", fmt.Errorf("read %q: %w", file, err) + } + _ = f.Close() + h.Write([]byte{0}) // Separator between files + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + // FindImageByName returns the image with the given name, or nil if not found. func (m *ImageManifest) FindImageByName(name string) *Image { for i := range m.Spec.Images { diff --git a/tools/labctl/internal/config/manifest_test.go b/tools/labctl/internal/config/manifest_test.go index 1edfe5d..a03c206 100644 --- a/tools/labctl/internal/config/manifest_test.go +++ b/tools/labctl/internal/config/manifest_test.go @@ -291,6 +291,30 @@ spec: command: ./scripts/embed-config.sh args: ["--config", "machine.yaml"] timeout: 10m +`, + }, + { + name: "valid manifest with transform hooks with inputs", + yaml: `apiVersion: images.lab.gilman.io/v1alpha1 +kind: ImageManifest +metadata: + name: lab-images +spec: + images: + - name: talos-iso + source: + url: https://factory.talos.dev/image/talos-amd64.iso + checksum: sha256:abc123 + destination: talos/talos-amd64.iso + hooks: + transform: + - name: embed-config + command: ./scripts/embed-config.sh + args: ["--config", "machine.yaml"] + timeout: 10m + inputs: + - "infrastructure/compute/talos/talconfig.yaml" + - "infrastructure/compute/talos/**/*.yaml" `, }, { @@ -441,6 +465,212 @@ func TestImage_EffectiveChecksum(t *testing.T) { } } +func TestImage_EffectiveChecksumWithInputs(t *testing.T) { + t.Run("returns base checksum when no hooks", func(t *testing.T) { + img := Image{ + Source: Source{Checksum: "sha256:abc123"}, + } + + checksum, err := img.EffectiveChecksumWithInputs(t.TempDir()) + + require.NoError(t, err) + assert.Equal(t, "sha256:abc123", checksum) + }) + + t.Run("returns base checksum when hooks have no inputs", func(t *testing.T) { + img := Image{ + Source: Source{Checksum: "sha256:abc123"}, + Hooks: &Hooks{ + Transform: []Hook{ + {Name: "test-hook", Command: "echo"}, + }, + }, + } + + checksum, err := img.EffectiveChecksumWithInputs(t.TempDir()) + + require.NoError(t, err) + assert.Equal(t, "sha256:abc123", checksum) + }) + + t.Run("incorporates input file hash when inputs are declared", func(t *testing.T) { + dir := t.TempDir() + + // Create a test input file + inputFile := filepath.Join(dir, "config.yaml") + err := os.WriteFile(inputFile, []byte("key: value"), 0o600) + require.NoError(t, err) + + img := Image{ + Source: Source{Checksum: "sha256:abc123"}, + Hooks: &Hooks{ + Transform: []Hook{ + { + Name: "test-hook", + Command: "echo", + Inputs: []string{"config.yaml"}, + }, + }, + }, + } + + checksum, err := img.EffectiveChecksumWithInputs(dir) + + require.NoError(t, err) + // Checksum should have "+inputs:" suffix with hash + assert.Contains(t, checksum, "sha256:abc123+inputs:") + assert.Len(t, checksum, len("sha256:abc123+inputs:")+64) // SHA256 is 64 hex chars + }) + + t.Run("different file contents produce different checksums", func(t *testing.T) { + dir := t.TempDir() + + // Create first test file + inputFile := filepath.Join(dir, "config.yaml") + err := os.WriteFile(inputFile, []byte("key: value1"), 0o600) + require.NoError(t, err) + + img := Image{ + Source: Source{Checksum: "sha256:abc123"}, + Hooks: &Hooks{ + Transform: []Hook{ + {Name: "test-hook", Command: "echo", Inputs: []string{"config.yaml"}}, + }, + }, + } + + checksum1, err := img.EffectiveChecksumWithInputs(dir) + require.NoError(t, err) + + // Modify the file + err = os.WriteFile(inputFile, []byte("key: value2"), 0o600) + require.NoError(t, err) + + checksum2, err := img.EffectiveChecksumWithInputs(dir) + require.NoError(t, err) + + assert.NotEqual(t, checksum1, checksum2) + }) + + t.Run("handles glob patterns", func(t *testing.T) { + dir := t.TempDir() + + // Create test files matching glob + err := os.WriteFile(filepath.Join(dir, "file1.yaml"), []byte("file1"), 0o600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(dir, "file2.yaml"), []byte("file2"), 0o600) + require.NoError(t, err) + + img := Image{ + Source: Source{Checksum: "sha256:abc123"}, + Hooks: &Hooks{ + Transform: []Hook{ + {Name: "test-hook", Command: "echo", Inputs: []string{"*.yaml"}}, + }, + }, + } + + checksum, err := img.EffectiveChecksumWithInputs(dir) + + require.NoError(t, err) + assert.Contains(t, checksum, "+inputs:") + }) + + t.Run("handles multiple hooks with inputs", func(t *testing.T) { + dir := t.TempDir() + + err := os.WriteFile(filepath.Join(dir, "config1.yaml"), []byte("config1"), 0o600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(dir, "config2.yaml"), []byte("config2"), 0o600) + require.NoError(t, err) + + img := Image{ + Source: Source{Checksum: "sha256:abc123"}, + Hooks: &Hooks{ + Transform: []Hook{ + {Name: "hook1", Command: "echo", Inputs: []string{"config1.yaml"}}, + {Name: "hook2", Command: "echo", Inputs: []string{"config2.yaml"}}, + }, + }, + } + + checksum, err := img.EffectiveChecksumWithInputs(dir) + + require.NoError(t, err) + assert.Contains(t, checksum, "+inputs:") + }) + + t.Run("handles validation.expected as base checksum", func(t *testing.T) { + dir := t.TempDir() + + err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("data"), 0o600) + require.NoError(t, err) + + img := Image{ + Source: Source{Checksum: "sha256:source"}, + Validation: &Validation{Expected: "sha256:validated"}, + Hooks: &Hooks{ + Transform: []Hook{ + {Name: "test-hook", Command: "echo", Inputs: []string{"config.yaml"}}, + }, + }, + } + + checksum, err := img.EffectiveChecksumWithInputs(dir) + + require.NoError(t, err) + // Should use validation.expected as base + assert.Contains(t, checksum, "sha256:validated+inputs:") + }) + + t.Run("returns empty string for no matching files (glob finds nothing)", func(t *testing.T) { + dir := t.TempDir() + + img := Image{ + Source: Source{Checksum: "sha256:abc123"}, + Hooks: &Hooks{ + Transform: []Hook{ + {Name: "test-hook", Command: "echo", Inputs: []string{"nonexistent*.yaml"}}, + }, + }, + } + + // Should succeed but produce a checksum based on empty file list + checksum, err := img.EffectiveChecksumWithInputs(dir) + require.NoError(t, err) + // With no files matching, the inputs hash is of empty content + assert.Contains(t, checksum, "+inputs:") + }) + + t.Run("ignores directories in glob matches", func(t *testing.T) { + dir := t.TempDir() + + // Create a subdirectory + subdir := filepath.Join(dir, "subdir") + err := os.Mkdir(subdir, 0o750) + require.NoError(t, err) + + // Create a file + err = os.WriteFile(filepath.Join(dir, "file.yaml"), []byte("file"), 0o600) + require.NoError(t, err) + + img := Image{ + Source: Source{Checksum: "sha256:abc123"}, + Hooks: &Hooks{ + Transform: []Hook{ + {Name: "test-hook", Command: "echo", Inputs: []string{"*"}}, + }, + }, + } + + // Should succeed and only include the file, not the directory + checksum, err := img.EffectiveChecksumWithInputs(dir) + + require.NoError(t, err) + assert.Contains(t, checksum, "+inputs:") + }) +} + func TestImageManifest_FindImageByName(t *testing.T) { t.Run("finds existing image", func(t *testing.T) { manifest := &ImageManifest{