feat: install on specific machines by machine id

This commit is contained in:
2026-01-11 00:55:05 +02:00
parent 09fed15745
commit bb36269d3d
11 changed files with 596 additions and 19 deletions

View File

@@ -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("")

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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{}
}

47
machine/machine.go Normal file
View File

@@ -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
}

218
machine/machine_id.go Normal file
View File

@@ -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{}
}

177
machine/machine_test.go Normal file
View File

@@ -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)
}
}

15
main.go
View File

@@ -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)