diff options
Diffstat (limited to 'lib/dslkeywords')
| -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 |
7 files changed, 325 insertions, 243 deletions
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 |
