Module: Twin::Scanner

Defined in:
lib/twin/scanner.rb

Class Method Summary collapse

Class Method Details

.build_job(r, vars = {}) ⇒ Object



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# File 'lib/twin/scanner.rb', line 178

def build_job(r, vars = {})
  path              = r["Path"].to_s
  source            = r["Source"].to_s
  target            = r["Target"].to_s
  target_path_field = r["Target-Path"].then { |v| v.to_s.empty? ? nil : v.to_s }
  return nil if path.empty? || source.empty? || target.empty?

  render   = r["Render"] == true
  excludes = split_list(r["Exclude"])
  # Own: paths inside the sync scope that the TARGET owns — machine-specific
  # config the source must never clobber. Same rsync effect as Exclude, but
  # kept apart so `status` can name the intent instead of hiding it among
  # build artefacts and .DS_Store.
  owned    = split_list(r["Own"])
  remote   = Twin::Remote.remote?(target)

  if render && remote
    raise "#{r["Program"]}: Render is not supported for remote targets (#{target})"
  end

  src_full = File.join(source, path)
  tgt_full = File.join(target, target_path_field || path)
  src_exists, src_mtime = stat(src_full)
  # Remote targets are stat'ed in one batched ssh call after all jobs are
  # built (fill_remote_stats) — until then they read as missing.
  tgt_exists, tgt_mtime = remote ? [false, nil] : stat(tgt_full)

  # Render jobs: status is content-based (mtime is meaningless for a rendered
  # target). conflict stays false so the mtime conflict-warning skips them.
  render_outdated = render ? render_outdated?(src_full, tgt_full, vars, path) : nil

  # rsync mirrors directories, so a directory source means a directory
  # target — also for remote jobs, whose far side can't be inspected here.
  directory = src_exists && File.directory?(src_full)

  # A file job whose mtimes drifted apart may still hold the same bytes
  # (a `cat >` copy before the first twin run). Check before judging;
  # a directory's own mtime is judged not at all (see Job#status).
  content_equal = nil
  if !render && !remote && !directory && src_exists && tgt_exists &&
     src_mtime && tgt_mtime && (tgt_mtime - src_mtime).abs >= 60
    content_equal = Twin::Conflict.same_content?(src_full, tgt_full)
  end

  # Same 60s tolerance as Job#status, so mtime jitter never flags a conflict.
  conflict = !render && !directory && !content_equal &&
             src_exists && tgt_exists && tgt_mtime && src_mtime &&
             tgt_mtime - src_mtime >= 60

  Job.new(
    program:          r["Program"].to_s,
    path:             path,
    description:      r["Description"].to_s,
    active:           (r["Active"] || 0).to_i,
    excludes:         excludes,
    owned:            owned,
    label:            r["Label"].to_s,
    source:           source,
    target:           target,
    cmd:              r["Cmd"].to_s,
    delete:           r["Delete"] == true,
    render:           render,
    render_outdated:  render_outdated,
    target_path_field: target_path_field,
    sync_file:        r["_note_file"].to_s,
    source_exists:    src_exists,
    target_exists:    tgt_exists,
    source_mtime:     src_mtime,
    target_mtime:     tgt_mtime,
    conflict:         !!conflict,
    target_unreachable: false,
    directory:        directory,
    content_equal:    content_equal,
  )
end

.fill_remote_stats(jobs) ⇒ Object

Remote targets can't be stat'ed locally — batch them into one ssh round-trip per host. A failed ssh marks the jobs unreachable instead of aborting the scan (local jobs stay usable).



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/twin/scanner.rb', line 106

def fill_remote_stats(jobs)
  jobs.select { |j| j.remote? && j.active == 1 }
      .group_by { |j| Twin::Remote.split(j.target).first }
      .each do |host, host_jobs|
    stats = Twin::Remote.stat_paths(host, host_jobs.map { |j| Twin::Remote.split(j.target_path).last })
    host_jobs.each do |j|
      rpath = Twin::Remote.split(j.target_path).last
      if stats.nil?
        j.target_unreachable = true
        next
      end
      mtime = stats[rpath]
      j.target_exists = !mtime.nil?
      j.target_mtime  = mtime
      j.conflict      = !j.directory && j.source_exists && mtime && j.source_mtime &&
                        mtime - j.source_mtime >= 60
    end
    verify_remote_file_content(host, host_jobs)
  end
end

.group(jobs) ⇒ Object



173
174
175
176
# File 'lib/twin/scanner.rb', line 173

def group(jobs)
  jobs.group_by { |j| [j.program, j.sync_file] }
      .map { |(name, _file), js| Program.new(name: name, jobs: js) }
end

.load_jobs(cfg, scan_path: nil) ⇒ Object



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/twin/scanner.rb', line 80

def load_jobs(cfg, scan_path: nil)
  raise "grubber not found in PATH" unless system("command -v grubber > /dev/null 2>&1")

  dir = scan_path || cfg.sync_dir
  stdout, stderr, status = Open3.capture3(
    "grubber", "extract", dir, "-b", "--format", "json"
  )
  raise "grubber: #{stderr.force_encoding('UTF-8').strip}" unless status.success?

  begin
    records = JSON.parse(stdout.force_encoding("UTF-8"))
  rescue JSON::ParserError => e
    raise "grubber returned invalid JSON: #{e.message}"
  end
  vars = cfg.var_map
  jobs = records.filter_map do |r|
    context = "#{r["Program"]} in #{File.basename(r["_note_file"].to_s)}"
    build_job(Twin::Template.substitute_record(r, vars, context: context), vars)
  end
  fill_remote_stats(jobs)
  jobs
end

.load_programs(cfg, file: nil, label: nil, show_all: false) ⇒ Object



148
149
150
151
152
153
154
155
# File 'lib/twin/scanner.rb', line 148

def load_programs(cfg, file: nil, label: nil, show_all: false)
  scan_path, name_filter = resolve_file_arg(cfg, file)
  jobs = load_jobs(cfg, scan_path: scan_path)
  jobs = jobs.select { |j| j.sync_file.include?(name_filter) } if name_filter
  jobs = jobs.select { |j| j.label == label }                   if label && !label.empty?
  jobs = jobs.select { |j| j.active == 1 }                     unless show_all
  group(jobs)
end

.render_outdated?(src_full, tgt_full, vars, context) ⇒ Boolean

For a render job: is the target out of date with the rendered template? nil when source is missing/a directory (status falls through to those). True when target is absent or content differs, or the template can't be rendered (unresolved token) — i.e. needs attention.

Returns:

  • (Boolean)


270
271
272
273
274
275
276
# File 'lib/twin/scanner.rb', line 270

def render_outdated?(src_full, tgt_full, vars, context)
  return nil unless File.file?(src_full)
  rendered = Twin::Template.render_file(src_full, vars, context: context)
  !File.exist?(tgt_full) || File.binread(tgt_full) != rendered
rescue
  true
end

.resolve_file_arg(cfg, file) ⇒ Object

Returns [scan_path, name_filter] for a given file argument.

  • nil / empty → [nil, nil] scan sync_dir, no filter
  • path to a dir → [dir, nil] scan that dir, no filter
  • path to a file → [dirname, basename] scan parent dir, filter by filename
  • bare name (no /) → [nil, name] scan sync_dir, filter by name


162
163
164
165
166
167
168
169
170
171
# File 'lib/twin/scanner.rb', line 162

def resolve_file_arg(cfg, file)
  return [nil, nil] if file.nil? || file.empty?
  if file.include?("/") || file == "." || file == ".."
    expanded = File.expand_path(file)
    return [expanded, nil]                             if File.directory?(expanded)
    return [File.dirname(expanded), File.basename(expanded)] if File.file?(expanded)
    raise "not found: #{file}"
  end
  [nil, file]
end

.split_list(value) ⇒ Object

Comma-separated block field → array of trimmed, non-empty entries.



262
263
264
# File 'lib/twin/scanner.rb', line 262

def split_list(value)
  (value || "").split(",").map(&:strip).reject(&:empty?)
end

.stat(path) ⇒ Object



254
255
256
257
258
259
# File 'lib/twin/scanner.rb', line 254

def stat(path)
  st = File.stat(path)
  [true, st.mtime]
rescue Errno::ENOENT, Errno::EACCES
  [false, nil]
end

.verify_remote_file_content(host, host_jobs) ⇒ Object

Remote counterpart of the local content check in build_job: file jobs whose mtimes drifted get one batched md5 round per host. Identical content clears the conflict — the timestamps merely disagree.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/twin/scanner.rb', line 130

def verify_remote_file_content(host, host_jobs)
  candidates = host_jobs.select do |j|
    !j.directory && !j.render && j.source_exists && j.target_exists &&
      j.source_mtime && j.target_mtime && (j.target_mtime - j.source_mtime).abs >= 60
  end
  return if candidates.empty?

  sums = Twin::Remote.md5_paths(host, candidates.map { |j| Twin::Remote.split(j.target_path).last })
  return if sums.nil?

  candidates.each do |j|
    remote_sum = sums[Twin::Remote.split(j.target_path).last]
    next if remote_sum.nil?
    j.content_equal = remote_sum == Twin::Conflict.local_md5(j.source_path)
    j.conflict      = false if j.content_equal
  end
end