diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-02 08:34:35 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-02 08:34:35 +0200 |
| commit | c1275b2c29ba755d88d7c0253e0c32e820389107 (patch) | |
| tree | 8713a342f84ab8a432f781573dd1a6e7178cd51d | |
| parent | 7c439bef61b90e6744ac971a999262a0eeb76750 (diff) | |
| parent | 5b8ce0b75271af6b4799800178ab3039d97c47b7 (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/Justfile | 17 | ||||
| -rw-r--r-- | examples/gem/Gemfile.lock | 2 | ||||
| -rw-r--r-- | examples/gem/Justfile | 15 | ||||
| -rw-r--r-- | examples/plain_ruby/Justfile | 11 | ||||
| -rw-r--r-- | examples/rake/Justfile | 15 | ||||
| -rw-r--r-- | lib/config.rb | 21 | ||||
| -rw-r--r-- | lib/dsl.rb | 29 | ||||
| -rw-r--r-- | lib/dslkeywords/directory.rb | 106 | ||||
| -rw-r--r-- | lib/dslkeywords/file.rb | 263 | ||||
| -rw-r--r-- | lib/dslkeywords/file_backup.rb | 49 | ||||
| -rw-r--r-- | lib/dslkeywords/package.rb | 39 | ||||
| -rw-r--r-- | lib/dslkeywords/resource.rb | 51 | ||||
| -rw-r--r-- | lib/dslkeywords/symlink.rb | 28 | ||||
| -rw-r--r-- | lib/dslkeywords/touch.rb | 32 | ||||
| -rw-r--r-- | lib/options.rb | 47 |
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) @@ -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) |
