Module: Agent::Lock::Freeze

Defined in:
lib/agent/lock/freeze.rb

Overview

Optional teeth, for the scope you want nobody to touch at all.

An advisory lock works because every agent checks it. That covers the agents that check. chflags uchg on macOS makes the file unwritable by anything, checking or not, which is the right answer for a handful of files that must not move while something else runs.

It is opt-in for three reasons. It is macOS only, since Linux's chattr +i needs root. It denies the holder too, so it fits a freeze rather than a file you are editing. And a session that dies with files frozen leaves a tree where git checkout and rm -rf fail with "Operation not permitted", which is why every frozen path is written into the lock: the thaw does not depend on the process that froze them still being alive.

Defined Under Namespace

Classes: TooBroad

Constant Summary collapse

LIMIT =

A freeze walks and flags every matched file, so a scope covering a whole checkout is a mistake rather than an instruction.

500
BATCH =

How many paths one chflags invocation is given.

200

Class Method Summary collapse

Class Method Details

.apply(paths, tree:, force: false) ⇒ Array<String>

Returns what was frozen.

Parameters:

  • paths (Array<String>) —

    relative to the tree root

  • tree (Tree)
  • force (Boolean) (defaults to: false) —

    allow a freeze wider than LIMIT

Returns:

  • (Array<String>) —

    what was frozen

Raises:



58
59
60
61
62
63
64
# File 'lib/agent/lock/freeze.rb', line 58

def apply(paths, tree:, force: false)
  raise TooBroad, "--enforce needs macOS; this is #{RUBY_PLATFORM}" unless supported?
  return [] if paths.empty?
  raise TooBroad, "#{paths.size} files is wider than a freeze should be" if paths.size > LIMIT && !force

  chflags("uchg", paths, tree)
end

.chflags(flag, paths, tree) ⇒ Array<String>

Only the files chflags actually accepted come back.

A batch that fails is retried one file at a time, because the usual reason is a single path somebody else owns, and reporting the whole batch as frozen would leave the lock claiming protection it does not have. Reporting the whole batch as failed would be just as wrong.

Returns:

  • (Array<String>) —

    relative paths that are now flagged



84
85
86
87
88
89
90
# File 'lib/agent/lock/freeze.rb', line 84

def chflags(flag, paths, tree)
  return [] unless supported?

  paths.each_slice(BATCH)
       .flat_map { |batch| flag_batch(flag, existing(batch, tree)) }
       .map { |path| path.delete_prefix("#{tree.root}/") }
end

.clear(paths, tree:) ⇒ Array<String>

Best effort on purpose: a path that has since been deleted or already thawed is not a reason to leave the rest frozen.

Returns:

  • (Array<String>) —

    what was thawed



70
71
72
73
74
# File 'lib/agent/lock/freeze.rb', line 70

def clear(paths, tree:)
  return [] if paths.nil? || paths.empty?

  chflags("nouchg", paths, tree)
end

.existing(batch, tree) ⇒ Array<String>

Returns absolute paths that are still there to flag.

Returns:

  • (Array<String>) —

    absolute paths that are still there to flag



93
94
95
# File 'lib/agent/lock/freeze.rb', line 93

def existing(batch, tree)
  batch.map { |rel| File.join(tree.root, rel) }.select { |path| File.exist?(path) }
end

.flag_batch(flag, absolute) ⇒ Array<String>

Returns the ones the command accepted.

Returns:

  • (Array<String>) —

    the ones the command accepted



98
99
100
101
102
103
# File 'lib/agent/lock/freeze.rb', line 98

def flag_batch(flag, absolute)
  return [] if absolute.empty?
  return absolute if run(flag, absolute)

  absolute.select { |path| run(flag, [path]) }
end

.matches(scope, tree) ⇒ Array<String>

Returns the files it would freeze, relative to the root.

Parameters:

Returns:

  • (Array<String>) —

    the files it would freeze, relative to the root



38
39
40
41
42
43
# File 'lib/agent/lock/freeze.rb', line 38

def matches(scope, tree)
  Dir.glob(recursive(scope.pattern), base: tree.root, flags: File::FNM_EXTGLOB)
     .select { |rel| File.file?(File.join(tree.root, rel)) }
     .reject { |rel| rel.start_with?(".git/") }
     .sort
end

.recursive(pattern) ⇒ String

A trailing ** means "everything under here" to this gem, and to anybody typing it. It does not mean that to Dir.glob, where a bare ** at the end matches one level, exactly like *. Only **/ walks down. So a scope that covers a subtree is spelled out before globbing.

Parameters:

  • pattern (String)

Returns:

  • (String)


52
# File 'lib/agent/lock/freeze.rb', line 52

def recursive(pattern) = pattern.end_with?("**") ? "#{pattern}/*" : pattern

.run(flag, paths) ⇒ Boolean

Returns whether the command reported success.

Returns:

  • (Boolean) —

    whether the command reported success



106
# File 'lib/agent/lock/freeze.rb', line 106

def run(flag, paths) = system("chflags", flag, *paths, out: File::NULL, err: File::NULL) || false

.supported? ⇒ Boolean

Returns:

  • (Boolean)


33
# File 'lib/agent/lock/freeze.rb', line 33

def supported? = RUBY_PLATFORM.include?("darwin")