From c500fcc22e9c7fec416dd044a8de02a77fe7f40d Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 29 Dec 2025 22:31:57 -0800 Subject: [PATCH 1/2] feat(labctl): detect transform hook input changes in images sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transform hooks can now declare input files/globs that affect their output via the `inputs` field. When specified, changes to these files trigger a re-sync without needing --force. The effective checksum now incorporates a hash of all declared input files, ensuring that changes to transform dependencies (like talconfig.yaml for the talos-embed-config hook) are properly detected. Closes: HOM-30 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- images/images.yaml | 5 + tools/labctl/cmd/images/sync.go | 26 +- tools/labctl/cmd/images/sync_test.go | 28 +-- tools/labctl/internal/config/manifest.go | 104 +++++++- tools/labctl/internal/config/manifest_test.go | 230 ++++++++++++++++++ 5 files changed, 373 insertions(+), 20 deletions(-) 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{ From 9a6e72f7efbc750be0beb08affb04aedf8485c28 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Mon, 29 Dec 2025 22:46:46 -0800 Subject: [PATCH 2/2] fix(vyos): use Docker healthcheck for container readiness detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VyOS test script was waiting for "migrate.*configure" in container logs, but this pattern doesn't appear in the VyOS Stream ISO. The script would always wait all 90 iterations (180 seconds) before continuing. Switch to using Docker's healthcheck status instead, which uses `systemctl is-system-running` and properly indicates when the container is ready. This reduces CI wait time by ~2-3 minutes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- images/hooks/vyos-test.sh | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) 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