summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-02 08:34:35 +0200
committerPaul Buetow <paul@buetow.org>2026-03-02 08:34:35 +0200
commitc1275b2c29ba755d88d7c0253e0c32e820389107 (patch)
tree8713a342f84ab8a432f781573dd1a6e7178cd51d
parent7c439bef61b90e6744ac971a999262a0eeb76750 (diff)
parent5b8ce0b75271af6b4799800178ab3039d97c47b7 (diff)
Merge branch 'develop'
Includes security fixes, bug fixes, and code quality refactors: - Fix command injection in DNFPackageManager (system() multi-arg form) - Fix backup_resursively! typo (latent NoMethodError) - Add error handling to DNFPackageManager (CommandFailed + run_dnf!) - Split file.rb monolith into per-class files - Extract DryRun concern (SRP), narrow BasicFile interface (ISP) - Extract register_keyword DSL helper (DRY) - Replace ObjectSpace scan with inherited-hook class registry - Defer Options.parse! and Config.load! to application entry point - Add Justfiles to all example directories
-rw-r--r--examples/cli/Justfile17
-rw-r--r--examples/gem/Gemfile.lock2
-rw-r--r--examples/gem/Justfile15
-rw-r--r--examples/plain_ruby/Justfile11
-rw-r--r--examples/rake/Justfile15
-rw-r--r--lib/config.rb21
-rw-r--r--lib/dsl.rb29
-rw-r--r--lib/dslkeywords/directory.rb106
-rw-r--r--lib/dslkeywords/file.rb263
-rw-r--r--lib/dslkeywords/file_backup.rb49
-rw-r--r--lib/dslkeywords/package.rb39
-rw-r--r--lib/dslkeywords/resource.rb51
-rw-r--r--lib/dslkeywords/symlink.rb28
-rw-r--r--lib/dslkeywords/touch.rb32
-rw-r--r--lib/options.rb47
15 files changed, 458 insertions, 267 deletions
diff --git a/examples/cli/Justfile b/examples/cli/Justfile
new file mode 100644
index 0000000..4c81601
--- /dev/null
+++ b/examples/cli/Justfile
@@ -0,0 +1,17 @@
+rcm := "../../bin/rcm"
+
+# Apply configuration
+run:
+ {{rcm}} config.rb
+
+# Dry run — show what would change without making changes
+dry:
+ {{rcm}} config.rb --dry
+
+# Verbose output
+debug:
+ {{rcm}} config.rb --debug
+
+# Limit execution to specific hosts (comma-separated, e.g. just hosts earth,mars)
+hosts target:
+ {{rcm}} config.rb --hosts {{target}}
diff --git a/examples/gem/Gemfile.lock b/examples/gem/Gemfile.lock
index 502fccc..f426524 100644
--- a/examples/gem/Gemfile.lock
+++ b/examples/gem/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: ../..
specs:
- rcm (0.1.0)
+ rcm (0.1.1)
erb
toml (~> 0.3)
diff --git a/examples/gem/Justfile b/examples/gem/Justfile
new file mode 100644
index 0000000..005267f
--- /dev/null
+++ b/examples/gem/Justfile
@@ -0,0 +1,15 @@
+# Install gem dependencies
+setup:
+ bundle install
+
+# Apply configuration
+run:
+ bundle exec ruby config.rb
+
+# Dry run — show what would change without making changes
+dry:
+ bundle exec ruby config.rb --dry
+
+# Verbose output
+debug:
+ bundle exec ruby config.rb --debug
diff --git a/examples/plain_ruby/Justfile b/examples/plain_ruby/Justfile
new file mode 100644
index 0000000..c758519
--- /dev/null
+++ b/examples/plain_ruby/Justfile
@@ -0,0 +1,11 @@
+# Apply configuration
+run:
+ ruby config.rb
+
+# Dry run — show what would change without making changes
+dry:
+ ruby config.rb --dry
+
+# Verbose output
+debug:
+ ruby config.rb --debug
diff --git a/examples/rake/Justfile b/examples/rake/Justfile
new file mode 100644
index 0000000..e25852a
--- /dev/null
+++ b/examples/rake/Justfile
@@ -0,0 +1,15 @@
+# Install gem dependencies
+setup:
+ bundle install
+
+# Apply configuration
+run:
+ rake setup
+
+# Dry run — show what would change without making changes
+dry:
+ rake setup -- --dry
+
+# Verbose output
+debug:
+ rake setup -- --debug
diff --git a/lib/config.rb b/lib/config.rb
index 2a13ae0..fa43c4f 100644
--- a/lib/config.rb
+++ b/lib/config.rb
@@ -8,12 +8,23 @@ end
module RCM
# Configuration — config.toml is optional. If the toml gem is not installed
# or no config.toml exists, config() will raise a helpful error when called.
+ #
+ # Config is not loaded at module load time. Call Config.load! once at the
+ # application entry point (e.g. from configure) before calling config().
+ # Tests that don't use config() don't need config.toml at all.
module Config
- @@config = if TOML_AVAILABLE && File.exist?('config.toml')
- TOML.load_file('config.toml')
- else
- {}
- end
+ @@config = {}
+
+ # Load (or reload) config.toml from the current working directory.
+ # Falls back to an empty hash when the toml gem is unavailable or the
+ # file does not exist, so callers that never invoke config() are unaffected.
+ def self.load!
+ @@config = if TOML_AVAILABLE && ::File.exist?('config.toml')
+ TOML.load_file('config.toml')
+ else
+ {}
+ end
+ end
def config(key)
raise "No such config key: #{key}" unless @@config.key?(key)
diff --git a/lib/dsl.rb b/lib/dsl.rb
index 4a2c4ad..e3f6ee2 100644
--- a/lib/dsl.rb
+++ b/lib/dsl.rb
@@ -4,6 +4,9 @@ require_relative 'log'
require_relative 'chained'
require_relative 'dslkeywords/file'
+require_relative 'dslkeywords/symlink'
+require_relative 'dslkeywords/touch'
+require_relative 'dslkeywords/directory'
require_relative 'dslkeywords/given'
require_relative 'dslkeywords/notify'
@@ -43,10 +46,36 @@ module RCM
@scheduled << @@objs[obj.id] = obj
end
+
+ private
+
+ # Shared helper for all file-system keyword registrations.
+ # Returns the keyword symbol when called without a path (used by the
+ # Chained DSL to identify resource types without creating an object).
+ # Otherwise guards on @conds_met, instantiates klass, lets the caller
+ # configure the object, registers it, and returns it.
+ #
+ # The block is always yielded — callers that accept an optional DSL
+ # block must guard for nil themselves inside the closure, e.g.
+ # register_keyword(Touch, :touch, path) { |t| t.instance_eval(&block) if block }
+ def register_keyword(klass, name, path)
+ return name if path.nil?
+ return unless @conds_met
+
+ obj = klass.new(path)
+ yield obj
+ self << obj
+ obj
+ end
end
end
def configure(reset: false, &block)
+ # Parse ARGV and load config.toml each time configure is called so that
+ # scripts and test suites that call configure multiple times always
+ # start from a consistent, freshly-loaded state.
+ RCM::Options.parse!
+ RCM::Config.load!
RCM::DSL.new(reset) do |rcm|
rcm.info('Configuring...')
rcm.instance_eval(&block)
diff --git a/lib/dslkeywords/directory.rb b/lib/dslkeywords/directory.rb
new file mode 100644
index 0000000..072c88a
--- /dev/null
+++ b/lib/dslkeywords/directory.rb
@@ -0,0 +1,106 @@
+require 'fileutils'
+
+require_relative 'file'
+
+module RCM
+ # Manages directories: create, delete/purge, or recursively copy from
+ # a source directory. Backup is performed before destructive operations.
+ # Extends BasicFile directly — Directory has no file content or sourcing,
+ # so it must not inherit content/from from BaseFile (ISP). The source
+ # directory for recursive copy is stored via the separate #source method.
+ class Directory < BasicFile
+ def recursively = @recursively = true
+
+ # Set or get the source directory path used for recursive copy.
+ def source(path = nil) = path.nil? ? @source_path : @source_path = path
+
+ def evaluate!
+ return unless super
+
+ case @is
+ when :present
+ evaluate_present!
+ when :absent, :purged
+ evaluate_absent!
+ end
+ ensure
+ permissions!
+ end
+
+ private
+
+ def evaluate_present!
+ if ::File.directory?(@file_path)
+ return @recursively ? evaluate_present_recursively! : nil
+ end
+
+ create_parent_directory! if @manage_directory
+
+ do? "Creating directory #{@file_path}" do
+ Dir.mkdir(@file_path)
+ end
+ end
+
+ # Override BasicFile#evaluate_absent! with directory-specific behaviour:
+ # optionally recursive removal and backup of the whole directory tree.
+ def evaluate_absent!
+ return unless ::File.directory?(@file_path)
+
+ backup!(@file_path)
+ @recursively = true if @is == :purged
+ what = @is == :purged ? 'Purging' : 'Deleting'
+
+ do? "#{what} directory #{@file_path}" do
+ if ::File.directory?(@file_path)
+ @recursively ? FileUtils.rm_r(@file_path) : Dir.delete(@file_path)
+ end
+ end
+ cleanup_parent_directory! if @manage_directory
+ end
+
+ def evaluate_present_recursively!
+ src = source
+ raise "Source #{src} is not a directory!" unless ::File.directory?(src)
+
+ if ::File.exist?(@file_path)
+ raise "Destination #{@file_path} is not a directory!" unless ::File.directory?(@file_path)
+
+ backup_recursively!(src, @file_path) unless @without_backup
+ end
+
+ do? "Copying #{src} -> #{@file_path} recursively" do
+ if ::File.directory?(@file_path)
+ Dir["#{src}/*"].each { FileUtils.cp_r(_1, @file_path) }
+ else
+ FileUtils.cp_r(src, @file_path)
+ end
+ end
+ end
+
+ # TODO: Unit test this
+ def backup_recursively!(source, dest)
+ Dir.foreach(source) do |entry|
+ next if ['.', '..'].include?(entry)
+
+ source_path = ::File.join(source, entry)
+ dest_path = ::File.join(dest, entry)
+
+ if ::File.directory?(source_path) && !::File.directory?(dest_path)
+ raise "Unable to copy directory #{source_path} into non-directory #{dest_path}"
+ elsif !::File.directory?(source_path) && ::File.directory?(dest_path)
+ raise "Unable to copy non-directory #{source_path} into directory #{dest_path}"
+ elsif ::File.directory?(source_path) && ::File.directory?(dest_path)
+ backup_recursively!(source_path, dest_path)
+ else
+ backup!(dest_path)
+ end
+ end
+ end
+ end
+
+ class DSL
+ def directory(file_path = nil, &block)
+ register_keyword(Directory, :directory, file_path) { |d| d.source(d.instance_eval(&block)) }
+ end
+ end
+end
diff --git a/lib/dslkeywords/file.rb b/lib/dslkeywords/file.rb
index 927054a..8e1c772 100644
--- a/lib/dslkeywords/file.rb
+++ b/lib/dslkeywords/file.rb
@@ -4,57 +4,23 @@ require 'fileutils'
require_relative 'resource'
require_relative '../chained'
+require_relative 'file_backup'
module RCM
- # Backup the file on change
- module FileBackup
- # TODO: Make protected?
- def backup!(file_path, checksum = nil)
- return if @without_backup
-
- suffix = if ::File.file?(file_path)
- checksum.nil? ? Digest::SHA256.file(file_path).hexdigest : checksum
- else
- Time.now.strftime('%s-%L')
- end
- make_backup!(file_path, suffix)
- end
-
- def different?(file_a, file_b)
- checksum_a = Digest::SHA256.file(file_a).hexdigest
- checksum_b = Digest::SHA256.file(file_b).hexdigest
- [checksum_a != checksum_b, checksum_a, checksum_b]
- end
-
- private
-
- def make_backup!(file_path, suffix)
- backup_dir = create_backup_directory!(file_path)
- backup_path = "#{backup_dir}/#{::File.basename(file_path)}.#{suffix}"
- return if ::File.exist?(backup_path)
-
- do? "Backing up #{file_path} -> #{backup_path}" do
- ::File.rename(file_path, backup_path)
- end
- end
-
- def create_backup_directory!(file_path)
- backup_dir = "#{::File.dirname(file_path)}/.rcmbackup"
- return backup_dir if ::File.directory?(backup_dir)
-
- do? "Creating backup directory #{backup_dir}" do
- Dir.mkdir(backup_dir)
- end
-
- backup_dir
- end
- end
-
- # Base for BaseFile and Directory
+ # Base class shared by all file-system resources (files, symlinks,
+ # touch, directories). Manages path, state (:present/:absent/:purged),
+ # permissions (mode/owner/group), and parent-directory lifecycle.
+ # Does NOT include content/templating — those belong in BaseFile so
+ # Touch and Directory (which have no file content) don't inherit them.
class BasicFile < Resource
include Chained
include FileBackup
+ # Raised by validate when an unsupported DSL option is used.
+ # Defined here so BasicFile#validate can raise it even when the
+ # concrete class does not extend BaseFile.
+ class UnsupportedOperation < StandardError; end
+
def initialize(file_path)
super(file_path)
@file_path = file_path
@@ -77,14 +43,6 @@ module RCM
true
end
- def content(text = nil)
- if text.nil?
- text = @from == :sourcefile ? ::File.read(@content) : @content
- return @from == :template ? ERB.new(text).result : text
- end
- @content = text.instance_of?(Array) ? text.join("\n") : text
- end
-
protected
def permissions!(file_path = path)
@@ -95,7 +53,7 @@ module RCM
set_owner!(stat)
end
- # Validate whether we can use this up in this context or not
+ # Reject DSL options that are not valid for this resource type.
def validate(method, what, *valids)
return what if valids.include?(what)
@@ -103,6 +61,18 @@ module RCM
"Unsupported '#{method}' operation #{what} (#{what.class})"
end
+ # Delete the resource and optionally remove orphaned parent directories.
+ # Used by File, Symlink, and Touch; Directory overrides this.
+ def evaluate_absent!
+ if ::File.exist?(@file_path)
+ do? "Deleting #{@file_path}" do
+ backup!(@file_path)
+ ::File.delete(@file_path) if ::File.file?(@file_path)
+ end
+ end
+ cleanup_parent_directory! if @manage_directory
+ end
+
def create_parent_directory!
dirname = ::File.dirname(@file_path)
return if ::File.directory?(dirname)
@@ -149,26 +119,27 @@ module RCM
end
end
- # Base for File and Symlink
+ # Intermediate base for resources that carry file content: regular files
+ # and symlinks. Adds content storage with optional ERB templating or
+ # sourcefile reading. Touch and Directory extend BasicFile directly so
+ # they are not burdened with content/from (ISP).
class BaseFile < BasicFile
- class UnsupportedOperation < StandardError; end
-
def from(what) = @from = validate(__method__, what.to_sym, :sourcefile, :template)
- protected
-
- def evaluate_absent!
- if ::File.exist?(@file_path)
- do? "Deleting #{@file_path}" do
- backup!(@file_path)
- ::File.delete(@file_path) if ::File.file?(@file_path)
- end
+ # Return or set the resource's content.
+ # Getter: resolves ERB templates or reads sourcefile on demand.
+ # Setter: stores plain text or joins an array with newlines.
+ def content(text = nil)
+ if text.nil?
+ text = @from == :sourcefile ? ::File.read(@content) : @content
+ return @from == :template ? ERB.new(text).result : text
end
- cleanup_parent_directory! if @manage_directory
+ @content = text.instance_of?(Array) ? text.join("\n") : text
end
end
- # Managing files
+ # Manages regular files: write content, ensure/remove individual lines,
+ # delete. Writes via a temp file so the final rename is atomic.
class File < BaseFile
def line(line) = @ensure_line = line
@@ -237,165 +208,9 @@ module RCM
end
end
- # Manage symlinks
- class Symlink < BaseFile
- def evaluate!
- return unless super
- return evaluate_absent! if %i[absent purged].include?(@is)
- return if ::File.symlink?(@file_path) && ::File.readlink(@file_path) == content
-
- create_parent_directory! if @manage_directory
- do? "Creating symlink #{@file_path}" do
- FileUtils.ln_sf(content, @file_path)
- end
- ensure
- permissions!
- end
- end
-
- # Emtpy file
- class Touch < BaseFile
- def is(what) = @is = validate(__method__, what.to_sym, :present, :absent, :purged, :updated)
-
- def evaluate!
- return unless super
- return evaluate_absent! if %i[absent purged].include?(@is)
- return if ::File.file?(@file_path) && @is != :updated
-
- create_parent_directory! if @manage_directory
- do? "Touching #{@file_path}" do
- FileUtils.touch(@file_path)
- end
- ensure
- permissions!
- end
- end
-
- class Directory < BaseFile
- def recursively = @recursively = true
-
- def evaluate!
- return unless super
-
- case @is
- when :present
- evaluate_present!
- when :absent, :purged
- evaluate_absent!
- end
- ensure
- permissions!
- end
-
- private
-
- def evaluate_present!
- if ::File.directory?(@file_path)
- return @recursively ? evaluate_present_recursively! : nil
- end
-
- create_parent_directory! if @manage_directory
-
- do? "Creating directory #{@file_path}" do
- Dir.mkdir(@file_path)
- end
- end
-
- def evaluate_absent!
- return unless ::File.directory?(@file_path)
-
- backup!(@file_path)
- @recursively = true if @is == :purged
- what = @is == :purged ? 'Purging' : 'Deleting'
-
- do? "#{what} directory #{@file_path}" do
- if ::File.directory?(@file_path)
- @recursively ? FileUtils.rm_r(@file_path) : Dir.delete(@file_path)
- end
- end
- cleanup_parent_directory! if @manage_directory
- end
-
- def evaluate_present_recursively!
- source_path = content
- raise "Source #{source_path} is not a directory!" unless ::File.directory?(source_path)
-
- if ::File.exist?(@file_path)
- raise "Destination #{@file_path} is not a directory!" unless ::File.directory?(@file_path)
-
- backup_resursively!(source_path, @file_path) unless @without_backup
- end
-
- do? "Copying #{source_path} -> #{@file_path} resursively" do
- if ::File.directory?(@file_path)
- Dir["#{source_path}/*"].each { FileUtils.cp_r(_1, @file_path) }
- else
- FileUtils.cp_r(source_path, @file_path)
- end
- end
- end
-
- # TODO: Unit test this
- def backup_recursively!(source, dest)
- Dir.foreach(source) do |entry|
- next if ['.', '..'].include?(entry)
-
- source_path = ::File.join(source, entry)
- dest_path = ::File.join(dest, entry)
-
- if ::File.directory?(source_path) && !::File.directory?(dest_path)
- raise "Unable to copy directory #{source_path} into non-directory #{dest_path}"
- elsif !::File.directory?(source_path) && ::File.directory?(dest_path)
- raise "Unable to copy non-directory #{source_path} into directory #{dest_path}"
- elsif ::File.directory?(source_path) && ::File.directory?(dest_path)
- backup_recursively!(source_path, dest_path)
- else
- backup!(dest_path)
- end
- end
- end
- end
-
class DSL
- # Add file keyword to the DSL
def file(file_path = nil, &block)
- return :file if file_path.nil?
- return unless @conds_met
-
- f = File.new(file_path)
- f.content(f.instance_eval(&block))
- self << f
- f
- end
-
- def symlink(file_path = nil, &block)
- return :symlink if file_path.nil?
- return unless @conds_met
-
- s = Symlink.new(file_path)
- s.content(s.instance_eval(&block))
- self << s
- s
- end
-
- def touch(file_path = nil, &block)
- return :touch if file_path.nil?
- return unless @conds_met
-
- t = Touch.new(file_path)
- t.instance_eval(&block) if block
- self << t
- t
- end
-
- def directory(file_path = nil, &block)
- return :directory if file_path.nil?
- return unless @conds_met
-
- d = Directory.new(file_path)
- d.content(d.instance_eval(&block))
- self << d
- d
+ register_keyword(File, :file, file_path) { |f| f.content(f.instance_eval(&block)) }
end
end
end
diff --git a/lib/dslkeywords/file_backup.rb b/lib/dslkeywords/file_backup.rb
new file mode 100644
index 0000000..210804c
--- /dev/null
+++ b/lib/dslkeywords/file_backup.rb
@@ -0,0 +1,49 @@
+require 'digest'
+
+module RCM
+ # Mixin that provides file-backup helpers for resource classes.
+ # Included by BasicFile so all file/directory/symlink resources share
+ # the same backup logic.
+ module FileBackup
+ # TODO: Make protected?
+ def backup!(file_path, checksum = nil)
+ return if @without_backup
+
+ suffix = if ::File.file?(file_path)
+ checksum.nil? ? Digest::SHA256.file(file_path).hexdigest : checksum
+ else
+ Time.now.strftime('%s-%L')
+ end
+ make_backup!(file_path, suffix)
+ end
+
+ def different?(file_a, file_b)
+ checksum_a = Digest::SHA256.file(file_a).hexdigest
+ checksum_b = Digest::SHA256.file(file_b).hexdigest
+ [checksum_a != checksum_b, checksum_a, checksum_b]
+ end
+
+ private
+
+ def make_backup!(file_path, suffix)
+ backup_dir = create_backup_directory!(file_path)
+ backup_path = "#{backup_dir}/#{::File.basename(file_path)}.#{suffix}"
+ return if ::File.exist?(backup_path)
+
+ do? "Backing up #{file_path} -> #{backup_path}" do
+ ::File.rename(file_path, backup_path)
+ end
+ end
+
+ def create_backup_directory!(file_path)
+ backup_dir = "#{::File.dirname(file_path)}/.rcmbackup"
+ return backup_dir if ::File.directory?(backup_dir)
+
+ do? "Creating backup directory #{backup_dir}" do
+ Dir.mkdir(backup_dir)
+ end
+
+ backup_dir
+ end
+ end
+end
diff --git a/lib/dslkeywords/package.rb b/lib/dslkeywords/package.rb
index 86903cf..9e4324f 100644
--- a/lib/dslkeywords/package.rb
+++ b/lib/dslkeywords/package.rb
@@ -5,10 +5,40 @@ require_relative 'resource'
module RCM
class DNFPackageManager
+ # Raised when a dnf subcommand exits with a non-zero status or when
+ # the dnf binary cannot be found.
+ class CommandFailed < StandardError; end
+
def installed?(pkg) = false
- def install(pkg) = `dnf install -y "#{pkg}"` unless installed?(pkg)
- def update(pkg) = `dnf update -y "#{pkg}"`
- def remove(pkg) = `dnf remove -y "#{pkg}"` if installed?(pkg)
+
+ def install(pkg)
+ return if installed?(pkg)
+
+ run_dnf!('install', pkg)
+ end
+
+ def update(pkg)
+ run_dnf!('update', pkg)
+ end
+
+ def remove(pkg)
+ return unless installed?(pkg)
+
+ run_dnf!('remove', pkg)
+ end
+
+ private
+
+ # Execute dnf <subcommand> -y <pkg> using separate arguments (no shell
+ # interpolation). Raises CommandFailed when dnf exits non-zero or is
+ # not found ($? is nil when the binary cannot be exec'd).
+ def run_dnf!(subcommand, pkg)
+ result = system('dnf', subcommand, '-y', pkg)
+ return if result
+
+ exit_code = $?&.exitstatus || '?'
+ raise CommandFailed, "dnf #{subcommand} #{pkg} failed (exit #{exit_code})"
+ end
end
# Managing packages
@@ -19,7 +49,8 @@ module RCM
def initialize(name)
super(name)
- raise UnsupportedOS, 'OS is not supported' unless File.file?('/etc/fedora-release')
+ # Use ::File to avoid resolving to RCM::File once file.rb is loaded.
+ raise UnsupportedOS, 'OS is not supported' unless ::File.file?('/etc/fedora-release')
@manager = DNFPackageManager.new
diff --git a/lib/dslkeywords/resource.rb b/lib/dslkeywords/resource.rb
index c48398b..c1c2554 100644
--- a/lib/dslkeywords/resource.rb
+++ b/lib/dslkeywords/resource.rb
@@ -3,15 +3,31 @@ require 'set'
require_relative 'keyword'
module RCM
- # To track recource dependencies
+ # Concern that wraps side-effecting blocks so they are skipped (and
+ # logged as dry-run) when the --dry option is active. Kept separate
+ # from dependency tracking so each module has a single responsibility.
+ module DryRun
+ # Log the action and yield the block, unless --dry is active.
+ # In dry-run mode only logs the message (with " - dry run!" appended)
+ # and returns without executing the block.
+ def do?(message)
+ if option :dry
+ info("#{message} - dry run!")
+ return
+ end
+ info(message)
+ yield
+ end
+ end
+
+ # To track resource dependencies
module ResourceDependencies
def initialize(...)
super(...)
@requires = Set.new
- @valid_resources = Set.new
- ObjectSpace.each_object(Class).each do |klass|
- @valid_resources << klass.to_s.sub('RCM::', '').downcase.to_sym if klass < Resource
- end
+ # Use the class-level registry (populated via Resource.inherited) rather
+ # than scanning ObjectSpace — deterministic, load-order-safe, and O(1).
+ @valid_resources = Resource.subclass_names
end
def method_missing(method_name, *args)
@@ -38,16 +54,6 @@ module RCM
end
def requires?(*others) = others.flatten.none? { |other| !@requires&.include?(other) }
-
- # Only run the block when not in dry mode
- def do?(message)
- if option :dry
- info("#{message} - dry run!")
- return
- end
- info(message)
- yield
- end
end
# To resolve dependencies
@@ -79,12 +85,27 @@ module RCM
# A resource is something concrete to be managed, e.g. a file, or a CRON job.
class Resource < Keyword
+ include DryRun
include DependencyEvaluator
include ResourceDependencies
class NoSuchResourceObject < StandardError; end
@@resource_find_cache = {}
+ # Class-level registry: every subclass is registered here when it is
+ # first loaded (via the inherited hook), so ResourceDependencies can
+ # look up valid keyword names without scanning ObjectSpace.
+ @@subclass_names = Set.new
+
+ def self.inherited(subclass)
+ super
+ @@subclass_names << subclass.to_s.sub('RCM::', '').downcase.to_sym
+ end
+
+ # Return a frozen snapshot so callers cannot accidentally mutate the
+ # shared registry through the @valid_resources instance variable.
+ def self.subclass_names = @@subclass_names.freeze
+
def self.find(id)
return @@resource_find_cache[id] if @@resource_find_cache.key?(id)
diff --git a/lib/dslkeywords/symlink.rb b/lib/dslkeywords/symlink.rb
new file mode 100644
index 0000000..053e83d
--- /dev/null
+++ b/lib/dslkeywords/symlink.rb
@@ -0,0 +1,28 @@
+require 'fileutils'
+
+require_relative 'file'
+
+module RCM
+ # Manages symbolic links: creates or removes them, optionally under
+ # a managed parent directory, and applies permissions afterwards.
+ class Symlink < BaseFile
+ def evaluate!
+ return unless super
+ return evaluate_absent! if %i[absent purged].include?(@is)
+ return if ::File.symlink?(@file_path) && ::File.readlink(@file_path) == content
+
+ create_parent_directory! if @manage_directory
+ do? "Creating symlink #{@file_path}" do
+ FileUtils.ln_sf(content, @file_path)
+ end
+ ensure
+ permissions!
+ end
+ end
+
+ class DSL
+ def symlink(file_path = nil, &block)
+ register_keyword(Symlink, :symlink, file_path) { |s| s.content(s.instance_eval(&block)) }
+ end
+ end
+end
diff --git a/lib/dslkeywords/touch.rb b/lib/dslkeywords/touch.rb
new file mode 100644
index 0000000..13d63f7
--- /dev/null
+++ b/lib/dslkeywords/touch.rb
@@ -0,0 +1,32 @@
+require 'fileutils'
+
+require_relative 'file'
+
+module RCM
+ # Creates an empty file (touch semantics). Supports the additional
+ # :updated state which re-touches the file even when it already exists.
+ # Extends BasicFile directly — Touch has no file content or sourcing,
+ # so it must not inherit content/from from BaseFile (ISP).
+ class Touch < BasicFile
+ def is(what) = @is = validate(__method__, what.to_sym, :present, :absent, :purged, :updated)
+
+ def evaluate!
+ return unless super
+ return evaluate_absent! if %i[absent purged].include?(@is)
+ return if ::File.file?(@file_path) && @is != :updated
+
+ create_parent_directory! if @manage_directory
+ do? "Touching #{@file_path}" do
+ FileUtils.touch(@file_path)
+ end
+ ensure
+ permissions!
+ end
+ end
+
+ class DSL
+ def touch(file_path = nil, &block)
+ register_keyword(Touch, :touch, file_path) { |t| t.instance_eval(&block) if block }
+ end
+ end
+end
diff --git a/lib/options.rb b/lib/options.rb
index 6ce2479..41a6a50 100644
--- a/lib/options.rb
+++ b/lib/options.rb
@@ -4,30 +4,41 @@ module RCM
# Command line options, supports both Rake mode (args after --)
# and standalone mode (direct args). Unknown options are ignored
# so that test runners and other tools can pass their own flags.
+ #
+ # Defaults are set at module load time. Call Options.parse! once at
+ # the application entry point to overlay them with actual ARGV values.
+ # Tests that never call parse! safely get the default values.
module Options
@@options = { debug: false, dry: false, hosts: [] }
- parser = OptionParser.new do |opts|
- opts.banner = 'Usage: rake [task] -- [options] OR ruby config.rb [options]'
- opts.on('-v', '--[no-]debug', 'debug output') { |v| @@options[:debug] = v }
- opts.on('-d', '--dry', 'dry mode') { |v| @@options[:dry] = v }
- opts.on('--hosts HOSTS', 'comma-separated list of target hostnames') do |v|
- @@options[:hosts] = v.split(',').map(&:strip)
+ # Parse ARGV and update @@options. Resets to defaults before each
+ # parse so stale values cannot accumulate across repeated calls
+ # (e.g. between test cases).
+ def self.parse!
+ @@options = { debug: false, dry: false, hosts: [] }
+
+ parser = OptionParser.new do |opts|
+ opts.banner = 'Usage: rake [task] -- [options] OR ruby config.rb [options]'
+ opts.on('-v', '--[no-]debug', 'debug output') { |v| @@options[:debug] = v }
+ opts.on('-d', '--dry', 'dry mode') { |v| @@options[:dry] = v }
+ opts.on('--hosts HOSTS', 'comma-separated list of target hostnames') do |v|
+ @@options[:hosts] = v.split(',').map(&:strip)
+ end
end
- end
- # Rake passes args after '--'; standalone scripts pass args directly.
- args = if ARGV.include?('--')
- ARGV.slice_before('--').to_a.last.drop(1)
- else
- ARGV.dup
- end
+ # Rake passes args after '--'; standalone scripts pass args directly.
+ args = if ARGV.include?('--')
+ ARGV.slice_before('--').to_a.last.drop(1)
+ else
+ ARGV.dup
+ end
- # Ignore unknown options (e.g. from test runners or other tools)
- begin
- parser.parse!(args)
- rescue OptionParser::InvalidOption
- retry
+ # Ignore unknown options (e.g. flags from test runners or rake itself).
+ begin
+ parser.parse!(args)
+ rescue OptionParser::InvalidOption
+ retry
+ end
end
def option(key)