From bb36269d3d64c0fb185b4f5a8ddf121fd8cc1b20 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sun, 11 Jan 2026 00:55:05 +0200 Subject: [PATCH] feat: install on specific machines by machine id --- appconfig/appconfig.go | 24 ++-- appconfig/installer_data.go | 3 + docs/command-line-interface.md | 35 +++-- docs/configuration-reference.md | 13 ++ docs/installer-configuration.md | 59 +++++++++ installer/installer.go | 11 ++ installer/installer_defaults.go | 13 +- machine/machine.go | 47 +++++++ machine/machine_id.go | 218 ++++++++++++++++++++++++++++++++ machine/machine_test.go | 177 ++++++++++++++++++++++++++ main.go | 15 +++ 11 files changed, 596 insertions(+), 19 deletions(-) create mode 100644 machine/machine.go create mode 100644 machine/machine_id.go create mode 100644 machine/machine_test.go diff --git a/appconfig/appconfig.go b/appconfig/appconfig.go index ee5b8b8..9ce4da8 100644 --- a/appconfig/appconfig.go +++ b/appconfig/appconfig.go @@ -28,6 +28,8 @@ type AppConfig struct { Env *map[string]string `json:"env" yaml:"env"` // PlatformEnv is a map of platform-specific environment variables to set. PlatformEnv *platform.PlatformMap[map[string]string] `json:"platform_env" yaml:"platform_env"` + // MachineAliases is a map of friendly names to machine IDs. + MachineAliases *map[string]string `json:"machine_aliases" yaml:"machine_aliases"` // Filter is a list of installer names to filter by. Filter []string } @@ -46,6 +48,8 @@ type AppCliConfig struct { LogFile *string // ShowLogFile indicates that only the log file path should be shown. ShowLogFile bool + // ShowMachineID indicates that only the machine ID should be shown. + ShowMachineID bool } // AppConfigDefaults provides default configurations for installer types. @@ -198,12 +202,13 @@ func boolPtr(b bool) *bool { func ParseCliConfig() *AppCliConfig { args := os.Args[1:] config := &AppCliConfig{ - ConfigFile: "", - Debug: nil, - CheckUpdates: nil, - Filter: []string{}, - LogFile: nil, - ShowLogFile: false, + ConfigFile: "", + Debug: nil, + CheckUpdates: nil, + Filter: []string{}, + LogFile: nil, + ShowLogFile: false, + ShowMachineID: false, } file := FindConfigFile() for len(args) > 0 { @@ -230,6 +235,8 @@ func ParseCliConfig() *AppCliConfig { // No value provided, just show log file path config.ShowLogFile = true } + case "-m", "--machine-id": + config.ShowMachineID = true case "-h", "--help": printHelp() os.Exit(0) @@ -248,8 +255,8 @@ func ParseCliConfig() *AppCliConfig { } args = args[1:] } - // If only showing log file, don't require config file - if config.ShowLogFile { + // If only showing log file or machine ID, don't require config file + if config.ShowLogFile || config.ShowMachineID { return config } if file == "" { @@ -270,6 +277,7 @@ func printHelp() { fmt.Println(" -U, --no-update Disable update checks") fmt.Println(" -f, --filter Filter by installer name (can be used multiple times)") fmt.Println(" -l, --log-file Set log file path, or show current path if no value given") + fmt.Println(" -m, --machine-id Show machine ID and exit") fmt.Println(" -h, --help Show this help message") fmt.Println(" -v, --version Show version") fmt.Println("") diff --git a/appconfig/installer_data.go b/appconfig/installer_data.go index 2348040..d933463 100644 --- a/appconfig/installer_data.go +++ b/appconfig/installer_data.go @@ -3,6 +3,7 @@ package appconfig import ( "strings" + "github.com/chenasraf/sofmani/machine" "github.com/chenasraf/sofmani/platform" "github.com/chenasraf/sofmani/utils" "github.com/samber/lo" @@ -24,6 +25,8 @@ type InstallerData struct { PlatformEnv *platform.PlatformMap[map[string]string] `json:"platform_env" yaml:"platform_env"` // Platforms is a list of platforms where this installer should run. Platforms *platform.Platforms `json:"platforms" yaml:"platforms"` + // Machines is a list of machine IDs where this installer should run. + Machines *machine.Machines `json:"machines" yaml:"machines"` // Steps is a list of sub-installers for group installers. Steps *[]InstallerData `json:"steps" yaml:"steps"` // Opts is a map of options specific to the installer type. diff --git a/docs/command-line-interface.md b/docs/command-line-interface.md index 267aa4d..6c29121 100644 --- a/docs/command-line-interface.md +++ b/docs/command-line-interface.md @@ -11,15 +11,17 @@ repository. You can call `sofmani` with the following flags to alter the behavior for the current run: -| Flag | Description | -| ------------------- | ------------------------------------------------------- | -| `-d`, `--debug` | Enable debug mode. | -| `-D`, `--no-debug` | Disable debug mode (default). | -| `-u`, `--update` | Enable update checking. | -| `-U`, `--no-update` | Disable update checking (default). | -| `-f`, `--filter` | Filter by installer name (can be used multiple times)\* | -| `-h`, `--help` | Display help information and exit. | -| `-v`, `--version` | Display version information and exit. | +| Flag | Description | +| --------------------- | ------------------------------------------------------- | +| `-d`, `--debug` | Enable debug mode. | +| `-D`, `--no-debug` | Disable debug mode (default). | +| `-u`, `--update` | Enable update checking. | +| `-U`, `--no-update` | Disable update checking (default). | +| `-f`, `--filter` | Filter by installer name (can be used multiple times)\* | +| `-l`, `--log-file` | Set log file path, or show current path if no value. | +| `-m`, `--machine-id` | Show machine ID and exit. | +| `-h`, `--help` | Display help information and exit. | +| `-v`, `--version` | Display version information and exit. | Each of these flags overrides the loaded config file, so while your default config can choose not to check for updates by default, you or another user can add the `--update` flag to override this @@ -50,6 +52,21 @@ included. - To only installers that contain "sofmani", but exclude ones tagged "config", use `-f sofmani -f "!tag:config"`. +### Machine ID + +The machine ID is a unique, deterministic identifier for the current machine. It is generated from +stable system identifiers (hardware UUID on macOS, `/etc/machine-id` on Linux, registry GUID on +Windows) and remains constant even if hostname or other system settings change. + +To view the current machine's ID: + +```sh +sofmani --machine-id +``` + +This ID can be used with the `machines` configuration option to run specific installers only on +certain machines. See [Installer Configuration](./installer-configuration.md#fields) for details. + ## Examples Search for the config in one of the default directories, and enable update checking: diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 85e8487..b9ed43f 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -38,6 +38,19 @@ Here is a breakdown of all configuration options: - OS environment variables are passed and may be overridden for this config and all of its installers here. +- **`machine_aliases`** (Object) + - A mapping of friendly names to machine IDs. + - Use `sofmani --machine-id` to get the machine ID for each of your machines. + - These aliases can then be used in installer `machines.only` and `machines.except` fields + instead of the raw machine IDs. + - Example: + ```yaml + machine_aliases: + work-laptop: 5fa2a8e8193868df + home-desktop: a1b2c3d4e5f67890 + home-server: fedcba0987654321 + ``` + ## Example Config ```yaml diff --git a/docs/installer-configuration.md b/docs/installer-configuration.md index 85f127b..d17672b 100644 --- a/docs/installer-configuration.md +++ b/docs/installer-configuration.md @@ -47,6 +47,22 @@ These fields are shared by all installer types. Some fields may vary in behavior - **Type**: Array of Strings - **Description**: Platforms where the step should **not** execute; replaces `platforms.only`. +- **`machines`** + + - **Type**: Object (optional) + - **Description**: Machine-specific execution controls. Use this to run installers only on + specific machines. Get the machine ID by running `sofmani --machine-id`. You can use either + raw machine IDs or aliases defined in the top-level `machine_aliases` configuration. + See `machines` subfields below. + - **Subfields**: + - **`machines.only`** + - **Type**: Array of Strings + - **Description**: Machine IDs or aliases where the step should execute. Supercedes + `machines.except`. + - **`machines.except`** + - **Type**: Array of Strings + - **Description**: Machine IDs or aliases where the step should **not** execute. + - **`steps`** - **Type**: Array of Installers @@ -356,6 +372,49 @@ install: command: 'curl https://pyenv.run | bash' ``` +### Machine-specific installers + +```yaml +# Define friendly names for your machines (get IDs with `sofmani --machine-id`) +machine_aliases: + work-laptop: a1b2c3d4e5f67890 + home-desktop: 5fa2a8e8193868df + home-server: fedcba0987654321 + +install: + # Only install on specific machines using aliases + - name: work-tools + type: group + machines: + only: ['work-laptop'] + steps: + - name: slack + type: brew + opts: + cask: true + - name: zoom + type: brew + opts: + cask: true + + # Install everywhere except the home server + - name: desktop-apps + type: group + machines: + except: ['home-server'] + steps: + - name: firefox + type: brew + opts: + cask: true + + # You can also use raw machine IDs directly + - name: special-tool + type: brew + machines: + only: ['a1b2c3d4e5f67890'] # Raw machine ID also works +``` + ### manifest ```yaml diff --git a/installer/installer.go b/installer/installer.go index d9558e8..1ee7903 100644 --- a/installer/installer.go +++ b/installer/installer.go @@ -5,6 +5,7 @@ import ( "github.com/chenasraf/sofmani/appconfig" "github.com/chenasraf/sofmani/logger" + "github.com/chenasraf/sofmani/machine" "github.com/chenasraf/sofmani/platform" "github.com/chenasraf/sofmani/utils" ) @@ -151,6 +152,16 @@ func RunInstaller(config *appconfig.AppConfig, installer IInstaller) error { logger.Debug("%s should not run on %s, skipping", name, curOS) return nil } + + machineID := machine.GetMachineID() + var machineAliases map[string]string + if config.MachineAliases != nil { + machineAliases = *config.MachineAliases + } + if !installer.GetData().Machines.GetShouldRunOnMachine(machineID, machineAliases) { + logger.Debug("%s should not run on machine %s, skipping", name, machineID) + return nil + } if !FilterInstaller(installer, config.Filter) { logger.Debug("%s is filtered, skipping", name) return nil diff --git a/installer/installer_defaults.go b/installer/installer_defaults.go index 6c7fbfa..97bb61c 100644 --- a/installer/installer_defaults.go +++ b/installer/installer_defaults.go @@ -1,9 +1,11 @@ package installer import ( - "github.com/chenasraf/sofmani/appconfig" - "github.com/chenasraf/sofmani/platform" "maps" + + "github.com/chenasraf/sofmani/appconfig" + "github.com/chenasraf/sofmani/machine" + "github.com/chenasraf/sofmani/platform" ) // InstallerWithDefaults applies default configurations to an installer data object. @@ -65,6 +67,9 @@ func InstallerWithDefaults( if override.Platforms != nil { data.Platforms = override.Platforms } + if override.Machines != nil { + data.Machines = override.Machines + } if override.PreUpdate != nil { data.PreUpdate = override.PreUpdate } @@ -112,6 +117,10 @@ func FillDefaults(data *appconfig.InstallerData) { platforms := platform.Platforms{} data.Platforms = &platforms } + if data.Machines == nil { + machines := machine.Machines{} + data.Machines = &machines + } if data.Steps == nil { data.Steps = &[]appconfig.InstallerData{} } diff --git a/machine/machine.go b/machine/machine.go new file mode 100644 index 0000000..f98556b --- /dev/null +++ b/machine/machine.go @@ -0,0 +1,47 @@ +package machine + +// Machines defines which machines a configuration applies to. +type Machines struct { + // Only specifies a list of machine IDs or aliases where the configuration should apply. + Only *[]string `json:"only" yaml:"only"` + // Except specifies a list of machine IDs or aliases where the configuration should not apply. + Except *[]string `json:"except" yaml:"except"` +} + +// GetShouldRunOnMachine determines if a configuration should run on the current machine +// based on the Only and Except fields of the Machines struct. +// The aliases parameter is a map of friendly names to machine IDs. When checking, +// aliases are resolved first, falling back to treating the value as a literal machine ID. +func (m *Machines) GetShouldRunOnMachine(machineID string, aliases map[string]string) bool { + if m == nil { + return true + } + + if m.Only != nil { + return containsMachineID(*m.Only, machineID, aliases) + } + if m.Except != nil { + return !containsMachineID(*m.Except, machineID, aliases) + } + return true +} + +// containsMachineID checks if the machine ID is in the list, resolving aliases first. +func containsMachineID(list []string, machineID string, aliases map[string]string) bool { + for _, entry := range list { + // First, try to resolve as an alias + if aliases != nil { + if resolvedID, ok := aliases[entry]; ok { + if resolvedID == machineID { + return true + } + continue + } + } + // Fall back to treating entry as a literal machine ID + if entry == machineID { + return true + } + } + return false +} diff --git a/machine/machine_id.go b/machine/machine_id.go new file mode 100644 index 0000000..ea66565 --- /dev/null +++ b/machine/machine_id.go @@ -0,0 +1,218 @@ +package machine + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "sync" +) + +var ( + cachedMachineID string + machineIDOnce sync.Once +) + +// GetMachineID returns a deterministic, unique machine identifier. +// The ID is stable across hostname changes and other system configuration changes. +// It uses platform-specific stable identifiers: +// - macOS: IOPlatformUUID (hardware UUID) +// - Linux: /etc/machine-id or /var/lib/dbus/machine-id +// - Windows: Registry MachineGuid +// +// The raw identifier is hashed with SHA-256 and truncated to 16 characters +// for a more user-friendly format while maintaining uniqueness. +func GetMachineID() string { + machineIDOnce.Do(func() { + var rawID string + var err error + + switch runtime.GOOS { + case "darwin": + rawID, err = getMachineIDDarwin() + case "linux": + rawID, err = getMachineIDLinux() + case "windows": + rawID, err = getMachineIDWindows() + default: + err = fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + if err != nil || rawID == "" { + // Fallback: generate a persistent ID file in user config directory + rawID, err = getOrCreateFallbackID() + if err != nil { + // Last resort: use a hash of available system info + rawID = getFallbackSystemInfo() + } + } + + // Hash the raw ID for privacy and consistent format + cachedMachineID = hashMachineID(rawID) + }) + + return cachedMachineID +} + +// getMachineIDDarwin retrieves the hardware UUID on macOS using ioreg. +func getMachineIDDarwin() (string, error) { + cmd := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice") + output, err := cmd.Output() + if err != nil { + return "", err + } + + // Parse the IOPlatformUUID from the output + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "IOPlatformUUID") { + // Format: "IOPlatformUUID" = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + parts := strings.Split(line, "=") + if len(parts) == 2 { + uuid := strings.TrimSpace(parts[1]) + uuid = strings.Trim(uuid, "\"") + return uuid, nil + } + } + } + + return "", fmt.Errorf("IOPlatformUUID not found") +} + +// getMachineIDLinux retrieves the machine ID on Linux from /etc/machine-id +// or /var/lib/dbus/machine-id as a fallback. +func getMachineIDLinux() (string, error) { + // Try /etc/machine-id first (systemd) + if data, err := os.ReadFile("/etc/machine-id"); err == nil { + id := strings.TrimSpace(string(data)) + if id != "" { + return id, nil + } + } + + // Fallback to /var/lib/dbus/machine-id + if data, err := os.ReadFile("/var/lib/dbus/machine-id"); err == nil { + id := strings.TrimSpace(string(data)) + if id != "" { + return id, nil + } + } + + return "", fmt.Errorf("machine-id not found") +} + +// getMachineIDWindows retrieves the MachineGuid from the Windows registry. +func getMachineIDWindows() (string, error) { + cmd := exec.Command("reg", "query", + `HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography`, + "/v", "MachineGuid") + output, err := cmd.Output() + if err != nil { + return "", err + } + + // Parse the MachineGuid from the output + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "MachineGuid") { + fields := strings.Fields(line) + if len(fields) >= 3 { + return fields[len(fields)-1], nil + } + } + } + + return "", fmt.Errorf("MachineGuid not found") +} + +// getOrCreateFallbackID creates or reads a persistent machine ID file +// in the user's config directory as a fallback mechanism. +func getOrCreateFallbackID() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + + sofmaniDir := configDir + "/sofmani" + idFile := sofmaniDir + "/machine-id" + + // Try to read existing ID + if data, err := os.ReadFile(idFile); err == nil { + id := strings.TrimSpace(string(data)) + if id != "" { + return id, nil + } + } + + // Create new ID based on available system info + newID := generateFallbackID() + + // Ensure directory exists + if err := os.MkdirAll(sofmaniDir, 0755); err != nil { + return newID, nil // Return the ID even if we can't persist it + } + + // Write the ID file + if err := os.WriteFile(idFile, []byte(newID), 0644); err != nil { + return newID, nil // Return the ID even if we can't persist it + } + + return newID, nil +} + +// generateFallbackID generates a unique ID using available system information. +func generateFallbackID() string { + info := getFallbackSystemInfo() + hash := sha256.Sum256([]byte(info)) + return hex.EncodeToString(hash[:]) +} + +// getFallbackSystemInfo gathers available system information for ID generation. +func getFallbackSystemInfo() string { + var parts []string + + // Add hostname (may change, but better than nothing) + if hostname, err := os.Hostname(); err == nil { + parts = append(parts, hostname) + } + + // Add user home directory path + if home, err := os.UserHomeDir(); err == nil { + parts = append(parts, home) + } + + // Add config directory path + if configDir, err := os.UserConfigDir(); err == nil { + parts = append(parts, configDir) + } + + // Add OS and architecture + parts = append(parts, runtime.GOOS, runtime.GOARCH) + + return strings.Join(parts, "|") +} + +// hashMachineID creates a SHA-256 hash of the raw machine ID +// and returns a truncated hex string (16 characters). +func hashMachineID(rawID string) string { + hash := sha256.Sum256([]byte(rawID)) + fullHex := hex.EncodeToString(hash[:]) + // Return first 16 characters for a more user-friendly format + return fullHex[:16] +} + +// SetMachineID overrides the detected machine ID. This is primarily used for testing. +func SetMachineID(id string) { + cachedMachineID = id + // Reset the once so that future calls won't re-detect + machineIDOnce.Do(func() {}) +} + +// ResetMachineID clears the cached machine ID. This is primarily used for testing. +func ResetMachineID() { + cachedMachineID = "" + machineIDOnce = sync.Once{} +} diff --git a/machine/machine_test.go b/machine/machine_test.go new file mode 100644 index 0000000..25543cc --- /dev/null +++ b/machine/machine_test.go @@ -0,0 +1,177 @@ +package machine + +import "testing" + +func TestMachines_GetShouldRunOnMachine(t *testing.T) { + tests := []struct { + name string + machines *Machines + machineID string + aliases map[string]string + want bool + }{ + { + name: "nil machines should run on any machine", + machines: nil, + machineID: "abc123", + aliases: nil, + want: true, + }, + { + name: "empty machines should run on any machine", + machines: &Machines{}, + machineID: "abc123", + aliases: nil, + want: true, + }, + { + name: "only matching machine ID should run", + machines: &Machines{ + Only: &[]string{"abc123", "def456"}, + }, + machineID: "abc123", + aliases: nil, + want: true, + }, + { + name: "only non-matching machine ID should not run", + machines: &Machines{ + Only: &[]string{"abc123", "def456"}, + }, + machineID: "xyz789", + aliases: nil, + want: false, + }, + { + name: "except matching machine ID should not run", + machines: &Machines{ + Except: &[]string{"abc123", "def456"}, + }, + machineID: "abc123", + aliases: nil, + want: false, + }, + { + name: "except non-matching machine ID should run", + machines: &Machines{ + Except: &[]string{"abc123", "def456"}, + }, + machineID: "xyz789", + aliases: nil, + want: true, + }, + { + name: "only takes precedence over except", + machines: &Machines{ + Only: &[]string{"abc123"}, + Except: &[]string{"abc123"}, + }, + machineID: "abc123", + aliases: nil, + want: true, + }, + { + name: "alias matching should run", + machines: &Machines{ + Only: &[]string{"work-laptop"}, + }, + machineID: "abc123", + aliases: map[string]string{"work-laptop": "abc123"}, + want: true, + }, + { + name: "alias non-matching should not run", + machines: &Machines{ + Only: &[]string{"work-laptop"}, + }, + machineID: "xyz789", + aliases: map[string]string{"work-laptop": "abc123"}, + want: false, + }, + { + name: "except with alias matching should not run", + machines: &Machines{ + Except: &[]string{"home-server"}, + }, + machineID: "abc123", + aliases: map[string]string{"home-server": "abc123"}, + want: false, + }, + { + name: "except with alias non-matching should run", + machines: &Machines{ + Except: &[]string{"home-server"}, + }, + machineID: "xyz789", + aliases: map[string]string{"home-server": "abc123"}, + want: true, + }, + { + name: "mixed aliases and literal IDs in only", + machines: &Machines{ + Only: &[]string{"work-laptop", "def456"}, + }, + machineID: "abc123", + aliases: map[string]string{"work-laptop": "abc123"}, + want: true, + }, + { + name: "literal ID fallback when no alias match", + machines: &Machines{ + Only: &[]string{"abc123"}, + }, + machineID: "abc123", + aliases: map[string]string{"other": "xyz789"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.machines.GetShouldRunOnMachine(tt.machineID, tt.aliases) + if got != tt.want { + t.Errorf("GetShouldRunOnMachine() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGetMachineID(t *testing.T) { + // Reset any cached value + ResetMachineID() + + // Get the machine ID + id := GetMachineID() + + // Should not be empty + if id == "" { + t.Error("GetMachineID() returned empty string") + } + + // Should be 16 characters (truncated hash) + if len(id) != 16 { + t.Errorf("GetMachineID() returned ID with length %d, want 16", len(id)) + } + + // Should be deterministic (same value on subsequent calls) + id2 := GetMachineID() + if id != id2 { + t.Errorf("GetMachineID() not deterministic: got %s then %s", id, id2) + } +} + +func TestSetMachineID(t *testing.T) { + // Save original and restore after test + originalID := GetMachineID() + defer SetMachineID(originalID) + + // Set a custom ID + customID := "testmachineid123" + SetMachineID(customID) + + // Verify the custom ID is returned + got := GetMachineID() + if got != customID { + t.Errorf("After SetMachineID(%s), GetMachineID() = %s", customID, got) + } +} diff --git a/main.go b/main.go index 0765cec..4be8118 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "github.com/chenasraf/sofmani/appconfig" "github.com/chenasraf/sofmani/installer" "github.com/chenasraf/sofmani/logger" + "github.com/chenasraf/sofmani/machine" "github.com/chenasraf/sofmani/utils" ) @@ -28,6 +29,12 @@ func main() { return } + // Handle --machine-id: show machine ID and exit + if cliConfig.ShowMachineID { + fmt.Println(machine.GetMachineID()) + return + } + // Set custom log file if provided if cliConfig.LogFile != nil { logger.SetLogFile(*cliConfig.LogFile) @@ -55,6 +62,14 @@ func main() { logger.Debug("%s", line) } + // Set MACHINE_ID environment variable + machineID := machine.GetMachineID() + logger.Debug("Setting env MACHINE_ID=%s", machineID) + if err := os.Setenv("MACHINE_ID", machineID); err != nil { + logger.Error("failed to set environment variable MACHINE_ID: %v", err) + return + } + if cfg.Env != nil { for k, v := range *cfg.Env { logger.Debug("Setting env %s=%s", k, v)