Module: Twin::Sync

Defined in:
lib/twin/sync.rb

Constant Summary collapse

ITEMIZE_CHANGE =

A line from rsync --itemize-changes describing a real change: an itemize code whose first column is the update type (< > c h *) and second the file type (f d L D S), e.g. ">f+++++++++", "cd+++++++++", "*deleting". No-op runs emit no such line; headers ("sending …", "created directory …") and the summary don't match. Deterministic — no scraping of prose.

/\A[<>ch*][fdLDS]/

Class Method Summary collapse

Class Method Details

.backup_args(job) ⇒ Object

Safety net for --delete: deleted and overwritten files land in a per-run backup dir on the target side (/.twin-backup/). rsync only creates the dir when it actually backs something up. The exclude keeps a backup dir inside the transfer root (Path: ".") from being deleted by the very sync it protects against.



157
158
159
160
161
# File 'lib/twin/sync.rb', line 157

def backup_args(job)
  root = job.remote? ? Twin::Remote.split(job.target).last : job.target
  dir  = File.join(root, ".twin-backup", run_stamp)
  ["--backup", "--backup-dir=#{dir}", "--exclude=.twin-backup/"]
end

.mounted?(path) ⇒ Boolean

True if the path lives on a mounted volume other than the root filesystem. Walks up parents until it finds a mount point (different device than parent) or hits "/" (path is on the root volume, not externally mounted).

Returns:

  • (Boolean)


24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/twin/sync.rb', line 24

def mounted?(path)
  return false unless File.exist?(path)
  current = File.expand_path(path)
  until current == "/"
    parent = File.expand_path("..", current)
    return true if File.stat(current).dev != File.stat(parent).dev
    current = parent
  end
  false
rescue Errno::ENOENT
  false
end

.render_job(cfg, job, dry_run: false) ⇒ Object

Render a template Job: read source, substitute {vars}, write if changed. Returns [success, output, changed].



39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/twin/sync.rb', line 39

def render_job(cfg, job, dry_run: false)
  return [false, "render: remote targets are not supported", false] if job.remote?

  src = job.source_path
  tgt = job.target_path

  return [false, "source not found: #{src}", false] unless File.exist?(src)
  return [false, "render: source must be a file, not a directory: #{src}", false] if File.directory?(src)

  begin
    rendered = Twin::Template.render_file(src, cfg.var_map, context: job.path)
  rescue => e
    return [false, e.message, false]
  end

  if dry_run
    return [true, "(dry-run) would render #{File.basename(src)}#{tgt}", false]
  end

  FileUtils.mkdir_p(File.dirname(tgt))

  current = File.exist?(tgt) ? File.binread(tgt) : nil
  changed = (current != rendered)

  if changed
    File.binwrite(tgt, rendered)
    output = "rendered #{File.basename(src)}#{tgt}"
  else
    output = "#{File.basename(src)}: content unchanged"
  end

  if !job.cmd.empty?
    if changed
      cmd_out, cmd_status = run(["sh", "-c", job.cmd])
      output += "\ncmd: #{job.cmd}\n#{cmd_out}"
      unless cmd_status.success?
        output += "cmd failed (exit #{cmd_status.exitstatus})"
        return [false, output, changed]
      end
    else
      output += "\ncmd skipped (content unchanged)"
    end
  end

  [true, output, changed]
end

.rsync_args(cfg, job, dry_run: false) ⇒ Object

Full rsync argument vector for a job.



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/twin/sync.rb', line 131

def rsync_args(cfg, job, dry_run: false)
  src = job.source_path
  tgt = job.target_path

  args = ["rsync", "-av", "--itemize-changes", "--update"]
  if job.delete
    args << "--delete"
    args.concat(backup_args(job))
  end
  args << "--dry-run" if dry_run
  cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
  job.excludes.each       { |ex| args << "--exclude=#{ex}" }

  if File.directory?(src)
    args << "#{src}/" << "#{tgt}/"
  else
    args << src << tgt
  end
  args
end

.run(args) ⇒ Object



173
174
175
176
177
178
179
# File 'lib/twin/sync.rb', line 173

def run(args)
  require "open3"
  stdout, stderr, status = Open3.capture3(*args)
  [stdout + stderr, status]
rescue Errno::ENOENT
  raise "command not found: #{args.first}"
end

.run_job(cfg, job, dry_run: false) ⇒ Object

Sync one Job. Returns [success, combined_output, transferred]. transferred is true when rsync actually moved bytes (false on no-op or dry_run).



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/twin/sync.rb', line 88

def run_job(cfg, job, dry_run: false)
  return render_job(cfg, job, dry_run: dry_run) if job.render

  src = job.source_path
  tgt = job.target_path

  return [false, "source not found: #{src}", false] unless File.exist?(src)

  if job.remote?
    host, rpath = Twin::Remote.split(tgt)
    unless dry_run || Twin::Remote.mkdir_p(host, File.dirname(rpath))
      return [false, "ssh: could not create #{File.dirname(rpath)} on #{host}", false]
    end
  else
    FileUtils.mkdir_p(File.dirname(tgt))
  end

  output, status = run(rsync_args(cfg, job, dry_run: dry_run))
  return [false, output, false] unless status.success?

  xfr = !dry_run && transferred?(output)

  if job.conflict && !xfr && !dry_run
    output += "\nskipped: target is newer, source not synced"
  end

  if !job.cmd.empty? && !dry_run
    if xfr
      cmd_out, cmd_status = run(["sh", "-c", job.cmd])
      output += "\ncmd: #{job.cmd}\n#{cmd_out}"
      unless cmd_status.success?
        output += "cmd failed (exit #{cmd_status.exitstatus})"
        return [false, output, xfr]
      end
    else
      output += "\ncmd skipped (nothing transferred)"
    end
  end

  [true, output, xfr]
end

.run_program(cfg, program, dry_run: false) ⇒ Object

Sync all jobs in a Program. Returns array of [job, success, output].



169
170
171
# File 'lib/twin/sync.rb', line 169

def run_program(cfg, program, dry_run: false)
  program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
end

.run_stampObject

One timestamp per twin process, so a multi-job run shares a backup dir.



164
165
166
# File 'lib/twin/sync.rb', line 164

def run_stamp
  @run_stamp ||= Time.now.strftime("%Y-%m-%d_%H%M%S")
end

.transferred?(output) ⇒ Boolean

True when rsync reported at least one changed item.

Returns:

  • (Boolean)


17
18
19
# File 'lib/twin/sync.rb', line 17

def transferred?(output)
  output.lines.any? { |l| ITEMIZE_CHANGE.match?(l) }
end