Module: Twin::Scanner
- Defined in:
- lib/twin/scanner.rb
Class Method Summary collapse
- .build_job(r, vars = {}) ⇒ Object
-
.fill_remote_stats(jobs) ⇒ Object
Remote targets can't be stat'ed locally — batch them into one ssh round-trip per host.
- .group(jobs) ⇒ Object
- .load_jobs(cfg, scan_path: nil) ⇒ Object
- .load_programs(cfg, file: nil, label: nil, show_all: false) ⇒ Object
-
.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).
-
.resolve_file_arg(cfg, file) ⇒ Object
Returns [scan_path, name_filter] for a given file argument.
-
.split_list(value) ⇒ Object
Comma-separated block field → array of trimmed, non-empty entries.
- .stat(path) ⇒ Object
-
.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.
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.}" 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.
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 == ".." = File.(file) return [, nil] if File.directory?() return [File.dirname(), File.basename()] if File.file?() 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 |