package deploy import ( "bytes" "context" "fmt" "os/exec" "time" "git.t-juice.club/torjus/homelab-deploy/internal/messages" ) // Executor handles the execution of nixos-rebuild commands. type Executor struct { flakeURL string hostname string timeout time.Duration } // NewExecutor creates a new deployment executor. func NewExecutor(flakeURL, hostname string, timeout time.Duration) *Executor { return &Executor{ flakeURL: flakeURL, hostname: hostname, timeout: timeout, } } // Result contains the result of a deployment execution. type Result struct { Success bool ExitCode int Stdout string Stderr string Error error } // ExecuteOptions contains optional settings for Execute. type ExecuteOptions struct { // HeartbeatInterval is how often to call the heartbeat callback. // If zero, no heartbeat is sent. HeartbeatInterval time.Duration // HeartbeatCallback is called periodically with elapsed time while the command runs. HeartbeatCallback func(elapsed time.Duration) } // ValidateRevision checks if a revision exists in the remote repository. // It uses git ls-remote to verify the ref exists. func (e *Executor) ValidateRevision(ctx context.Context, revision string) error { // Extract the base URL for git ls-remote // flakeURL is like git+https://git.example.com/user/repo.git // We need to strip the git+ prefix for git ls-remote gitURL := e.flakeURL if len(gitURL) > 4 && gitURL[:4] == "git+" { gitURL = gitURL[4:] } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", gitURL, revision) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { if ctx.Err() == context.DeadlineExceeded { return fmt.Errorf("timeout validating revision") } return fmt.Errorf("revision %q not found: %w", revision, err) } return nil } // Execute runs nixos-rebuild with the specified action and revision. func (e *Executor) Execute(ctx context.Context, action messages.Action, revision string) *Result { return e.ExecuteWithOptions(ctx, action, revision, nil) } // ExecuteWithOptions runs nixos-rebuild with the specified action, revision, and options. func (e *Executor) ExecuteWithOptions(ctx context.Context, action messages.Action, revision string, opts *ExecuteOptions) *Result { ctx, cancel := context.WithTimeout(ctx, e.timeout) defer cancel() // Build the flake reference: ?ref=# flakeRef := fmt.Sprintf("%s?ref=%s#%s", e.flakeURL, revision, e.hostname) cmd := exec.CommandContext(ctx, "nixos-rebuild", string(action), "--flake", flakeRef) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr // Start the command startTime := time.Now() if err := cmd.Start(); err != nil { return &Result{ Success: false, ExitCode: -1, Error: fmt.Errorf("failed to start command: %w", err), } } // Set up heartbeat if configured var heartbeatDone chan struct{} if opts != nil && opts.HeartbeatInterval > 0 && opts.HeartbeatCallback != nil { heartbeatDone = make(chan struct{}) go func() { ticker := time.NewTicker(opts.HeartbeatInterval) defer ticker.Stop() for { select { case <-heartbeatDone: return case <-ticker.C: opts.HeartbeatCallback(time.Since(startTime)) } } }() } // Wait for command to complete err := cmd.Wait() // Stop heartbeat goroutine if heartbeatDone != nil { close(heartbeatDone) } result := &Result{ Stdout: stdout.String(), Stderr: stderr.String(), } if err != nil { result.Success = false result.Error = err if ctx.Err() == context.DeadlineExceeded { result.Error = fmt.Errorf("deployment timed out after %v", e.timeout) } if exitErr, ok := err.(*exec.ExitError); ok { result.ExitCode = exitErr.ExitCode() } else { result.ExitCode = -1 } } else { result.Success = true result.ExitCode = 0 } return result } // BuildCommand returns the command that would be executed (for logging/debugging). func (e *Executor) BuildCommand(action messages.Action, revision string) string { flakeRef := fmt.Sprintf("%s?ref=%s#%s", e.flakeURL, revision, e.hostname) return fmt.Sprintf("nixos-rebuild %s --flake %s", action, flakeRef) }