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
3 changes: 3 additions & 0 deletions .igloo/igloo.ini
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ project = true
[display]
enabled = true
gpu = true

[symlinks]
paths = .gitconfig, .ssh, .config/nvim, .bashrc, .profile, .bash_profile
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ project = true
[display]
enabled = true
gpu = true

[symlinks]
paths = .gitconfig, .ssh, .config/nvim
```

### Init Scripts 📜
Expand All @@ -101,6 +104,26 @@ npm install -g yarn

Scripts run in lexicographical order, so use numbered prefixes like `01-`, `02-`, etc.

### Symlinks 🔗

The `[symlinks]` section lets you link files or folders from your host home directory (`~/host/`) to the container's home (`~/`). This is perfect for sharing dotfiles!

**Default symlinks** (created automatically with `igloo init`):

```ini
[symlinks]
paths = .gitconfig, .ssh, .bashrc, .profile, .bash_profile
```

Each path listed will create a symlink: `~/<path>` → `~/host/<path>`. If a file doesn't exist on your host, it's silently skipped—no errors!

Add more paths as needed:

```ini
[symlinks]
paths = .gitconfig, .ssh, .bashrc, .profile, .bash_profile, .config/nvim, .vimrc
```

## 🎨 Flags & Options

### igloo init
Expand Down
7 changes: 7 additions & 0 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@ func runInit(distro, release, name, packages string) error {
Enabled: true,
GPU: true,
},
Symlinks: []string{
".gitconfig",
".ssh",
".bashrc",
".profile",
".bash_profile",
},
}

// Create .igloo directory and write config file
Expand Down
27 changes: 27 additions & 0 deletions cmd/provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,33 @@ func provisionContainer(cfg *config.IglooConfig) error {
return fmt.Errorf("cloud-init failed: %w", err)
}

// Create symlinks from ~/host/ to ~/
if len(cfg.Symlinks) > 0 {
fmt.Println(styles.Info("Creating symlinks..."))
homeDir := fmt.Sprintf("/home/%s", username)
hostDir := fmt.Sprintf("%s/host", homeDir)
for _, link := range cfg.Symlinks {
// Clean the path and remove leading ~/ or / if present
link = filepath.Clean(link)
if len(link) >= 2 && link[:2] == "~/" {
link = link[2:]
} else if len(link) >= 1 && link[0] == '/' {
link = link[1:]
}

source := filepath.Join(hostDir, link)
target := filepath.Join(homeDir, link)

// Create parent directory if needed, then create symlink
// Use -f to force overwrite, and || true to not fail if source doesn't exist
parentDir := filepath.Dir(target)
cmd := fmt.Sprintf("mkdir -p %s && [ -e %s ] && ln -sf %s %s || true", parentDir, source, source, target)
if err := client.ExecAsUser(name, username, "/bin/sh", "-c", cmd); err != nil {
fmt.Println(styles.Warning(fmt.Sprintf("Failed to create symlink for %s: %v", link, err)))
}
}
}

// Add display passthrough (now /run/user/<uid> exists)
if cfg.Display.Enabled {
fmt.Println(styles.Info("Configuring display passthrough..."))
Expand Down
10 changes: 10 additions & 0 deletions cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,15 @@ func runStatus() error {
}
}

// Show symlinks
if len(cfg.Symlinks) > 0 {
fmt.Println()
fmt.Println(styles.Header("Symlinks"))
fmt.Printf(" %s ~/host/<path> → ~/<path>\n", styles.Label("Pattern:"))
for _, s := range cfg.Symlinks {
fmt.Printf(" %s\n", s)
}
}

return nil
}
26 changes: 26 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"gopkg.in/ini.v1"
)
Expand Down Expand Up @@ -38,6 +39,7 @@ type IglooConfig struct {
Packages PackagesConfig
Mounts MountsConfig
Display DisplayConfig
Symlinks []string // List of paths to symlink from ~/host/ to ~/
}

// ContainerConfig holds container-specific settings
Expand Down Expand Up @@ -89,6 +91,18 @@ func Load(path string) (*IglooConfig, error) {
return nil, fmt.Errorf("failed to parse display section: %w", err)
}

// Parse symlinks section (comma-separated list)
symlinksKey := cfg.Section("symlinks").Key("paths")
if symlinksKey != nil && symlinksKey.String() != "" {
paths := strings.Split(symlinksKey.String(), ",")
for _, p := range paths {
p = strings.TrimSpace(p)
if p != "" {
config.Symlinks = append(config.Symlinks, p)
}
}
}

return config, nil
}

Expand Down Expand Up @@ -145,6 +159,18 @@ func Write(path string, config *IglooConfig) error {
return err
}

// Symlinks section
if len(config.Symlinks) > 0 {
symlinksSec, err := cfg.NewSection("symlinks")
if err != nil {
return err
}
symlinksSec.Comment = "Symlinks from ~/host/ to ~/ (files/folders that exist on host)"
if _, err := symlinksSec.NewKey("paths", strings.Join(config.Symlinks, ", ")); err != nil {
return err
}
}

return cfg.SaveTo(path)
}

Expand Down
49 changes: 49 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,55 @@ gpu = false
t.Error("Display.GPU = true, want false")
}

// Verify symlinks section (empty in this config)
if len(cfg.Symlinks) != 0 {
t.Errorf("Symlinks = %v, want empty", cfg.Symlinks)
}

}

func TestLoad_WithSymlinks(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "igloo.ini")

content := `[container]
image = images:debian/trixie
name = test-igloo

[packages]
install =

[mounts]
home = true
project = true

[display]
enabled = true
gpu = false

[symlinks]
paths = .gitconfig, .ssh, .config/nvim
`

if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}

cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() failed: %v", err)
}

// Verify symlinks
expected := []string{".gitconfig", ".ssh", ".config/nvim"}
if len(cfg.Symlinks) != len(expected) {
t.Errorf("Symlinks length = %d, want %d", len(cfg.Symlinks), len(expected))
}
for i, s := range cfg.Symlinks {
if s != expected[i] {
t.Errorf("Symlinks[%d] = %q, want %q", i, s, expected[i])
}
}
}

func TestLoad_FileNotFound(t *testing.T) {
Expand Down
22 changes: 21 additions & 1 deletion internal/incus/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,26 @@ func (c *Client) ExecAsRoot(name string, command ...string) error {
return cmd.Run()
}

// ExecAsUser runs a command in an instance as a specific user
func (c *Client) ExecAsUser(name, username string, command ...string) error {
uid := os.Getuid()
gid := os.Getgid()

args := []string{
"exec", name,
"--user", fmt.Sprintf("%d", uid),
"--group", fmt.Sprintf("%d", gid),
"--env", "HOME=/home/" + username,
"--env", "USER=" + username,
"--",
}
args = append(args, command...)
cmd := exec.Command("incus", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

// ExecInteractive runs an interactive shell in an instance
func (c *Client) ExecInteractive(name, username, workDir string) error {
uid := os.Getuid()
Expand All @@ -193,7 +213,7 @@ func (c *Client) ExecInteractive(name, username, workDir string) error {
"--env", "HOME=/home/" + username,
"--env", "USER=" + username,
"--env", "XAUTHORITY=/home/" + username + "/.Xauthority",
"--", "/bin/bash", "-l",
"--", "/bin/bash", "--login", "-i",
}

cmd := exec.Command("incus", args...)
Expand Down