diff --git a/cmd/destroy.go b/cmd/destroy.go index 549d031..412f249 100644 --- a/cmd/destroy.go +++ b/cmd/destroy.go @@ -65,6 +65,11 @@ func runDestroy(force, keepConfig bool) error { fmt.Println(styles.Warning(fmt.Sprintf("Container %s does not exist", cfg.Container.Name))) } + // Remove stored config hash + if err := config.RemoveStoredHash(cfg.Container.Name); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not remove stored hash: %v", err))) + } + // Remove .igloo directory unless --keep-config if !keepConfig { fmt.Println(styles.Info("Removing .igloo directory...")) diff --git a/cmd/enter.go b/cmd/enter.go index 8bb9921..565f73d 100644 --- a/cmd/enter.go +++ b/cmd/enter.go @@ -1,9 +1,11 @@ package cmd import ( + "bufio" "fmt" "os" "path/filepath" + "strings" "github.com/frostyard/igloo/internal/config" "github.com/frostyard/igloo/internal/incus" @@ -16,7 +18,8 @@ func enterCmd() *cobra.Command { Use: "enter", Short: "Enter the igloo development environment", Long: `Enter opens an interactive shell in the igloo container. -If the container is not running, it will be started first.`, +If the container is not running, it will be started first. +If the .igloo configuration has changed, you will be prompted to rebuild.`, Example: ` # Enter the igloo environment igloo enter`, RunE: func(cmd *cobra.Command, args []string) error { @@ -43,11 +46,48 @@ func runEnter() error { if err != nil { return fmt.Errorf("failed to check instance: %w", err) } + + if exists { + // Check if config has changed since last provision + changed, currentHash, err := config.ConfigChanged(cfg.Container.Name) + if err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not check for config changes: %v", err))) + } else if changed { + fmt.Println(styles.Warning("Configuration in .igloo/ has changed since last provision.")) + fmt.Print(styles.Info("Rebuild container to apply changes? [y/N]: ")) + + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + + if response == "y" || response == "yes" { + fmt.Println(styles.Info("Removing old container...")) + if err := client.Delete(cfg.Container.Name, true); err != nil { + return fmt.Errorf("failed to remove container: %w", err) + } + exists = false + } else { + // Update stored hash to current so we don't keep asking + if err := config.StoreHash(cfg.Container.Name, currentHash); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not update config hash: %v", err))) + } + } + } + } + if !exists { fmt.Println(styles.Info("Container does not exist, provisioning...")) if err := provisionContainer(cfg); err != nil { return fmt.Errorf("failed to provision container: %w", err) } + + // Store the config hash after successful provision + currentHash, err := config.HashConfigDir() + if err == nil { + if err := config.StoreHash(cfg.Container.Name, currentHash); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not store config hash: %v", err))) + } + } } // Check if instance is running diff --git a/cmd/init.go b/cmd/init.go index 82a31da..d4ffc4e 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -164,6 +164,14 @@ echo "Working directory: $(pwd)" return err } + // Store the config hash for change detection + currentHash, err := config.HashConfigDir() + if err == nil { + if err := config.StoreHash(cfg.Container.Name, currentHash); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not store config hash: %v", err))) + } + } + fmt.Println(styles.Info("Run 'igloo enter' to start working")) return nil diff --git a/cmd/remove.go b/cmd/remove.go index a2a4b76..f5f473a 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -59,6 +59,11 @@ func runRemove(force bool) error { return fmt.Errorf("failed to remove instance: %w", err) } + // Remove stored config hash so next enter will re-provision + if err := config.RemoveStoredHash(cfg.Container.Name); err != nil { + fmt.Println(styles.Warning(fmt.Sprintf("Could not remove stored hash: %v", err))) + } + fmt.Println(styles.Success(fmt.Sprintf("Container %s removed (.igloo preserved)", cfg.Container.Name))) return nil } diff --git a/internal/config/hash.go b/internal/config/hash.go new file mode 100644 index 0000000..9a479ab --- /dev/null +++ b/internal/config/hash.go @@ -0,0 +1,156 @@ +package config + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "sort" +) + +// GetDataDir returns the XDG data directory for igloo +// Uses $XDG_DATA_HOME/igloo or ~/.local/share/igloo +func GetDataDir() string { + dataHome := os.Getenv("XDG_DATA_HOME") + if dataHome == "" { + home := os.Getenv("HOME") + dataHome = filepath.Join(home, ".local", "share") + } + return filepath.Join(dataHome, "igloo") +} + +// HashConfigDir computes a SHA256 hash of all files in the .igloo directory +func HashConfigDir() (string, error) { + return hashDir(ConfigDir) +} + +// hashDir computes a SHA256 hash of all files in a directory +func hashDir(dir string) (string, error) { + h := sha256.New() + + // Walk the config directory and hash all file contents + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Hash the relative path for structure + relPath, err := filepath.Rel(dir, path) + if err != nil { + return err + } + + // Skip directories, just hash their names for structure + if info.IsDir() { + h.Write([]byte("dir:" + relPath + "\n")) + return nil + } + + // Hash the relative path and file contents + h.Write([]byte("file:" + relPath + "\n")) + + // Read and hash file contents + f, err := os.Open(path) + if err != nil { + return err + } + + if _, err := io.Copy(h, f); err != nil { + if closeErr := f.Close(); closeErr != nil { + return closeErr + } + return err + } + + if err := f.Close(); err != nil { + return err + } + + return nil + }) + + if err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +// GetStoredHash retrieves the stored hash for a container +func GetStoredHash(containerName string) (string, error) { + hashFile := filepath.Join(GetDataDir(), containerName+".hash") + data, err := os.ReadFile(hashFile) + if err != nil { + if os.IsNotExist(err) { + return "", nil // No stored hash yet + } + return "", err + } + return string(data), nil +} + +// StoreHash saves the hash for a container +func StoreHash(containerName, hash string) error { + dataDir := GetDataDir() + if err := os.MkdirAll(dataDir, 0755); err != nil { + return err + } + + hashFile := filepath.Join(dataDir, containerName+".hash") + return os.WriteFile(hashFile, []byte(hash), 0644) +} + +// RemoveStoredHash deletes the stored hash for a container +func RemoveStoredHash(containerName string) error { + hashFile := filepath.Join(GetDataDir(), containerName+".hash") + err := os.Remove(hashFile) + if os.IsNotExist(err) { + return nil // Already gone + } + return err +} + +// ConfigChanged checks if the .igloo directory has changed since last provision +// Returns (changed, currentHash, error) +func ConfigChanged(containerName string) (bool, string, error) { + currentHash, err := HashConfigDir() + if err != nil { + return false, "", err + } + + storedHash, err := GetStoredHash(containerName) + if err != nil { + return false, currentHash, err + } + + // If no stored hash, this is first run - not "changed" + if storedHash == "" { + return false, currentHash, nil + } + + return currentHash != storedHash, currentHash, nil +} + +// ListStoredHashes returns all container names that have stored hashes +func ListStoredHashes() ([]string, error) { + dataDir := GetDataDir() + entries, err := os.ReadDir(dataDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var containers []string + for _, entry := range entries { + if !entry.IsDir() && filepath.Ext(entry.Name()) == ".hash" { + name := entry.Name() + containers = append(containers, name[:len(name)-5]) // Remove .hash suffix + } + } + + sort.Strings(containers) + return containers, nil +} diff --git a/internal/config/hash_test.go b/internal/config/hash_test.go new file mode 100644 index 0000000..0641662 --- /dev/null +++ b/internal/config/hash_test.go @@ -0,0 +1,117 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestHashDir(t *testing.T) { + // Create a temp directory with config structure + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".igloo") + + // Create the config directory + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a config file + configContent := "[container]\nname = test\n" + if err := os.WriteFile(filepath.Join(configDir, "igloo.ini"), []byte(configContent), 0644); err != nil { + t.Fatal(err) + } + + // Hash should succeed + hash1, err := hashDir(configDir) + if err != nil { + t.Fatalf("hashDir() error = %v", err) + } + if hash1 == "" { + t.Error("hashDir() returned empty hash") + } + + // Same content should produce same hash + hash2, err := hashDir(configDir) + if err != nil { + t.Fatalf("hashDir() error = %v", err) + } + if hash1 != hash2 { + t.Errorf("hashDir() not deterministic: %q != %q", hash1, hash2) + } + + // Changing content should change hash + if err := os.WriteFile(filepath.Join(configDir, "igloo.ini"), []byte("[container]\nname = changed\n"), 0644); err != nil { + t.Fatal(err) + } + hash3, err := hashDir(configDir) + if err != nil { + t.Fatalf("hashDir() error = %v", err) + } + if hash1 == hash3 { + t.Error("hashDir() should return different hash for different content") + } +} + +func TestStoreAndGetHash(t *testing.T) { + // Use a temp directory for data + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + containerName := "test-container" + testHash := "abc123def456" + + // Initially no hash stored + hash, err := GetStoredHash(containerName) + if err != nil { + t.Fatalf("GetStoredHash() error = %v", err) + } + if hash != "" { + t.Errorf("GetStoredHash() = %q, want empty", hash) + } + + // Store a hash + if err := StoreHash(containerName, testHash); err != nil { + t.Fatalf("StoreHash() error = %v", err) + } + + // Retrieve it + hash, err = GetStoredHash(containerName) + if err != nil { + t.Fatalf("GetStoredHash() error = %v", err) + } + if hash != testHash { + t.Errorf("GetStoredHash() = %q, want %q", hash, testHash) + } + + // Remove it + if err := RemoveStoredHash(containerName); err != nil { + t.Fatalf("RemoveStoredHash() error = %v", err) + } + + // Should be gone + hash, err = GetStoredHash(containerName) + if err != nil { + t.Fatalf("GetStoredHash() error = %v", err) + } + if hash != "" { + t.Errorf("GetStoredHash() after remove = %q, want empty", hash) + } +} + +func TestConfigChanged(t *testing.T) { + // This test requires actual .igloo directory, so we'll skip the ConfigChanged test + // and focus on testing the underlying hashDir and store/get functions + t.Skip("ConfigChanged requires modifying package-level constants") +} + +func TestGetDataDir(t *testing.T) { + // Test with XDG_DATA_HOME set + t.Setenv("XDG_DATA_HOME", "/custom/data") + + dataDir := GetDataDir() + expected := "/custom/data/igloo" + if dataDir != expected { + t.Errorf("GetDataDir() = %q, want %q", dataDir, expected) + } +}