require "uuid" require "uri" require "file_utils" require "specparser" require "./context.cr" require "./package.cr" require "./instructions.cr" require "./sources.cr" require "./exception.cr" module FileUtils def self.find(directory, &block : Proc(String, Nil)) Dir.each_child directory do |child| child_path = directory + "/" + child yield child_path if File.directory? child_path self.find child_path, &block end end end def self.find_files(directory, &block : Proc(String, Nil)) Dir.each_child directory do |child| child_path = directory + "/" + child if File.file? child_path yield child_path end if File.directory? child_path self.find child_path, &block end end end end class Package::Recipe @context : Context # Core recipe informations. getter name : String getter version : String getter release = 1 property url : String? property description : String? # Informations not specific to individual packages. getter sources = Sources.new getter packages = Array(Package).new property packager : String? property maintainer : String? getter contributors = Array(String).new # Relations to other packages. # FIXME: `dependencies` needs splitting between run-time and build-time. getter run_dependencies = Array(String).new getter build_dependencies = Array(String).new getter provides = Array(String).new getter conflicts = Array(String).new # Build instructions, helpers and other build-only data. setter dirname : String? # matching getter defined manually later. getter options = Hash(String, String).new getter watch_script : String? @prefix : String? property recipe_directory = "." property working_uuid : UUID = UUID.random # Does this package requires running `strip` on its content? # Man-page and source packages don't, for example. property require_stripping = true def initialize(@context, @name, @version) @context.options.each do |k, v| @options[k] = v end end def self.new(context, name, version) instance = Recipe.allocate.tap &.initialize(context, name, version) instance.packages << Package.new instance, false, instance.fake_root_directory instance end def initialize(@context, filename : String) @context.options.each do |k, v| @options[k] = v end specs = SpecParser.parse File.read(filename), { "pkg" => fake_root_directory, "prefix" => prefix } name : String? = nil version : String? = nil @recipe_directory = File.dirname filename specs.assignments.each do |key, value| case key when "name" name = value.as_s when "version" version = value.as_s when "release" @release = value.as_s.to_i when "url" @url = value.as_s when "description" @description = value.as_s_or_ls when "packager" @packager = value.as_s when "maintainer" @maintainer = value.as_s when "sources" value.as_a_or_s.each do |source| @sources << source end # User instructions. when /(?(pre-)?(configure|build|install)|post-install)/ phase = $~["phase"] @context.user_instructions[phase] = Instructions.new(phase).<<(value.as_s_or_ls) when "dirname" @dirname = value.as_s when "prefix" @prefix = value.as_s when "dependencies" value.as_a_or_s.each do |atom| @run_dependencies << atom @build_dependencies << atom end when "build-dependencies" value.as_a_or_s.each do |atom| @build_dependencies << atom end when "run-dependencies" value.as_a_or_s.each do |atom| @run_dependencies << atom end when "conflicts" value.as_a_or_s.each do |atom| @conflicts << atom end when "provides" value.as_a_or_s.each do |atom| @provides << atom end when "options" value.as_a_or_s.each do |option| match = option.split(':').map(&.strip) if match.size != 2 STDERR.puts "WARNING: misformed option: #{option}" next end key, value = match @options[key] = value end when "watch" @watch_script = value.as_s_or_ls end end raise "`name` was not provided" unless name raise "`version` was not provided" unless version @name = name @version = version # Packages can only be created once @name and @version exist! @packages << Package.new self, false, fake_root_directory # Spec sections are like this: # %split my-application # split = name of the section specs.sections.each do |section| case section.name when "split" @packages << Package.new self, section end end end def working_directory "#{@context.working_directory}/#{@working_uuid}" end def building_directory "#{working_directory}/build" end def fake_root_directory "#{working_directory}/root" end def dirname @dirname || "#{name}-#{version}" end def prefix : String @prefix || @context.prefixes[0]? || "/usr" end def download sources.each do |url| filename = @context.sources_directory + "/" + url.filename unless File.exists? filename if url.scheme == "file" Baguette::Log.info "Copying '#{url.filename}'" Do.cp "#{recipe_directory}/#{url.filename}", filename else Baguette::Log.info "Downloading '#{url.filename}'" status = Do.run @context.sources_directory, "wget", [ url.to_s, "-O", filename ] raise DownloadError.new self, url unless status.success? end end end end def extract Do.mkdir_p building_directory sources.each do |url| basename = url.filename if basename.match /\.(tar\.(gz|xz|bz2|lzma)|tgz)$/ Baguette::Log.info "Extracting '#{url.filename}'" status = Do.run( building_directory, "bsdtar", [ "xf", @context.sources_directory + "/" + url.filename ] ) raise ExtractionError.new self, url unless status.success? elsif basename.match /\.patch$/ Baguette::Log.info "Applying '#{url.filename}'" status = Do.run( "#{building_directory}/#{dirname}", "patch", [ "-i", "#{@context.sources_directory}/#{url.filename}" ] ) raise ExtractionError.new self, url unless status.success? else Baguette::Log.info "Copying '#{url.filename}'" directory = if url.scheme == "file" @recipe_directory else @context.sources_directory end Do.cp "#{directory}/#{url.filename}", "#{building_directory}/#{url.filename}" end end end def execute_backend(backends : Hash(String, Backend::Building)) : BuildStatus last_backend = "not-known" # In case the recipe didn't provide instructions: checking backends. backends.each do |name, backend| last_backend = name cname = name.colorize(:light_green).to_s Baguette::Log.info "Backend :: '#{cname}'" Do.cd building_directory rvalue = backend.build @context, self if rvalue == BuildStatus::Pass Baguette::Log.info "Pass…" next end return rvalue end BuildStatus::Pass rescue e Baguette::Log.error "Exception backend '#{last_backend}'" Baguette::Log.error "Exception caught: #{e.message}" BuildStatus::Failed end # TODO: # - Have some instructions be non-critical, like the (pre|post) ones. # - Be careful about return values, flee from everything if something # goes somehow wrong. def run Do.mkdir_p fake_root_directory ENV["PKG"] = fake_root_directory # Safety precautions. old_dir = Dir.current # TODO: skip sequences @context.all_phase_names.each do |phase| cphase = phase.colorize(:light_magenta).to_s Baguette::Log.info "Building ('#{cphase}')" ret = if instructions = @context.user_instructions[phase]? instructions.run building_directory else # Some phases have available backends. case phase when "configure" cphase = "configure".colorize(:light_blue).to_s Baguette::Log.info "Executing phase :: #{cphase}" execute_backend @context.configure_backends when "build" cphase = "build".colorize(:light_blue).to_s Baguette::Log.info "Executing phase :: #{cphase}" execute_backend @context.building_backends when "install" cphase = "install".colorize(:light_blue).to_s Baguette::Log.info "Executing phase :: #{cphase}" execute_backend @context.install_backends else # Not available backend and no user instructions. BuildStatus::Pass end end if ret.failed? raise BuildError.new self, "Building ('#{phase}') failed." end end Do.cd old_dir ENV["PKG"] = nil if Dir.children(fake_root_directory).size == 0 raise BuildError.new self, "No file was installed in the fake root." end do_strip if @require_stripping do_splits end private def do_strip Baguette::Log.info "Stripping binaries" FileUtils.find_files(fake_root_directory) do |path| file_output = `file #{path}` strip_opt = nil : String? if file_output.match /ELF.*executable.*not stripped/ strip_opt = "--strip-all" elsif file_output.match /ELF.*shared object.*not stripped/ strip_opt = "--strip-unneeded" elsif file_output.match /current ar archive/ strip_opt = "--strip-debug" end if strip_opt Baguette::Log.detail "stripping #{path}" Process.run "strip", [strip_opt, path] end end end private def do_splits (@packages + auto_splits).each do |package| next if package == @packages[0] files = package.files || [] of String file_patterns = package.file_patterns || [] of Regex files_to_split = [] of String files.each do |file| origin = "#{fake_root_directory}#{file}" if File.exists? origin files_to_split << file end end FileUtils.find fake_root_directory do |file| file = file[fake_root_directory.size..file.size] files_to_split << file if file_patterns.any? &.match file end if files_to_split.size > 0 Baguette::Log.info "Splitting " + "'#{package.name}'".colorize(:light_red).underline.to_s end # FIXME: What do we do if those are not on the filesystem? files_to_split.each do |file| origin = "#{fake_root_directory}#{file}" destination = "#{package.fake_root_directory}#{file}" Baguette::Log.detail "Moving '#{file}' to split" Do.mkdir_p File.dirname destination Do.mv( "#{fake_root_directory}#{file}", "#{package.fake_root_directory}#{file}" ) end end end def auto_splits : Array(Package) @context.splitter_backends.compact_map do |name, backend| csplit = name.colorize(:light_green).to_s Baguette::Log.info "Creating split for #{csplit}" backend.create_split self end end # TODO: # - Errors management. Stop at first failure? # - Splits. This should be done between #build and #package. def package # This tries to build them all and stops at the first failure # (failures are currently reported by Context#package) (auto_splits + @packages).each do |package| if package.automatic && ! File.exists? package.fake_root_directory next end Baguette::Log.info "Assembling " + "'#{package.name}'".colorize(:light_red).underline.to_s unless @context.package package raise PackagingError.new self, package end end end def clean FileUtils.rm_rf working_directory end def watch if script = @watch_script status, output = Do.captured_sh script output = output.to_s .gsub(/^[ \t\n]*/, "").gsub(/[ \t\n]*$/, "") if output != @version STDERR.puts "WARNING: '#{@name}' is old! (#{output} / #{@version})" else puts "'#{@name}' is up to date" end return end STDERR.puts "WARNING: '#{@name}' has no watch." end def to_s "#{name}-#{version}" end def to_spec_s spec = [ "name: #{name}", "version: #{version}", "release: #{release}" ].join("\n") + "\n" if @sources.size == 1 spec += "sources: #{sources[0].to_s.gsub @version, "%{version}"}\n" end if maintainer spec += "maintainer: #{maintainer}\n" end if packager spec += "packager: #{packager}\n" end if @options.size > 0 spec += "options:\n" @options.each do |key, value| value = value.gsub fake_root_directory, "%{pkg}" spec += "\t- #{key}: #{value}\n" end spec += "\n" # Limitation of the current implementation. end if @dependencies.size > 0 spec += "dependencies:\n" @dependencies.each do |dep| spec += "\t- #{dep}\n" end spec += "\n" end if @dirname spec += "dirname: #{@dirname}\n" end @context.all_phase_names.each do |phase| if instructions = @context.user_instructions[phase] next unless instructions.size > 0 spec += "@#{phase}\n" instructions.each do |line| line = line.gsub /^\t* *\n/, "" line = line.gsub /\n *\t*\n/, "\n" line = line.gsub /\n *\t*$/, "" line = line.gsub fake_root_directory, "%{pkg}" line = line.gsub name, "%{name}" line = line.gsub version, "%{version}" line = line.gsub dirname, "%{dirname}" line = line.gsub(/^[\t ]*/, "").gsub(/\n\t*/, "\n\t") spec += "\t#{line}\n" end spec += "\n" # Limitation of the current implementation. end end spec end end