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
13 changes: 13 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Ideas, Features, Roadmap

## Known Bugs

- the xauthority device changes when x or wayland is restarted. `igloo enter` should validate the existence of the xauthority device on each enter, replacing it if necessary

## Ideas

- On a host with no VS Code install, we'll be running code inside each igloo container. It'd be nice to share settings between them. Can't really symlink the ~/.vscode dir from the host since it won't exist. Explore having a Shared State sort of thing where directories like that live in ~/.config/igloo/shared_state and are linked in (by default? configurable?). Implement: `[shared_state]` section in config file that stores listed directories in ~/.config/igloo/shared_state and symlinks listed directories into the container, allowing all igloo instances to share these directories. This feature could be used for one-off script storage too.

- copy the igloo binary into the container, and add one or more commands intended to run inside the container. Perhaps showing, editing `shared_state` contents? definitely `igloo status` should work inside the container and show that it knows it's inside.

- modify PS1 in the container to add `[igloo]` prefix, change color of prompt, something visual as an indicator? Or... simply add a tip in the readme showing how to do this.
5 changes: 5 additions & 0 deletions cmd/enter.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ func runEnter() error {
}
}

// Update Xauthority mount if necessary (file path can change on Wayland)
if err := client.UpdateXauthority(cfg.Container.Name); err != nil {
fmt.Println(styles.Warning(fmt.Sprintf("Could not update Xauthority: %v", err)))
}

// Get user info
username := os.Getenv("USER")
cwd, err := os.Getwd()
Expand Down
79 changes: 79 additions & 0 deletions internal/incus/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,85 @@ func (c *Client) AddGPUDevice(name string) error {
return cmd.Run()
}

// RemoveDevice removes a device from an instance
func (c *Client) RemoveDevice(name, deviceName string) error {
cmd := exec.Command("incus", "config", "device", "remove", name, deviceName)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

// DeviceExists checks if a device exists on an instance
func (c *Client) DeviceExists(name, deviceName string) (bool, error) {
cmd := exec.Command("incus", "config", "device", "show", name)
output, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("failed to list devices: %w", err)
}

// Simple check if device name appears in output
return strings.Contains(string(output), deviceName+":"), nil
}

// GetDeviceSource gets the source path of a disk device
func (c *Client) GetDeviceSource(name, deviceName string) (string, error) {
cmd := exec.Command("incus", "config", "device", "get", name, deviceName, "source")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get device source: %w", err)
}
return strings.TrimSpace(string(output)), nil
}

// UpdateXauthority updates the xauthority device mount if the source file has changed
// This is necessary because XWayland can create new Xauthority files when restarted
func (c *Client) UpdateXauthority(name string) error {
// Get current Xauthority file from environment
xauthFile := os.Getenv("XAUTHORITY")
if xauthFile == "" {
xauthFile = os.Getenv("HOME") + "/.Xauthority"
}

// Check if the file exists
if _, err := os.Stat(xauthFile); os.IsNotExist(err) {
// No xauthority file available, nothing to update
return nil
}

// Check if xauthority device exists
deviceExists, err := c.DeviceExists(name, "xauthority")
if err != nil {
return fmt.Errorf("failed to check xauthority device: %w", err)
}

username := os.Getenv("USER")
xauthPath := fmt.Sprintf("/home/%s/.Xauthority", username)

if !deviceExists {
// Device doesn't exist, add it
return c.AddDiskDevice(name, "xauthority", xauthFile, xauthPath)
}

// Device exists, check if source has changed
currentSource, err := c.GetDeviceSource(name, "xauthority")
if err != nil {
return fmt.Errorf("failed to get current xauthority source: %w", err)
}

if currentSource != xauthFile {
// Source has changed, remove and re-add the device
if err := c.RemoveDevice(name, "xauthority"); err != nil {
return fmt.Errorf("failed to remove old xauthority device: %w", err)
}

if err := c.AddDiskDevice(name, "xauthority", xauthFile, xauthPath); err != nil {
return fmt.Errorf("failed to add new xauthority device: %w", err)
}
}

return nil
}

// SetConfig sets a configuration option on an instance
func (c *Client) SetConfig(name, key, value string) error {
cmd := exec.Command("incus", "config", "set", name, key+"="+value)
Expand Down
129 changes: 129 additions & 0 deletions internal/incus/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package incus

import (
"os"
"strings"
"testing"
)

// TestUpdateXauthority_NoFile tests UpdateXauthority when no xauthority file exists
func TestUpdateXauthority_NoFile(t *testing.T) {
// Save current env vars
oldXauth := os.Getenv("XAUTHORITY")
oldHome := os.Getenv("HOME")
defer func() {
if oldXauth != "" {
_ = os.Setenv("XAUTHORITY", oldXauth)
} else {
_ = os.Unsetenv("XAUTHORITY")
}
_ = os.Setenv("HOME", oldHome)
}()

// Set env to point to non-existent file
tmpDir := t.TempDir()
_ = os.Setenv("HOME", tmpDir)
_ = os.Unsetenv("XAUTHORITY")

client := NewClient()

// This should not error when file doesn't exist - it just returns nil
err := client.UpdateXauthority("test-container")
if err != nil {
t.Errorf("UpdateXauthority() should not error when file doesn't exist, got: %v", err)
}
}

// TestUpdateXauthority_WithFile tests UpdateXauthority when xauthority file exists
// Note: This test can't fully test the incus commands without a running incus instance,
// but it verifies the file detection logic works
func TestUpdateXauthority_WithFile(t *testing.T) {
// Save current env vars
oldXauth := os.Getenv("XAUTHORITY")
oldHome := os.Getenv("HOME")
oldUser := os.Getenv("USER")
defer func() {
if oldXauth != "" {
_ = os.Setenv("XAUTHORITY", oldXauth)
} else {
_ = os.Unsetenv("XAUTHORITY")
}
_ = os.Setenv("HOME", oldHome)
_ = os.Setenv("USER", oldUser)
}()

// Create a temporary xauthority file
tmpDir := t.TempDir()
xauthFile := tmpDir + "/.Xauthority"
if err := os.WriteFile(xauthFile, []byte("fake xauth data"), 0600); err != nil {
t.Fatalf("Failed to create test xauthority file: %v", err)
}

_ = os.Setenv("HOME", tmpDir)
_ = os.Setenv("USER", "testuser")
_ = os.Unsetenv("XAUTHORITY")

client := NewClient()

// This will attempt to call incus commands which will fail without incus installed
// but we can verify it at least tries to process the file
err := client.UpdateXauthority("test-container")

// We expect an error because incus isn't available in test environment,
// but we're verifying the logic path is taken when the file exists
// The function should have attempted to check device existence
if err == nil {
// If no error, then either incus is available (unlikely in CI)
// or the logic correctly handled the case
t.Log("UpdateXauthority succeeded (incus may be available)")
} else {
// Expected case: incus command failed
errMsg := err.Error()
if !strings.Contains(errMsg, "incus") && !strings.Contains(errMsg, "device") &&
!strings.Contains(errMsg, "executable file not found") && !strings.Contains(errMsg, "command not found") {
t.Errorf("Expected incus-related error, got: %v", err)
}
}
}

// TestUpdateXauthority_CustomPath tests UpdateXauthority with XAUTHORITY env var set
func TestUpdateXauthority_CustomPath(t *testing.T) {
// Save current env vars
oldXauth := os.Getenv("XAUTHORITY")
oldUser := os.Getenv("USER")
defer func() {
if oldXauth != "" {
_ = os.Setenv("XAUTHORITY", oldXauth)
} else {
_ = os.Unsetenv("XAUTHORITY")
}
_ = os.Setenv("USER", oldUser)
}()

// Create a temporary xauthority file with custom path
tmpDir := t.TempDir()
xauthFile := tmpDir + "/.mutter-Xwaylandauth.ABC123"
if err := os.WriteFile(xauthFile, []byte("fake xauth data"), 0600); err != nil {
t.Fatalf("Failed to create test xauthority file: %v", err)
}

_ = os.Setenv("XAUTHORITY", xauthFile)
_ = os.Setenv("USER", "testuser")

client := NewClient()

// This will attempt to call incus commands which will fail without incus installed
err := client.UpdateXauthority("test-container")

// We expect an error because incus isn't available in test environment
if err == nil {
t.Log("UpdateXauthority succeeded (incus may be available)")
} else {
// Expected case: incus command failed
errMsg := err.Error()
if !strings.Contains(errMsg, "incus") && !strings.Contains(errMsg, "device") &&
!strings.Contains(errMsg, "executable file not found") && !strings.Contains(errMsg, "command not found") {
t.Errorf("Expected incus-related error, got: %v", err)
}
}
}