diff --git a/docs/ec2-macos-utils.md b/docs/ec2-macos-utils.md index db947a9..18c8a6e 100644 --- a/docs/ec2-macos-utils.md +++ b/docs/ec2-macos-utils.md @@ -18,5 +18,8 @@ help text and usages that accompany them. ### SEE ALSO +* [ec2-macos-utils check](ec2-macos-utils_check.md) - run various system checks +* [ec2-macos-utils debug](ec2-macos-utils_debug.md) - debug utilities for EC2 macOS instances * [ec2-macos-utils grow](ec2-macos-utils_grow.md) - resize container to max size +* [ec2-macos-utils watchdog](ec2-macos-utils_watchdog.md) - monitor system health diff --git a/docs/ec2-macos-utils_check.md b/docs/ec2-macos-utils_check.md new file mode 100644 index 0000000..2cd3ec3 --- /dev/null +++ b/docs/ec2-macos-utils_check.md @@ -0,0 +1,25 @@ +## ec2-macos-utils check + +run various system checks + +### Synopsis + +run diagnostics and checks on various system components + +### Options + +``` + -h, --help help for check +``` + +### Options inherited from parent commands + +``` + -v, --verbose Enable verbose logging output +``` + +### SEE ALSO + +* [ec2-macos-utils](ec2-macos-utils.md) - utilities for EC2 macOS instances +* [ec2-macos-utils check imds](ec2-macos-utils_check_imds.md) - check IMDS connectivity + diff --git a/docs/ec2-macos-utils_check_imds.md b/docs/ec2-macos-utils_check_imds.md new file mode 100644 index 0000000..7016eb1 --- /dev/null +++ b/docs/ec2-macos-utils_check_imds.md @@ -0,0 +1,28 @@ +## ec2-macos-utils check imds + +check IMDS connectivity + +### Synopsis + +verifies connectivity to the EC2 Instance Metadata Service + +``` +ec2-macos-utils check imds [flags] +``` + +### Options + +``` + -h, --help help for imds +``` + +### Options inherited from parent commands + +``` + -v, --verbose Enable verbose logging output +``` + +### SEE ALSO + +* [ec2-macos-utils check](ec2-macos-utils_check.md) - run various system checks + diff --git a/docs/ec2-macos-utils_debug.md b/docs/ec2-macos-utils_debug.md new file mode 100644 index 0000000..a89d91a --- /dev/null +++ b/docs/ec2-macos-utils_debug.md @@ -0,0 +1,25 @@ +## ec2-macos-utils debug + +debug utilities for EC2 macOS instances + +### Synopsis + +utilities and tools for debugging EC2 macOS instances + +### Options + +``` + -h, --help help for debug +``` + +### Options inherited from parent commands + +``` + -v, --verbose Enable verbose logging output +``` + +### SEE ALSO + +* [ec2-macos-utils](ec2-macos-utils.md) - utilities for EC2 macOS instances +* [ec2-macos-utils debug create-sysdiagnose](ec2-macos-utils_debug_create-sysdiagnose.md) - create sysdiagnose archive + diff --git a/docs/ec2-macos-utils_debug_create-sysdiagnose.md b/docs/ec2-macos-utils_debug_create-sysdiagnose.md new file mode 100644 index 0000000..f2d19b8 --- /dev/null +++ b/docs/ec2-macos-utils_debug_create-sysdiagnose.md @@ -0,0 +1,34 @@ +## ec2-macos-utils debug create-sysdiagnose + +create sysdiagnose archive + +### Synopsis + +creates a sysdiagnose archive including logs, system stats, +and other debug data. The resulting archive will be saved in the specified +output directory. + +This command requires root privileges. Run with sudo if not running as root. + +``` +ec2-macos-utils debug create-sysdiagnose [flags] +``` + +### Options + +``` + -h, --help help for create-sysdiagnose + --output-dir string directory where the sysdiagnose archive will be saved (default "/tmp") + --timeout duration set the timeout for creation (e.g. 10m, 30m, 1.5h) (default 15m0s) +``` + +### Options inherited from parent commands + +``` + -v, --verbose Enable verbose logging output +``` + +### SEE ALSO + +* [ec2-macos-utils debug](ec2-macos-utils_debug.md) - debug utilities for EC2 macOS instances + diff --git a/docs/ec2-macos-utils_watchdog.md b/docs/ec2-macos-utils_watchdog.md new file mode 100644 index 0000000..68b9806 --- /dev/null +++ b/docs/ec2-macos-utils_watchdog.md @@ -0,0 +1,26 @@ +## ec2-macos-utils watchdog + +monitor system health + +### Synopsis + +monitor system health and collect diagnostic data. +Contains subcommands for monitoring various aspects of system health. + +### Options + +``` + -h, --help help for watchdog +``` + +### Options inherited from parent commands + +``` + -v, --verbose Enable verbose logging output +``` + +### SEE ALSO + +* [ec2-macos-utils](ec2-macos-utils.md) - utilities for EC2 macOS instances +* [ec2-macos-utils watchdog network-health-monitor](ec2-macos-utils_watchdog_network-health-monitor.md) - monitor network health + diff --git a/docs/ec2-macos-utils_watchdog_network-health-monitor.md b/docs/ec2-macos-utils_watchdog_network-health-monitor.md new file mode 100644 index 0000000..2820a2b --- /dev/null +++ b/docs/ec2-macos-utils_watchdog_network-health-monitor.md @@ -0,0 +1,35 @@ +## ec2-macos-utils watchdog network-health-monitor + +monitor network health + +### Synopsis + +monitor network health with periodic checks. +A sysdiagnose will be collected on first failure, after which the monitor will exit. + +This command requires root privileges. Run with sudo if not running as root. + +``` +ec2-macos-utils watchdog network-health-monitor [flags] +``` + +### Options + +``` + -h, --help help for network-health-monitor + --interval duration interval between network checks (default 5m0s) + --output-base-dir string base directory for sysdiagnose output (default "/private/var/db/ec2-macos-utils/sysdiagnose") + --startup-delay duration delay before starting checks (default 5m0s) + --sysdiagnose-timeout duration timeout for sysdiagnose collection (default 15m0s) +``` + +### Options inherited from parent commands + +``` + -v, --verbose Enable verbose logging output +``` + +### SEE ALSO + +* [ec2-macos-utils watchdog](ec2-macos-utils_watchdog.md) - monitor system health + diff --git a/go.mod b/go.mod index 3d6e854..39dff20 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.23.3 require ( github.com/Masterminds/semver v1.5.0 + github.com/docker/go-units v0.5.0 github.com/dustin/go-humanize v1.0.1 github.com/golang/mock v1.6.0 github.com/sirupsen/logrus v1.9.3 diff --git a/go.sum b/go.sum index f97ff1b..1772197 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= diff --git a/internal/cmd/check_imds.go b/internal/cmd/check_imds.go new file mode 100644 index 0000000..4fed87b --- /dev/null +++ b/internal/cmd/check_imds.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +const ( + imdsTokenURL = "http://169.254.169.254/latest/api/token" +) + +func checkCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "check", + Short: "run various system checks", + Long: "run diagnostics and checks on various system components", + } + + cmd.AddCommand( + checkImdsCommand(), + ) + + return cmd +} + +func checkImdsCommand() *cobra.Command { + return &cobra.Command{ + Use: "imds", + Short: "check IMDS connectivity", + Long: "verifies connectivity to the EC2 Instance Metadata Service", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runCheckIMDS(cmd.Context()) + }, + } +} + +func runCheckIMDS(ctx context.Context) error { + const dialerTimeout = 5 * time.Second // timeout for the dialed network connection to start + const imdsTokenLifetime = "941" // arbitrary short-lived token lifetime + + logrus.Info("Starting IMDS connectivity check") + + client := &http.Client{Timeout: dialerTimeout} + + req, err := http.NewRequestWithContext(ctx, "PUT", imdsTokenURL, nil) + if err != nil { + logrus.WithError(err).Error("Failed to create request") + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Add("X-aws-ec2-metadata-token-ttl-seconds", imdsTokenLifetime) + + resp, err := client.Do(req) + if err != nil { + logrus.WithError(err).Error("Failed to connect to IMDS") + return fmt.Errorf("failed to connect to IMDS: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + _, err = io.ReadAll(resp.Body) + if err != nil { + logrus.WithError(err).Error("Failed to read IMDS response") + return fmt.Errorf("failed to read IMDS response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logrus.WithField("statusCode", resp.StatusCode).Error("IMDS returned non-200 status code") + return fmt.Errorf("IMDS returned non-200 status code: %d", resp.StatusCode) + } + + logrus.Info("IMDS connectivity check passed") + return nil +} diff --git a/internal/cmd/debug.go b/internal/cmd/debug.go new file mode 100644 index 0000000..fd8245f --- /dev/null +++ b/internal/cmd/debug.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/docker/go-units" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/aws/ec2-macos-utils/internal/sysdiagnose" +) + +const ( + // Timeouts + sysdiagnoseDefaultTimeout = 15 * time.Minute + sysdiagnoseMinTimeout = 5 * time.Minute + + // Timestamp format + sysdiagnoseTimestampFormat = "20060102_150405" // YYYYMMDD_HHMMSS +) + +type sysdiagnoseArgs struct { + outputDir string + timeout time.Duration +} + +func debugCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "debug", + Short: "debug utilities for EC2 macOS instances", + Long: "utilities and tools for debugging EC2 macOS instances", + } + + cmd.AddCommand(createSysdiagnoseCommand()) + + return cmd +} + +func createSysdiagnoseCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create-sysdiagnose", + Short: "create sysdiagnose archive", + Long: strings.TrimSpace(` +creates a sysdiagnose archive including logs, system stats, +and other debug data. The resulting archive will be saved in the specified +output directory. + +This command requires root privileges. Run with sudo if not running as root. + `), + } + + var args sysdiagnoseArgs + cmd.Flags().StringVar(&args.outputDir, "output-dir", os.TempDir(), "directory where the sysdiagnose archive will be saved") + cmd.Flags().DurationVar(&args.timeout, "timeout", sysdiagnoseDefaultTimeout, "set the timeout for creation (e.g. 10m, 30m, 1.5h)") + + cmd.RunE = func(cmd *cobra.Command, cmdArgs []string) error { + if os.Geteuid() != 0 { + return errors.New("root privileges required - run with sudo") + } + + ctx := cmd.Context() + + if args.timeout < sysdiagnoseMinTimeout { + return fmt.Errorf("timeout must be at least %v to ensure creation can complete", sysdiagnoseMinTimeout) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, args.timeout) + defer cancel() + ctx = timeoutCtx + + logrus.WithField("args", args).Debug("Running sysdiagnose") + if err := runSysdiagnose(ctx, args); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return errors.New("creation timeout exceeded") + } + return err + } + + return nil + } + + return cmd +} + +func runSysdiagnose(ctx context.Context, args sysdiagnoseArgs) error { + // Create output directory with owner-only permissions (rwx------) since it will contain sensitive diagnostic data + if err := os.MkdirAll(args.outputDir, 0700); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + timestamp := time.Now().UTC().Format(sysdiagnoseTimestampFormat) + archiveName := fmt.Sprintf("sysdiagnose_%s", timestamp) + outputPath := filepath.Join(args.outputDir, archiveName+".tar.gz") + + logrus.WithFields(logrus.Fields{ + "output_path": outputPath, + }).Info("Starting sysdiagnose creation") + + outputReader, err := sysdiagnose.Collect(ctx, archiveName) + if err != nil { + return fmt.Errorf("failed to create sysdiagnose: %w", err) + } + defer func() { _ = outputReader.Close() }() + + // Create output file with read-only permissions (r--------) since diagnostic data should not be modified + output, err := os.OpenFile(outputPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0400) + if err != nil { + return fmt.Errorf("failed to create output file %s: %w", outputPath, err) + } + defer func() { _ = output.Close() }() + + written, err := io.Copy(output, outputReader) + if err != nil { + // Ignore error from Remove() since: + // 1. We're already in an error state from io.Copy + // 2. If Remove() fails, the incomplete/corrupt file remaining is not critical + _ = os.Remove(outputPath) + return fmt.Errorf("failed to write sysdiagnose data: %w", err) + } + + logrus.WithFields(logrus.Fields{ + "output_path": outputPath, + "bytes": written, + }).Infof("Sysdiagnose creation completed (%s)", units.HumanSize(float64(written))) + + return nil +} diff --git a/internal/cmd/network_health_monitor.go b/internal/cmd/network_health_monitor.go new file mode 100644 index 0000000..f5db5fb --- /dev/null +++ b/internal/cmd/network_health_monitor.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/aws/ec2-macos-utils/internal/system" +) + +const ( + networkMonitorDefaultInterval = 5 * time.Minute + networkMonitorDefaultStartupDelay = 5 * time.Minute + networkMonitorDefaultOutputBaseDir = "/private/var/db/ec2-macos-utils/sysdiagnose" +) + +type networkHealthMonitorArgs struct { + interval time.Duration + startupDelay time.Duration + outputDir string + sysdiagnoseTimeout time.Duration +} + +func newNetworkHealthMonitorCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "network-health-monitor", + Short: "monitor network health", + Long: strings.TrimSpace(` +monitor network health with periodic checks. +A sysdiagnose will be collected on first failure, after which the monitor will exit. + +This command requires root privileges. Run with sudo if not running as root. + `), + } + + var args networkHealthMonitorArgs + cmd.Flags().DurationVar(&args.interval, "interval", networkMonitorDefaultInterval, "interval between network checks") + cmd.Flags().DurationVar(&args.startupDelay, "startup-delay", networkMonitorDefaultStartupDelay, "delay before starting checks") + cmd.Flags().StringVar(&args.outputDir, "output-base-dir", networkMonitorDefaultOutputBaseDir, "base directory for sysdiagnose output") + cmd.Flags().DurationVar(&args.sysdiagnoseTimeout, "sysdiagnose-timeout", sysdiagnoseDefaultTimeout, "timeout for sysdiagnose collection") + + cmd.PreRunE = func(cmd *cobra.Command, _ []string) error { + if os.Geteuid() != 0 { + return errors.New("root privileges required - run with sudo") + } + + if args.interval < 0 { + return errors.New("interval cannot be negative") + } + + if args.startupDelay < 0 { + return errors.New("startup delay cannot be negative") + } + + if args.sysdiagnoseTimeout < sysdiagnoseMinTimeout { + return fmt.Errorf("timeout must be at least %v to ensure creation can complete", sysdiagnoseMinTimeout) + } + + return nil + } + + cmd.RunE = func(cmd *cobra.Command, _ []string) error { + // Get collection prefix for potential use later + prefix, err := getCollectionPrefix() + if err != nil { + logrus.WithError(err).Warn("Failed to get prefix, using 'unknown'") + prefix = "unknown" + } + + // Create only the base output directory + if err := os.MkdirAll(args.outputDir, 0700); err != nil { + return fmt.Errorf("base output directory creation: %w", err) + } + + // Check if sysdiagnose already exists in the prefix directory + prefixDir := filepath.Join(args.outputDir, prefix) + existing, err := filepath.Glob(filepath.Join(prefixDir, "sysdiagnose_*.tar.gz")) + if err != nil { + return fmt.Errorf("invalid glob pattern: %w", err) + } + if len(existing) > 0 { + logrus.Warn("Monitor already captured sysdiagnose for failure, stopping watchdog") + return nil + } + + // Set the final output directory + args.outputDir = prefixDir + + return runNetworkHealthMonitor(cmd.Context(), args) + } + + return cmd +} + +func runNetworkHealthMonitor(ctx context.Context, args networkHealthMonitorArgs) error { + logrus.WithField("delay", args.startupDelay).Info("Waiting before starting network checks") + + // Handle startup delay + select { + case <-time.After(args.startupDelay): + case <-ctx.Done(): + return ctx.Err() + } + + timer := time.NewTimer(args.interval) + defer timer.Stop() + + logrus.WithField("interval", args.interval).Info("Starting network health monitoring") + + sysdiagnoseCollectionArgs := sysdiagnoseArgs{ + outputDir: args.outputDir, + timeout: args.sysdiagnoseTimeout, + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + sysdiagnoseCollected, err := checkNetworkAndCollect(ctx, sysdiagnoseCollectionArgs) + timer.Reset(args.interval) + + if err != nil { + logrus.WithError(err).Error("Sysdiagnose collection failed") + continue + } + if sysdiagnoseCollected { + logrus.Info("Sysdiagnose collected, stopping watchdog") + return nil + } + } + } +} + +func checkNetworkAndCollect(ctx context.Context, sysArgs sysdiagnoseArgs) (bool, error) { + if err := runCheckIMDS(ctx); err != nil { + logrus.WithError(err).Warn("IMDS check failed, collecting sysdiagnose") + + // Create the directory before collecting sysdiagnose + if err := os.MkdirAll(sysArgs.outputDir, 0700); err != nil { + return false, fmt.Errorf("sysdiagnose output directory creation: %w", err) + } + + if err := runSysdiagnose(ctx, sysArgs); err != nil { + return false, fmt.Errorf("sysdiagnose collection: %w", err) + } + + return true, nil + } + + return false, nil +} + +func getCollectionPrefix() (string, error) { + return system.GetHostIOPlatformUUID() +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 49ce6c1..b2de2f4 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -22,6 +22,9 @@ func MainCommand() *cobra.Command { cmds := []*cobra.Command{ growContainerCommand(), + checkCommand(), + debugCommand(), + watchdogCommand(), } for i := range cmds { cmd.AddCommand(cmds[i]) diff --git a/internal/cmd/watchdog.go b/internal/cmd/watchdog.go new file mode 100644 index 0000000..edc1861 --- /dev/null +++ b/internal/cmd/watchdog.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" +) + +func watchdogCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "watchdog", + Short: "monitor system health", + Long: strings.TrimSpace(` +monitor system health and collect diagnostic data. +Contains subcommands for monitoring various aspects of system health. + `), + } + + cmd.AddCommand(newNetworkHealthMonitorCommand()) + return cmd +} diff --git a/internal/sysdiagnose/create_sysdiagnose.go b/internal/sysdiagnose/create_sysdiagnose.go new file mode 100644 index 0000000..b691603 --- /dev/null +++ b/internal/sysdiagnose/create_sysdiagnose.go @@ -0,0 +1,96 @@ +package sysdiagnose + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/sirupsen/logrus" +) + +const ( + // systemSysdiagnoseExecutable is the hardcoded path to the macOS sysdiagnose executable + systemSysdiagnoseExecutable = "/usr/bin/sysdiagnose" +) + +// Collect executes a full run of sysdiagnose and returns a handle to read the +// resulting archive. Callers should close the returned io.ReadCloser when +// finished. Sysdiagnose requires root privileges to collect system data and an +// error will be returned if called without root privileges. +func Collect(ctx context.Context, archiveName string) (io.ReadCloser, error) { + // Validate archive name + if archiveName == "" { + return nil, errors.New("archive name required") + } + if filepath.Base(archiveName) != filepath.Clean(archiveName) { + return nil, errors.New("archive name must be a valid path basename without directory parts") + } + + workDir, err := os.MkdirTemp("", "sysdiagnose-helper*") + if err != nil { + return nil, fmt.Errorf("failed to create directory: %w", err) + } + + if err = os.Chmod(workDir, 0700); err != nil { + return nil, fmt.Errorf("unable to chmod sysdiagnose dir: %w", err) + } + defer func() { _ = os.RemoveAll(workDir) }() + + archiveOutputPath := filepath.Join(workDir, fmt.Sprintf("%s.tar.gz", archiveName)) + args, err := sysdiagnoseArgs(archiveOutputPath) + if err != nil { + return nil, fmt.Errorf("error building sysdiagnose args: %w", err) + } + logrus.WithContext(ctx).WithField("args", args).Debug("preparing sysdiagnose collection") + + cmd := exec.CommandContext(ctx, systemSysdiagnoseExecutable, args...) + + logrus.WithContext(ctx).WithFields(logrus.Fields{ + "archive_name": archiveName, + "command": cmd.String(), + }).Info("running sysdiagnose - this produces large archive file in a few minutes, usually 100s of MB") + + tStart := time.Now() + err = cmd.Run() + if err != nil { + return nil, fmt.Errorf("error running sysdiagnose: %w", err) + } + + runtime := time.Since(tStart).Truncate(time.Second) + logrus.WithContext(ctx).WithField("runtime_seconds", runtime.Seconds()).Info("sysdiagnose collected") + + // now open a handle to retain the IO stream and remove the filesystem entry + // before returning to caller - this ensures the library call isn't the one + // leaking data on disk. + handle, err := os.OpenFile(archiveOutputPath, os.O_RDONLY, 0) + if err != nil { + return nil, fmt.Errorf("open result handle: %w", err) + } + + // Note: The file is intentionally removed while keeping the handle open. + // This is a common pattern in Unix systems - the file will remain accessible + // through the handle until it's closed, but won't be visible in the filesystem. + if err := os.Remove(archiveOutputPath); err != nil { + logrus.WithContext(ctx).WithField("path", archiveOutputPath).Warn("sysdiagnose unable to remove temporary file") + } + + return handle, nil +} + +func sysdiagnoseArgs(outputFileFullPath string) ([]string, error) { + if outputFileFullPath == "" { + return nil, errors.New("output file path required") + } + + return []string{ + "-f", filepath.Dir(outputFileFullPath), // output directory + "-A", filepath.Base(outputFileFullPath), // archive name + "-u", // without UI feedback + "-b", // without showing Finder + }, nil +} diff --git a/internal/system/system.go b/internal/system/system.go index 4789501..6616c8b 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -1,10 +1,14 @@ package system import ( + "bufio" "bytes" + "errors" "fmt" "io" "os" + "os/exec" + "strings" "howett.net/plist" ) @@ -77,7 +81,7 @@ func (v *VersionInfo) Product() (*Product, error) { func decodeVersionInfo(reader io.ReadSeeker) (*VersionInfo, error) { // Decode the system version plist into the VersionInfo struct var version VersionInfo - decoder := plist.NewDecoder(reader) + decoder := plist.NewDecoder(reader) if err := decoder.Decode(&version); err != nil { return nil, fmt.Errorf("system failed to decode contents of reader: %w", err) } @@ -118,3 +122,57 @@ func readProductVersionFile(path string) (*VersionInfo, error) { } return version, nil } + +// GetHostIOPlatformUUID retrieves the host's platform UUID +// which is unique for each Mac device +func GetHostIOPlatformUUID() (string, error) { + out, err := queryIORegistryPlatformEntry() + if err != nil { + return "", err + } + return parseIOPlatformUUID(out) +} + +// queryIORegistryPlatformEntry executes the ioreg command and returns its output +func queryIORegistryPlatformEntry() ([]byte, error) { + cmd := exec.Command("ioreg", "-d1", "-c", "IOPlatformExpertDevice", "-r", "-w0") + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ioreg query: %w", err) + } + return out, nil +} + +// parseIOPlatformUUID extracts the platform UUID from ioreg output +func parseIOPlatformUUID(data []byte) (string, error) { + // platformUUIDKeyToken is the key identifier used to locate the IOPlatformUUID + const platformUUIDKeyToken = `"IOPlatformUUID"` + + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := scanner.Text() + if !strings.Contains(line, platformUUIDKeyToken) { + continue + } + + // Parse the line containing UUID + fields := strings.Fields(strings.ReplaceAll(line, `"`, "")) + if len(fields) != 3 { + continue + } + key, uuid := fields[0], fields[2] + if key != strings.Trim(platformUUIDKeyToken, `"`) { + continue + } + + if uuid != "" { + return uuid, nil + } + } + + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("error scanning ioreg output: %w", err) + } + + return "", errors.New("UUID not found in ioreg output") +} diff --git a/internal/system/system_test.go b/internal/system/system_test.go new file mode 100644 index 0000000..88f9b15 --- /dev/null +++ b/internal/system/system_test.go @@ -0,0 +1,116 @@ +package system + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseIOPlatformUUID(t *testing.T) { + tests := []struct { + name string + input []byte + want string + wantErr bool + }{ + { + name: "valid UUID", + input: []byte(` + "IOPlatformUUID" = "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV" + `), + want: "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV", + wantErr: false, + }, + { + name: "empty input", + input: []byte(``), + want: "", + wantErr: true, + }, + { + name: "malformed line - missing equals", + input: []byte(` + "IOPlatformUUID" "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV" + `), + want: "", + wantErr: true, + }, + { + name: "malformed line - extra fields", + input: []byte(` + "IOPlatformUUID" = "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV" extra + `), + want: "", + wantErr: true, + }, + { + name: "empty UUID value", + input: []byte(` + "IOPlatformUUID" = "" + `), + want: "", + wantErr: true, + }, + { + name: "wrong key", + input: []byte(` + "WrongKey" = "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV" + `), + want: "", + wantErr: true, + }, + { + name: "actual ioreg format", + input: []byte(`+-o Root + +-o IOPlatformExpertDevice + { + "IOPlatformUUID" = "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV" + } + `), + want: "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV", + wantErr: false, + }, + { + name: "ioreg format with multiple entries", + input: []byte(`+-o J314sAP + { + "manufacturer" = <"Apple Inc."> + "model" = <"MacBookPro18,3"> + "IOPlatformSerialNumber" = "ABCDE1FGHI" + "IOPlatformUUID" = "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV" + "device_type" = <"bootrom"> + } + `), + want: "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV", + wantErr: false, + }, + { + name: "malformed ioreg format - concatenated entries", + input: []byte(`+-o J314sAP + { + "manufacturer" = <"Apple Inc."> + "model" = <"MacBookPro18,3"> + "IOPlatformSerialNumber" = "ABCDE1FGHI""IOPlatformUUID" = "ABCD1234-5678-90EF-GHIJ-KLMNOPQRSTUV""device_type" = <"bootrom"> + } + `), + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseIOPlatformUUID(tt.input) + + // Handle error expectations + if tt.wantErr { + assert.Error(t, err) + return + } + + // Handle success expectations + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +}