d7e6d64967
Option A (validation): - handlers_syncpairs.go: reject create/update when both SourceMachineID and DestMachineID are set; clear error message explains the constraint - engine.go: detect 'both remote' rsync error at runtime and surface it as error_code=remote_to_remote_unsupported with a human-readable message Option B (remote-to-remote support): - rsync_runner.go: add RunRemote() method that SSHs to the source machine and runs rsync locally there (src=local path, dst=user@host:/path), streaming output back through the onLine callback - engine.go: when both srcMachine and dstMachine are non-nil, use RunRemote() instead of Run(), SSHing to srcMachine and running rsync from there. Also wake dstMachine via WoL when both sides are remote.
290 lines
5.7 KiB
Go
290 lines
5.7 KiB
Go
package syncengine
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"strings"
|
|
)
|
|
|
|
type RsyncResult struct {
|
|
ExitCode int
|
|
Stdout string
|
|
Stderr string
|
|
Stats *RsyncStats
|
|
}
|
|
|
|
type RsyncRunner struct {
|
|
sshDir string
|
|
privKey string
|
|
}
|
|
|
|
type SyncPairConfig struct {
|
|
ID int64
|
|
Name string
|
|
SourceMachineID *int64
|
|
SourcePath string
|
|
DestMachineID *int64
|
|
DestPath string
|
|
Direction string
|
|
RsyncFlags string
|
|
ExcludePatterns []string
|
|
Source string
|
|
Dest string
|
|
}
|
|
|
|
func NewRsyncRunner(sshDir, privKey string) *RsyncRunner {
|
|
return &RsyncRunner{sshDir: sshDir, privKey: privKey}
|
|
}
|
|
|
|
func (r *RsyncRunner) Run(ctx context.Context, pair *SyncPairConfig, onLine func(stream string, line string)) (*RsyncResult, error) {
|
|
args := r.buildArgs(pair)
|
|
|
|
cmd := exec.CommandContext(ctx, "rsync", args...)
|
|
if r.privKey != "" {
|
|
sshCmd := fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s",
|
|
r.privKey, strings.TrimRight(r.sshDir, "/")+"/known_hosts")
|
|
cmd.Args = append([]string{"rsync", "-e", sshCmd}, args[1:]...)
|
|
}
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("starting rsync: %w", err)
|
|
}
|
|
|
|
var outLines, errLines []string
|
|
done := make(chan struct{})
|
|
go func() {
|
|
br := io.Reader(stdout)
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := br.Read(buf)
|
|
if n > 0 {
|
|
line := strings.TrimRight(string(buf[:n]), "\r\n")
|
|
if line != "" {
|
|
outLines = append(outLines, line)
|
|
if onLine != nil {
|
|
onLine("stdout", line)
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
select {
|
|
case <-done:
|
|
default:
|
|
close(done)
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
br := io.Reader(stderr)
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := br.Read(buf)
|
|
if n > 0 {
|
|
line := strings.TrimRight(string(buf[:n]), "\r\n")
|
|
if line != "" {
|
|
errLines = append(errLines, line)
|
|
if onLine != nil {
|
|
onLine("stderr", line)
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
select {
|
|
case <-done:
|
|
default:
|
|
close(done)
|
|
}
|
|
}()
|
|
|
|
err = cmd.Wait()
|
|
<-done
|
|
|
|
result := &RsyncResult{
|
|
ExitCode: 0,
|
|
Stdout: strings.Join(outLines, "\n"),
|
|
Stderr: strings.Join(errLines, "\n"),
|
|
Stats: ParseFinalStats(strings.Join(outLines, "\n")),
|
|
}
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
result.ExitCode = exitErr.ExitCode()
|
|
} else {
|
|
result.ExitCode = -1
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (r *RsyncRunner) buildArgs(pair *SyncPairConfig) []string {
|
|
var args []string
|
|
|
|
flags := strings.Fields(pair.RsyncFlags)
|
|
args = append(args, flags...)
|
|
|
|
for _, pattern := range pair.ExcludePatterns {
|
|
args = append(args, "--exclude="+pattern)
|
|
}
|
|
|
|
if pair.Direction == "mirror" {
|
|
args = append(args, "--delete")
|
|
}
|
|
|
|
if pair.Direction == "pull" {
|
|
args = append(args, pair.Dest, pair.Source)
|
|
} else {
|
|
args = append(args, pair.Source, pair.Dest)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
func (r *RsyncRunner) buildRemoteArgs(pair *SyncPairConfig) []string {
|
|
var args []string
|
|
|
|
flags := strings.Fields(pair.RsyncFlags)
|
|
args = append(args, flags...)
|
|
|
|
for _, pattern := range pair.ExcludePatterns {
|
|
args = append(args, "--exclude="+pattern)
|
|
}
|
|
|
|
if pair.Direction == "mirror" {
|
|
args = append(args, "--delete")
|
|
}
|
|
|
|
if pair.Direction == "pull" {
|
|
args = append(args, pair.Dest, pair.Source)
|
|
} else {
|
|
args = append(args, pair.Source, pair.Dest)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
type RemoteMachine struct {
|
|
Host string
|
|
Port int
|
|
SSHUser string
|
|
PrivKey string
|
|
}
|
|
|
|
func (r *RsyncRunner) RunRemote(ctx context.Context, pair *SyncPairConfig, remote RemoteMachine, onLine func(stream string, line string)) (*RsyncResult, error) {
|
|
args := r.buildRemoteArgs(pair)
|
|
|
|
sshArgs := []string{
|
|
"ssh",
|
|
"-i", remote.PrivKey,
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "UserKnownHostsFile=" + strings.TrimRight(r.sshDir, "/") + "/known_hosts",
|
|
"-tt",
|
|
"-p", fmt.Sprintf("%d", remote.Port),
|
|
fmt.Sprintf("%s@%s", remote.SSHUser, remote.Host),
|
|
"rsync",
|
|
}
|
|
sshArgs = append(sshArgs, args...)
|
|
|
|
cmd := exec.CommandContext(ctx, "ssh", sshArgs...)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("starting ssh: %w", err)
|
|
}
|
|
|
|
var outLines, errLines []string
|
|
done := make(chan struct{})
|
|
go func() {
|
|
br := io.Reader(stdout)
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := br.Read(buf)
|
|
if n > 0 {
|
|
line := strings.TrimRight(string(buf[:n]), "\r\n")
|
|
if line != "" {
|
|
outLines = append(outLines, line)
|
|
if onLine != nil {
|
|
onLine("stdout", line)
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
select {
|
|
case <-done:
|
|
default:
|
|
close(done)
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
br := io.Reader(stderr)
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := br.Read(buf)
|
|
if n > 0 {
|
|
line := strings.TrimRight(string(buf[:n]), "\r\n")
|
|
if line != "" {
|
|
errLines = append(errLines, line)
|
|
if onLine != nil {
|
|
onLine("stderr", line)
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
select {
|
|
case <-done:
|
|
default:
|
|
close(done)
|
|
}
|
|
}()
|
|
|
|
err = cmd.Wait()
|
|
<-done
|
|
|
|
result := &RsyncResult{
|
|
ExitCode: 0,
|
|
Stdout: strings.Join(outLines, "\n"),
|
|
Stderr: strings.Join(errLines, "\n"),
|
|
Stats: ParseFinalStats(strings.Join(outLines, "\n")),
|
|
}
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
result.ExitCode = exitErr.ExitCode()
|
|
} else {
|
|
result.ExitCode = -1
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|