mark-twin 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +135 -15
- data/README.md +171 -7
- data/bin/twin +5 -0
- data/lib/twin/add.rb +177 -0
- data/lib/twin/cli.rb +186 -19
- data/lib/twin/config.rb +26 -1
- data/lib/twin/journal.rb +49 -0
- data/lib/twin/picker.rb +2 -0
- data/lib/twin/remote.rb +72 -0
- data/lib/twin/scanner.rb +94 -26
- data/lib/twin/sync.rb +126 -13
- data/lib/twin/template.rb +36 -0
- data/lib/twin/version.rb +1 -1
- data/lib/twin.rb +4 -0
- metadata +5 -1
data/lib/twin/cli.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
require "optparse"
|
|
2
2
|
require "json"
|
|
3
3
|
require "set"
|
|
4
|
+
require "time" # Time#iso8601 for --json output
|
|
4
5
|
|
|
5
6
|
require_relative "config"
|
|
6
7
|
require_relative "scanner"
|
|
7
8
|
require_relative "sync"
|
|
9
|
+
require_relative "journal"
|
|
10
|
+
require_relative "add"
|
|
8
11
|
require_relative "picker"
|
|
9
12
|
|
|
10
13
|
module Twin
|
|
@@ -21,6 +24,10 @@ module Twin
|
|
|
21
24
|
twin list [--all] [--label X] [--file X] [--json]
|
|
22
25
|
twin status [--all] [--label X] [--file X] [--json]
|
|
23
26
|
twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
|
|
27
|
+
[--quiet] [--skip-unavailable]
|
|
28
|
+
twin add <path> scaffold a new sync entry for a local path
|
|
29
|
+
twin log [-n N] [--json] recent journal entries (default 20)
|
|
30
|
+
twin doctor check tools, renderers, and sync targets
|
|
24
31
|
twin --help show this message
|
|
25
32
|
|
|
26
33
|
FILE ARGUMENT:
|
|
@@ -44,6 +51,9 @@ module Twin
|
|
|
44
51
|
when "list" then cmd_list(cfg, argv.drop(1))
|
|
45
52
|
when "status" then cmd_status(cfg, argv.drop(1))
|
|
46
53
|
when "sync" then cmd_sync(cfg, argv.drop(1))
|
|
54
|
+
when "add" then cmd_add(cfg, argv.drop(1))
|
|
55
|
+
when "log" then cmd_log(argv.drop(1))
|
|
56
|
+
when "doctor" then cmd_doctor(cfg)
|
|
47
57
|
when "-h", "--help", "help"
|
|
48
58
|
puts USAGE
|
|
49
59
|
when /\A-/
|
|
@@ -134,6 +144,7 @@ module Twin
|
|
|
134
144
|
p.jobs.each do |j|
|
|
135
145
|
src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
136
146
|
tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
|
|
147
|
+
tgt = "(unreachable)" if j.target_unreachable
|
|
137
148
|
conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : ""
|
|
138
149
|
puts " #{j.path}#{conflict}"
|
|
139
150
|
puts " src #{src}"
|
|
@@ -153,31 +164,40 @@ module Twin
|
|
|
153
164
|
end
|
|
154
165
|
|
|
155
166
|
if programs.empty?
|
|
156
|
-
puts "no matching programs"
|
|
167
|
+
puts "no matching programs" unless opts[:quiet]
|
|
157
168
|
return
|
|
158
169
|
end
|
|
159
170
|
|
|
160
|
-
programs.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
results = programs.map do |p|
|
|
172
|
+
sync_jobs(cfg, p, p.active_jobs,
|
|
173
|
+
dry_run: opts[:dry_run], quiet: opts[:quiet],
|
|
174
|
+
skip_unavailable: opts[:skip_unavailable])
|
|
175
|
+
end
|
|
176
|
+
exit 1 unless results.all?
|
|
165
177
|
end
|
|
166
178
|
|
|
167
|
-
|
|
179
|
+
# Sync the given jobs. Returns true when every attempted job succeeded.
|
|
180
|
+
# quiet: print only conflicts, errors, and jobs that changed something
|
|
181
|
+
# skip_unavailable: skip jobs whose target is unmounted/unreachable instead of aborting
|
|
182
|
+
def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false)
|
|
168
183
|
jobs = jobs.select { |j| j.active == 1 }
|
|
169
|
-
return if jobs.empty?
|
|
170
|
-
|
|
171
|
-
# one
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
184
|
+
return true if jobs.empty?
|
|
185
|
+
|
|
186
|
+
# one availability check per unique target root:
|
|
187
|
+
# local targets must be mounted volumes, remote ones reachable via ssh
|
|
188
|
+
availability = {}
|
|
189
|
+
jobs.each { |j| availability[j.target] ||= target_availability(j) }
|
|
190
|
+
jobs, unavailable = jobs.partition { |j| availability[j.target] == :ok }
|
|
191
|
+
|
|
192
|
+
unavailable.map { |j| availability[j.target] }.uniq.each do |reason|
|
|
193
|
+
if skip_unavailable
|
|
194
|
+
puts "skipped: #{reason}" unless quiet
|
|
195
|
+
else
|
|
196
|
+
warn "abort: #{reason}"
|
|
177
197
|
exit 1
|
|
178
198
|
end
|
|
179
|
-
checked << j.target
|
|
180
199
|
end
|
|
200
|
+
return true if jobs.empty?
|
|
181
201
|
|
|
182
202
|
conflicts = jobs.select(&:conflict)
|
|
183
203
|
unless conflicts.empty?
|
|
@@ -186,13 +206,157 @@ module Twin
|
|
|
186
206
|
warn "continuing sync (--update skips newer files on target)."
|
|
187
207
|
end
|
|
188
208
|
|
|
189
|
-
|
|
209
|
+
header_printed = false
|
|
210
|
+
all_ok = true
|
|
190
211
|
jobs.each do |job|
|
|
191
|
-
success, output = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
|
|
212
|
+
success, output, transferred = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
|
|
213
|
+
Twin::Journal.record(job, success: success, transferred: transferred, output: output) unless dry_run
|
|
214
|
+
all_ok &&= success
|
|
215
|
+
next if quiet && success && !transferred
|
|
216
|
+
|
|
217
|
+
unless header_printed
|
|
218
|
+
puts "→ #{program.name}"
|
|
219
|
+
header_printed = true
|
|
220
|
+
end
|
|
192
221
|
puts " • #{job.path}"
|
|
193
222
|
puts output.gsub(/^/, " ") if output && !output.strip.empty?
|
|
194
223
|
warn " error syncing #{job.path}" unless success
|
|
195
224
|
end
|
|
225
|
+
all_ok
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# :ok, or a human-readable reason the target can't be synced right now.
|
|
229
|
+
def target_availability(job)
|
|
230
|
+
if job.remote?
|
|
231
|
+
host, = Twin::Remote.split(job.target)
|
|
232
|
+
return :ok if Twin::Remote.reachable?(host)
|
|
233
|
+
"#{host} is not reachable via ssh"
|
|
234
|
+
else
|
|
235
|
+
return :ok if Twin::Sync.mounted?(job.target)
|
|
236
|
+
"#{job.target} is not a mounted volume"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ── add ────────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def cmd_add(cfg, args)
|
|
243
|
+
result = Twin::Add.run(cfg, args)
|
|
244
|
+
return unless result && result[:dry_run]
|
|
245
|
+
cmd_sync(cfg, ["-p", result[:program],
|
|
246
|
+
"--file=#{File.basename(result[:file])}", "--dry-run"])
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# ── log ────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def cmd_log(args)
|
|
252
|
+
n = 20
|
|
253
|
+
json = false
|
|
254
|
+
OptionParser.new do |o|
|
|
255
|
+
o.on("-n N", Integer) { |v| n = v }
|
|
256
|
+
o.on("--json") { json = true }
|
|
257
|
+
end.parse!(args)
|
|
258
|
+
|
|
259
|
+
entries = Twin::Journal.tail(n)
|
|
260
|
+
if json
|
|
261
|
+
puts JSON.pretty_generate(entries)
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
if entries.empty?
|
|
265
|
+
puts "journal is empty (#{Twin::Journal.log_path})"
|
|
266
|
+
return
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
tty = $stdout.tty?
|
|
270
|
+
entries.each do |e|
|
|
271
|
+
ts = Time.parse(e["ts"]).strftime("%Y-%m-%d %H:%M:%S")
|
|
272
|
+
mark = e["ok"] ? "✓" : "✗"
|
|
273
|
+
mark = Picker.colorize(e["ok"] ? :in_sync : :both_missing, mark) if tty
|
|
274
|
+
note = e["ok"] ? (e["changed"] ? "changed" : "no-op") : "error: #{e["error"]}"
|
|
275
|
+
puts "#{ts} #{mark} #{e["program"]} #{e["path"]} (#{note})"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# ── doctor ─────────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
def cmd_doctor(cfg)
|
|
282
|
+
ok = true
|
|
283
|
+
|
|
284
|
+
puts "Tools"
|
|
285
|
+
%w[grubber rsync fzf].each do |bin|
|
|
286
|
+
if tool_available?(bin)
|
|
287
|
+
puts " ✓ #{bin}"
|
|
288
|
+
else
|
|
289
|
+
puts " ✗ #{bin} (required — not found in PATH)"
|
|
290
|
+
ok = false
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
puts "\nRenderers (preview)"
|
|
295
|
+
found_renderer = false
|
|
296
|
+
%w[apex glow bat].each do |bin|
|
|
297
|
+
if tool_available?(bin)
|
|
298
|
+
puts " ✓ #{bin}"
|
|
299
|
+
found_renderer = true
|
|
300
|
+
else
|
|
301
|
+
puts " – #{bin} (not installed)"
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
puts " ⚠ no renderer found — file preview will fall back to cat" unless found_renderer
|
|
305
|
+
|
|
306
|
+
puts "\nTemplating"
|
|
307
|
+
if cfg.hosts.empty?
|
|
308
|
+
puts " – no hosts configured"
|
|
309
|
+
else
|
|
310
|
+
%i[host target].each do |attr|
|
|
311
|
+
name = cfg.send(attr)
|
|
312
|
+
if name.empty?
|
|
313
|
+
puts " ✗ #{attr} not set in config"
|
|
314
|
+
ok = false
|
|
315
|
+
elsif cfg.hosts.key?(name)
|
|
316
|
+
puts " ✓ #{attr}: #{name}"
|
|
317
|
+
else
|
|
318
|
+
puts " ✗ #{attr} #{name.inspect} not found in hosts"
|
|
319
|
+
ok = false
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
puts "\nTargets"
|
|
325
|
+
begin
|
|
326
|
+
programs = Scanner.load_programs(cfg, show_all: true)
|
|
327
|
+
puts " ✓ all template tokens resolved" unless cfg.hosts.empty?
|
|
328
|
+
targets = programs.flat_map(&:jobs).map(&:target).uniq.sort
|
|
329
|
+
if targets.empty?
|
|
330
|
+
puts " (no programs loaded)"
|
|
331
|
+
else
|
|
332
|
+
targets.each do |tgt|
|
|
333
|
+
if Twin::Remote.remote?(tgt)
|
|
334
|
+
host, = Twin::Remote.split(tgt)
|
|
335
|
+
if Twin::Remote.reachable?(host)
|
|
336
|
+
puts " ✓ #{tgt} (ssh)"
|
|
337
|
+
else
|
|
338
|
+
puts " ✗ #{tgt} (ssh: #{host} not reachable)"
|
|
339
|
+
ok = false
|
|
340
|
+
end
|
|
341
|
+
elsif Twin::Sync.mounted?(tgt)
|
|
342
|
+
puts " ✓ #{tgt}"
|
|
343
|
+
else
|
|
344
|
+
puts " ✗ #{tgt} (not mounted)"
|
|
345
|
+
ok = false
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
rescue => e
|
|
350
|
+
puts " ✗ #{e.message}"
|
|
351
|
+
ok = false
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
puts ok ? "\nAll checks passed." : "\nSome checks failed."
|
|
355
|
+
exit 1 unless ok
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def tool_available?(name)
|
|
359
|
+
system("command -v #{name} > /dev/null 2>&1")
|
|
196
360
|
end
|
|
197
361
|
|
|
198
362
|
# ── option parsing ─────────────────────────────────────────────────────────
|
|
@@ -209,13 +373,16 @@ module Twin
|
|
|
209
373
|
end
|
|
210
374
|
|
|
211
375
|
def parse_sync_opts(args)
|
|
212
|
-
opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false
|
|
376
|
+
opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false,
|
|
377
|
+
quiet: false, skip_unavailable: false }
|
|
213
378
|
OptionParser.new do |o|
|
|
214
379
|
o.on("--all") { opts[:show_all] = true }
|
|
215
380
|
o.on("--label=L") { |v| opts[:label] = v }
|
|
216
381
|
o.on("--file=F") { |v| opts[:file] = v }
|
|
217
382
|
o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
|
|
218
383
|
o.on("--dry-run") { opts[:dry_run] = true }
|
|
384
|
+
o.on("-q", "--quiet") { opts[:quiet] = true }
|
|
385
|
+
o.on("--skip-unavailable") { opts[:skip_unavailable] = true }
|
|
219
386
|
end.parse!(args)
|
|
220
387
|
opts
|
|
221
388
|
end
|
data/lib/twin/config.rb
CHANGED
|
@@ -5,7 +5,8 @@ module Twin
|
|
|
5
5
|
class Config
|
|
6
6
|
attr_accessor :sync_dir, :global_excludes,
|
|
7
7
|
:apex_theme, :apex_width,
|
|
8
|
-
:apex_code_highlight, :apex_code_highlight_theme
|
|
8
|
+
:apex_code_highlight, :apex_code_highlight_theme,
|
|
9
|
+
:hosts, :host, :target
|
|
9
10
|
|
|
10
11
|
DEFAULTS = {
|
|
11
12
|
"global_excludes" => [".DS_Store"],
|
|
@@ -13,6 +14,9 @@ module Twin
|
|
|
13
14
|
"apex_width" => nil,
|
|
14
15
|
"apex_code_highlight" => nil,
|
|
15
16
|
"apex_code_highlight_theme" => nil,
|
|
17
|
+
"hosts" => {},
|
|
18
|
+
"host" => "",
|
|
19
|
+
"target" => "",
|
|
16
20
|
}.freeze
|
|
17
21
|
|
|
18
22
|
def initialize(data = {})
|
|
@@ -23,6 +27,27 @@ module Twin
|
|
|
23
27
|
@apex_width = merged["apex_width"]
|
|
24
28
|
@apex_code_highlight = merged["apex_code_highlight"]
|
|
25
29
|
@apex_code_highlight_theme = merged["apex_code_highlight_theme"]
|
|
30
|
+
@hosts = merged["hosts"] || {}
|
|
31
|
+
@host = ENV["TWIN_HOST"] || merged["host"].to_s
|
|
32
|
+
@target = merged["target"].to_s
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build the flat substitution map {src.home => ..., dst.home => ..., dst.mount => ...}.
|
|
36
|
+
# Returns empty hash when no hosts are configured (substitution becomes a no-op).
|
|
37
|
+
def var_map
|
|
38
|
+
return {} if hosts.empty?
|
|
39
|
+
raise "host not set in config" if host.empty?
|
|
40
|
+
raise "target not set in config" if target.empty?
|
|
41
|
+
|
|
42
|
+
src_host = hosts[host]
|
|
43
|
+
dst_host = hosts[target]
|
|
44
|
+
raise "unknown host #{host.inspect} (not in hosts)" unless src_host
|
|
45
|
+
raise "unknown target #{target.inspect} (not in hosts)" unless dst_host
|
|
46
|
+
|
|
47
|
+
map = {}
|
|
48
|
+
src_host.each { |k, v| map["src.#{k}"] = v.to_s }
|
|
49
|
+
dst_host.each { |k, v| map["dst.#{k}"] = v.to_s }
|
|
50
|
+
map
|
|
26
51
|
end
|
|
27
52
|
|
|
28
53
|
def self.load
|
data/lib/twin/journal.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Twin
|
|
6
|
+
# Append-only sync journal: one JSON line per synced job in
|
|
7
|
+
# ~/.local/state/twin/log.jsonl. Answers "did yesterday's sync actually
|
|
8
|
+
# run, and what did it do?" — and stays machine-readable (jq/grubber).
|
|
9
|
+
# Journal failures never break a sync; they degrade to a warning.
|
|
10
|
+
module Journal
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def state_dir
|
|
14
|
+
ENV["TWIN_STATE_DIR"] || File.join(Dir.home, ".local", "state", "twin")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def log_path = File.join(state_dir, "log.jsonl")
|
|
18
|
+
|
|
19
|
+
# Record one job result. Dry-runs are not journaled.
|
|
20
|
+
def record(job, success:, transferred:, output: nil)
|
|
21
|
+
entry = {
|
|
22
|
+
ts: Time.now.iso8601,
|
|
23
|
+
program: job.program,
|
|
24
|
+
path: job.path,
|
|
25
|
+
target: job.target,
|
|
26
|
+
ok: success,
|
|
27
|
+
changed: transferred,
|
|
28
|
+
}
|
|
29
|
+
unless success
|
|
30
|
+
entry[:error] = output.to_s.lines.map(&:strip).reject(&:empty?).last.to_s[0, 200]
|
|
31
|
+
end
|
|
32
|
+
FileUtils.mkdir_p(state_dir)
|
|
33
|
+
File.open(log_path, "a") { |f| f.puts(JSON.generate(entry)) }
|
|
34
|
+
rescue SystemCallError => e
|
|
35
|
+
warn "journal: #{e.message}" unless @warned
|
|
36
|
+
@warned = true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Last n entries, oldest first. Unparseable lines are skipped.
|
|
40
|
+
def tail(n)
|
|
41
|
+
return [] unless File.exist?(log_path)
|
|
42
|
+
File.readlines(log_path).last(n).filter_map do |line|
|
|
43
|
+
JSON.parse(line)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/twin/picker.rb
CHANGED
|
@@ -17,6 +17,7 @@ module Twin
|
|
|
17
17
|
missing_target: "!",
|
|
18
18
|
missing_source: "!",
|
|
19
19
|
both_missing: "✗",
|
|
20
|
+
unreachable: "?",
|
|
20
21
|
disabled: "·",
|
|
21
22
|
}.freeze
|
|
22
23
|
|
|
@@ -27,6 +28,7 @@ module Twin
|
|
|
27
28
|
missing_target: "\e[31m", # red
|
|
28
29
|
missing_source: "\e[31m", # red
|
|
29
30
|
both_missing: "\e[31m", # red
|
|
31
|
+
unreachable: "\e[31m", # red
|
|
30
32
|
disabled: "\e[2m", # dim
|
|
31
33
|
}.freeze
|
|
32
34
|
|
data/lib/twin/remote.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Twin
|
|
4
|
+
# Remote (ssh) targets, written exactly as rsync understands them:
|
|
5
|
+
# "user@host:/path" or "host:/path". A target counts as remote when a colon
|
|
6
|
+
# appears before the first slash. Sources stay local — twin pushes.
|
|
7
|
+
module Remote
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"].freeze
|
|
11
|
+
|
|
12
|
+
def remote?(target)
|
|
13
|
+
%r{\A[^/]+:}.match?(target.to_s)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# "user@host:/path" → ["user@host", "/path"]
|
|
17
|
+
def split(target)
|
|
18
|
+
host, path = target.to_s.split(":", 2)
|
|
19
|
+
[host, path.to_s]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Non-interactive reachability probe (BatchMode: never asks for a password).
|
|
23
|
+
def reachable?(host)
|
|
24
|
+
system("ssh", *SSH_OPTS, host, "true", out: File::NULL, err: File::NULL)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Stat many paths in one ssh round-trip. Paths go over stdin (one per
|
|
28
|
+
# line), the remote loop answers "path<TAB>epoch" or "path<TAB>-" for
|
|
29
|
+
# missing ones. Tries BSD stat first, then GNU — covers macOS and Linux.
|
|
30
|
+
# Returns {path => Time or nil-if-missing}, or nil when ssh itself failed.
|
|
31
|
+
STAT_SCRIPT = <<~SH.freeze
|
|
32
|
+
while IFS= read -r p; do
|
|
33
|
+
if [ -e "$p" ]; then
|
|
34
|
+
printf '%s\t%s\n' "$p" "$(stat -f %m -- "$p" 2>/dev/null || stat -c %Y -- "$p")"
|
|
35
|
+
else
|
|
36
|
+
printf '%s\t-\n' "$p"
|
|
37
|
+
fi
|
|
38
|
+
done
|
|
39
|
+
SH
|
|
40
|
+
|
|
41
|
+
def stat_paths(host, paths)
|
|
42
|
+
return {} if paths.empty?
|
|
43
|
+
out, _err, status = Open3.capture3(
|
|
44
|
+
"ssh", *SSH_OPTS, host, STAT_SCRIPT,
|
|
45
|
+
stdin_data: paths.join("\n") + "\n"
|
|
46
|
+
)
|
|
47
|
+
return nil unless status.success?
|
|
48
|
+
|
|
49
|
+
result = {}
|
|
50
|
+
out.each_line do |line|
|
|
51
|
+
path, mtime = line.chomp.split("\t", 2)
|
|
52
|
+
next unless path && mtime
|
|
53
|
+
result[path] = mtime == "-" ? nil : Time.at(mtime.to_i)
|
|
54
|
+
end
|
|
55
|
+
result
|
|
56
|
+
rescue Errno::ENOENT
|
|
57
|
+
nil # ssh not installed
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Create a directory on the remote side (mkdir -p equivalent).
|
|
61
|
+
def mkdir_p(host, dir)
|
|
62
|
+
_out, _err, status = Open3.capture3("ssh", *SSH_OPTS, host, "mkdir", "-p", shellesc(dir))
|
|
63
|
+
status.success?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Escape one argument for the remote shell (ssh joins args with spaces and
|
|
67
|
+
# hands the string to a shell — local exec-style arrays don't protect it).
|
|
68
|
+
def shellesc(s)
|
|
69
|
+
"'" + s.gsub("'", "'\\\\''") + "'"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/twin/scanner.rb
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "open3"
|
|
3
3
|
|
|
4
|
+
require_relative "remote"
|
|
5
|
+
|
|
4
6
|
module Twin
|
|
5
7
|
# One YAML block from a sync-file, enriched with live filesystem state.
|
|
6
8
|
Job = Struct.new(
|
|
7
9
|
:program, :path, :description, :active, :excludes, :label,
|
|
8
|
-
:source, :target, :cmd, :sync_file,
|
|
10
|
+
:source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
|
|
9
11
|
:source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
|
|
12
|
+
:target_unreachable,
|
|
10
13
|
keyword_init: true,
|
|
11
14
|
) do
|
|
12
15
|
def source_path = File.join(source, path)
|
|
13
|
-
def target_path = File.join(target, path)
|
|
16
|
+
def target_path = File.join(target, target_path_field || path)
|
|
17
|
+
def remote? = Twin::Remote.remote?(target)
|
|
14
18
|
|
|
15
19
|
def status
|
|
16
20
|
return :disabled if active != 1
|
|
21
|
+
return :unreachable if target_unreachable
|
|
17
22
|
return :both_missing if !source_exists && !target_exists
|
|
18
23
|
return :missing_source unless source_exists
|
|
19
24
|
return :missing_target unless target_exists
|
|
25
|
+
# Render jobs compare by content, not mtime — a rendered target's mtime
|
|
26
|
+
# bears no relation to the template's.
|
|
27
|
+
return render_outdated ? :source_newer : :in_sync if render
|
|
20
28
|
return :target_newer if conflict
|
|
21
29
|
return :in_sync if source_mtime.nil? || target_mtime.nil?
|
|
22
30
|
delta = source_mtime - target_mtime
|
|
@@ -38,7 +46,7 @@ module Twin
|
|
|
38
46
|
# Aggregate status across jobs — worst first.
|
|
39
47
|
def status
|
|
40
48
|
states = jobs.map(&:status)
|
|
41
|
-
%i[both_missing missing_source missing_target target_newer source_newer disabled in_sync]
|
|
49
|
+
%i[unreachable both_missing missing_source missing_target target_newer source_newer disabled in_sync]
|
|
42
50
|
.find { |s| states.include?(s) } || :in_sync
|
|
43
51
|
end
|
|
44
52
|
|
|
@@ -65,7 +73,36 @@ module Twin
|
|
|
65
73
|
rescue JSON::ParserError => e
|
|
66
74
|
raise "grubber returned invalid JSON: #{e.message}"
|
|
67
75
|
end
|
|
68
|
-
|
|
76
|
+
vars = cfg.var_map
|
|
77
|
+
jobs = records.filter_map do |r|
|
|
78
|
+
context = "#{r["Program"]} in #{File.basename(r["_note_file"].to_s)}"
|
|
79
|
+
build_job(Twin::Template.substitute_record(r, vars, context: context), vars)
|
|
80
|
+
end
|
|
81
|
+
fill_remote_stats(jobs)
|
|
82
|
+
jobs
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Remote targets can't be stat'ed locally — batch them into one ssh
|
|
86
|
+
# round-trip per host. A failed ssh marks the jobs unreachable instead
|
|
87
|
+
# of aborting the scan (local jobs stay usable).
|
|
88
|
+
def fill_remote_stats(jobs)
|
|
89
|
+
jobs.select { |j| j.remote? && j.active == 1 }
|
|
90
|
+
.group_by { |j| Twin::Remote.split(j.target).first }
|
|
91
|
+
.each do |host, host_jobs|
|
|
92
|
+
stats = Twin::Remote.stat_paths(host, host_jobs.map { |j| Twin::Remote.split(j.target_path).last })
|
|
93
|
+
host_jobs.each do |j|
|
|
94
|
+
rpath = Twin::Remote.split(j.target_path).last
|
|
95
|
+
if stats.nil?
|
|
96
|
+
j.target_unreachable = true
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
mtime = stats[rpath]
|
|
100
|
+
j.target_exists = !mtime.nil?
|
|
101
|
+
j.target_mtime = mtime
|
|
102
|
+
j.conflict = j.source_exists && mtime && j.source_mtime &&
|
|
103
|
+
mtime - j.source_mtime >= 60
|
|
104
|
+
end
|
|
105
|
+
end
|
|
69
106
|
end
|
|
70
107
|
|
|
71
108
|
def load_programs(cfg, file: nil, label: nil, show_all: false)
|
|
@@ -98,38 +135,57 @@ module Twin
|
|
|
98
135
|
.map { |(name, _file), js| Program.new(name: name, jobs: js) }
|
|
99
136
|
end
|
|
100
137
|
|
|
101
|
-
def build_job(r)
|
|
102
|
-
path
|
|
103
|
-
source
|
|
104
|
-
target
|
|
138
|
+
def build_job(r, vars = {})
|
|
139
|
+
path = r["Path"].to_s
|
|
140
|
+
source = r["Source"].to_s
|
|
141
|
+
target = r["Target"].to_s
|
|
142
|
+
target_path_field = r["Target-Path"].then { |v| v.to_s.empty? ? nil : v.to_s }
|
|
105
143
|
return nil if path.empty? || source.empty? || target.empty?
|
|
106
144
|
|
|
145
|
+
render = r["Render"] == true
|
|
107
146
|
excludes = (r["Exclude"] || "").split(",").map(&:strip).reject(&:empty?)
|
|
147
|
+
remote = Twin::Remote.remote?(target)
|
|
148
|
+
|
|
149
|
+
if render && remote
|
|
150
|
+
raise "#{r["Program"]}: Render is not supported for remote targets (#{target})"
|
|
151
|
+
end
|
|
108
152
|
|
|
109
153
|
src_full = File.join(source, path)
|
|
110
|
-
tgt_full = File.join(target, path)
|
|
154
|
+
tgt_full = File.join(target, target_path_field || path)
|
|
111
155
|
src_exists, src_mtime = stat(src_full)
|
|
112
|
-
|
|
156
|
+
# Remote targets are stat'ed in one batched ssh call after all jobs are
|
|
157
|
+
# built (fill_remote_stats) — until then they read as missing.
|
|
158
|
+
tgt_exists, tgt_mtime = remote ? [false, nil] : stat(tgt_full)
|
|
159
|
+
|
|
160
|
+
# Render jobs: status is content-based (mtime is meaningless for a rendered
|
|
161
|
+
# target). conflict stays false so the mtime conflict-warning skips them.
|
|
162
|
+
render_outdated = render ? render_outdated?(src_full, tgt_full, vars, path) : nil
|
|
163
|
+
|
|
113
164
|
# Same 60s tolerance as Job#status, so mtime jitter never flags a conflict.
|
|
114
|
-
conflict = src_exists && tgt_exists && tgt_mtime && src_mtime &&
|
|
165
|
+
conflict = !render && src_exists && tgt_exists && tgt_mtime && src_mtime &&
|
|
115
166
|
tgt_mtime - src_mtime >= 60
|
|
116
167
|
|
|
117
168
|
Job.new(
|
|
118
|
-
program:
|
|
119
|
-
path:
|
|
120
|
-
description:
|
|
121
|
-
active:
|
|
122
|
-
excludes:
|
|
123
|
-
label:
|
|
124
|
-
source:
|
|
125
|
-
target:
|
|
126
|
-
cmd:
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
169
|
+
program: r["Program"].to_s,
|
|
170
|
+
path: path,
|
|
171
|
+
description: r["Description"].to_s,
|
|
172
|
+
active: (r["Active"] || 0).to_i,
|
|
173
|
+
excludes: excludes,
|
|
174
|
+
label: r["Label"].to_s,
|
|
175
|
+
source: source,
|
|
176
|
+
target: target,
|
|
177
|
+
cmd: r["Cmd"].to_s,
|
|
178
|
+
delete: r["Delete"] == true,
|
|
179
|
+
render: render,
|
|
180
|
+
render_outdated: render_outdated,
|
|
181
|
+
target_path_field: target_path_field,
|
|
182
|
+
sync_file: r["_note_file"].to_s,
|
|
183
|
+
source_exists: src_exists,
|
|
184
|
+
target_exists: tgt_exists,
|
|
185
|
+
source_mtime: src_mtime,
|
|
186
|
+
target_mtime: tgt_mtime,
|
|
187
|
+
conflict: !!conflict,
|
|
188
|
+
target_unreachable: false,
|
|
133
189
|
)
|
|
134
190
|
end
|
|
135
191
|
|
|
@@ -139,5 +195,17 @@ module Twin
|
|
|
139
195
|
rescue Errno::ENOENT, Errno::EACCES
|
|
140
196
|
[false, nil]
|
|
141
197
|
end
|
|
198
|
+
|
|
199
|
+
# For a render job: is the target out of date with the rendered template?
|
|
200
|
+
# nil when source is missing/a directory (status falls through to those).
|
|
201
|
+
# True when target is absent or content differs, or the template can't be
|
|
202
|
+
# rendered (unresolved token) — i.e. needs attention.
|
|
203
|
+
def render_outdated?(src_full, tgt_full, vars, context)
|
|
204
|
+
return nil unless File.file?(src_full)
|
|
205
|
+
rendered = Twin::Template.render_file(src_full, vars, context: context)
|
|
206
|
+
!File.exist?(tgt_full) || File.binread(tgt_full) != rendered
|
|
207
|
+
rescue
|
|
208
|
+
true
|
|
209
|
+
end
|
|
142
210
|
end
|
|
143
211
|
end
|