Skip to content
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
5 changes: 5 additions & 0 deletions cmd/destroy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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..."))
Expand Down
42 changes: 41 additions & 1 deletion cmd/enter.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 {
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions cmd/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
156 changes: 156 additions & 0 deletions internal/config/hash.go
Original file line number Diff line number Diff line change
@@ -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
}
117 changes: 117 additions & 0 deletions internal/config/hash_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}