# # Copyright 2012-2014 Chef Software, Inc. # Copyright 2014 Noah Kantrowitz # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # require "time" require "ffi_yajl" require "omnibus/manifest" require "omnibus/manifest_entry" require "omnibus/reports" module Omnibus class Project class << self # # @param [String] name # the name to the project definition to load from disk # # @return [Project] # def load(name, manifest = nil) loaded_projects[name] ||= begin filepath = Omnibus.project_path(name) if filepath.nil? raise MissingProject.new(name) else log.internal(log_key) do "Loading project `#{name}' from `#{filepath}'." end end instance = new(filepath, manifest) instance.evaluate_file(filepath) instance.load_dependencies instance end end # # Reset cached project information. # def reset! @loaded_projects = nil end private # # The list of projects loaded thus far. # # @return [Hash] # def loaded_projects @loaded_projects ||= {} end end attr_reader :manifest include Cleanroom include Digestable include Logging include NullArgumentable include Sugarable include Util def initialize(filepath = nil, manifest = nil) @filepath = filepath @manifest = manifest end # # @!group DSL methods # # The following DSL methods are available from within the project # definitions. # -------------------------------------------------- # # **[Required]** Set or retrieve the name of the project. # # @example # name 'chef' # # @raise [MissingRequiredAttribute] if a value was not set before being # subsequently retrieved # # @param [String] val # the name to set # # @return [String] # def name(val = NULL) if null?(val) @name || raise(MissingRequiredAttribute.new(self, :name, "hamlet")) else @name = val end end expose :name # # Set or retrieve a friendly name for the project. This defaults to the # capitalized name if not specified. # # @example # friendly_name 'Chef' # # @param [String] val # the name to set # # @return [String] # def friendly_name(val = NULL) if null?(val) @friendly_name || name.capitalize else @friendly_name = val end end expose :friendly_name # # Set or retrieve the package name of the project. Defaults to the package # name defaults to the project name. # # @example # package_name 'com.chef.project' # # @param [String] val # the package name to set # # @return [String] # def package_name(val = NULL) if null?(val) @package_name || name else @package_name = val end end expose :package_name # # **[Required]** Set or retrieve the path at which the project should be # installed by the generated package. # # Even on Windows-based systems, this path should be the Unix-like path, # since that's what Ruby expects. In the event +\+ is used as a file # separator, it will be replaced with +/+. This method also attempts to # remove duplicate slashes which might be caused as a result of string # interpolation. # # @example # install_dir '/opt/chef' # # @raise [MissingRequiredAttribute] if a value was not set before being # subsequently retrieved # # @param [String] val # the install path to set # # @return [String] # def install_dir(val = NULL) if null?(val) @install_dir || raise(MissingRequiredAttribute.new(self, :install_dir, "/opt/chef")) else @install_dir = val.tr('\\', "/").squeeze("/").chomp("/") end end expose :install_dir # # The default root where a project should be installed. On Windows-based # systems, this value defaults to +C:+. On non-Windows systems, this value # defaults to +/opt+. # # @example # install_dir "#{default_root}/chef" #=> Produces +C:/chef+ on Windows and # #=> +/opt/chef+ on Linux # # @return [String] # def default_root if windows? "C:" else "/opt" end end expose :default_root # # Path to the +/files+ directory in the omnibus project. This directory can # contain arbitrary files used by the project. # # @example # patch = File.join(files_path, 'rubygems', 'patch.rb') # # @return [String] # path to the files directory # def files_path File.expand_path("#{Config.project_root}/files") end expose :files_path # # **[Required]** Set or retrieve the the package maintainer. # # @example # maintainer 'Chef Software, Inc.' # # @raise [MissingRequiredAttribute] if a value was not set before being # subsequently retrieved # # @param [String] val # the name of the maintainer # # @return [String] # def maintainer(val = NULL) if null?(val) @maintainer || raise(MissingRequiredAttribute.new(self, :maintainer, "Chef Software, Inc.")) else @maintainer = val end end expose :maintainer # # **[Required]** Set or retrive the package homepage. # # @example # homepage 'https://www.getchef.com' # # @raise [MissingRequiredAttribute] if a value was not set before being # subsequently retrieved # # @param [String] val # the homepage for the project # # @return [String] # def homepage(val = NULL) if null?(val) @homepage || raise(MissingRequiredAttribute.new(self, :homepage, "https://www.getchef.com")) else @homepage = val end end expose :homepage # # Set or retrieve the project description. # # @example # description 'This is my description' # # @param [String] val # the project description # # @return [String] # def description(val = NULL) if null?(val) @description || "The full stack of #{name}" else @description = val end end expose :description # # Add to the list of packages this one replaces. # # This should only be used when renaming a package and obsoleting the old # name of the package. **Setting this to the same name as package_name will # cause RPM upgrades to fail.** # # @example # replace 'the-old-package' # # @param [String] val # the name of the package to replace # # @return [String] # def replace(val = NULL) replaces << val replaces.dup end expose :replace # # Add to the list of packages this one conflicts with. # # @example # conflicts 'foo' # conflicts 'bar' # # @param [String] val # the conflict to add # # @return [Array] # the list of conflicts # def conflict(val) conflicts << val conflicts.dup end expose :conflict # # Set or retrieve the version of the project. # # @example Using a string # build_version '1.0.0' # # @example From git # build_version do # source :git # end # # @example From the version of a dependency # build_version do # source :version, from_dependency: 'chef' # end # # @example From git of a dependency # build_version do # source :git, from_dependency: 'chef' # end # # When using the +:git+ source, by default the output format of the # +build_version+ is semver. This can be modified using the +:output_format+ # parameter to any of the methods of +BuildVersion+. For example: # # build version do # source :git, from_dependency: 'chef' # output_format :git_describe # end # # @see Omnibus::BuildVersion # @see Omnibus::BuildVersionDSL # # @param [String] val # the build version to set # @param [Proc] block # the block to run when constructing the +build_version+ # # @return [String] # def build_version(val = NULL, &block) if block && !null?(val) raise Error, "You cannot specify additional parameters to " \ "#build_version when a block is given!" end if block @build_version_dsl = BuildVersionDSL.new(&block) else if null?(val) @build_version_dsl.build_version else @build_version_dsl = BuildVersionDSL.new(val) end end end expose :build_version # # Set or retrieve the git revision of the omnibus # project being built. # # If not set by the user, and the current workding directory is a # git directory, it will return the revision of the current # working directory. # def build_git_revision(val = NULL) if null?(val) @build_git_revision ||= get_local_revision else @build_git_revision = val end end expose :build_git_revision # # Set or retrieve the build iteration of the project. Defaults to +1+ if not # otherwise set. # # @example # build_iteration 5 # # @param [Fixnum] val # the build iteration number # # @return [Fixnum] # def build_iteration(val = NULL) if null?(val) @build_iteration || 1 else @build_iteration = val end end expose :build_iteration # # Add or override a customization for the packager with the given +id+. When # given multiple blocks with the same +id+, they are evaluated _in order_, # so the last block evaluated will take precedence over the previous ones. # # @example # package :id do # key 'value' # end # # @param [Symbol] id # the id of the packager to customize # def package(id, &block) unless block raise InvalidValue.new(:package, "have a block") end packagers[id] << block end expose :package # # Add or override a customization for the compressor with the given +id+. # When given multiple blocks with the same +id+, they are evaluated # _in order_, so the last block evaluated will take precedence over the # previous ones. # # @example With customization # compress :dmg do # window_bounds '10, 20, 30, 40' # end # # @example Without customization # compress :tgz # # If multiple +compress+ blocks are specified, the "most prefered" one for # the current system will be used. # # @param [Symbol] id # the id of the compressor to customize # def compress(id, &block) if block compressors[id] << block else compressors[id] << Proc.new {} end end expose :compress # # Set or retrieve the user the package should install as. This varies with # operating system, and may be ignored if the underlying packager does not # support it. # # Defaults to +"root"+. # # @example # package_user 'build' # # @param [String] val # the user to use for the package build # # @return [String] # def package_user(val = NULL) if null?(val) @package_user || "root" else @package_user = val end end expose :package_user # # Set or retrieve the overrides hash for one piece of software being # overridden. Calling it as a setter does not merge hash entries and it will # set all the overrides for a given software definition. # # @example # override 'chef', version: '1.2.3' # # @param [Hash] val # the value to override # # @return [Hash] # def override(name, val = NULL) if null?(val) overrides[name.to_sym] else overrides[name.to_sym] = val end end expose :override # # Set or retrieve the group the package should install as. This varies with # operating system and may be ignored if the underlying packager does not # support it. # # Defaults to +Ohai['root_group']+. If +Ohai['root_group']+ is +nil+, it # defaults to +"root"+. # # @example # package_group 'build' # # @param [String] val # the group to use for the package build # # @return [String] # def package_group(val = NULL) if null?(val) @package_group || Ohai["root_group"] || "root" else @package_group = val end end expose :package_group # # Set or retrieve the path to the resources on disk for use in packagers. # # @example # resources_path '/path/to/resources' # # @param [String] val # the path where resources live # # @return [String] # def resources_path(val = NULL) if null?(val) @resources_path || "#{Config.project_root}/resources/#{name}" else @resources_path = File.expand_path(val) end end expose :resources_path # # The path to the package scripts directory for this project. These are # optional scripts that can be bundled into the resulting package for # running at various points in the package management lifecycle. # # These scripts and their names vary with operating system. # # @return [String] # def package_scripts_path(arg = NULL) if null?(arg) @package_scripts_path || "#{Config.project_root}/package-scripts/#{name}" else @package_scripts_path = File.expand_path(arg) end end expose :package_scripts_path # # Add a software dependency. # # Note that this is a *build time* dependency. If you need to specify an # external dependency that is required at runtime, see {#runtime_dependency} # instead. # # @example # dependency 'foo' # dependency 'bar' # # @param [String] val # the name of a Software dependency # # @return [Array] # the list of dependencies # def dependency(val) dependencies << val dependencies.dup end expose :dependency # # Add a package that is a runtime dependency of this project. # # This is distinct from a build-time dependency, which should correspond to # a software definition. # # @example # runtime_dependency 'foo' # # @param [String] val # the name of the runtime dependency # # @return [Array] # the list of runtime dependencies # def runtime_dependency(val) runtime_dependencies << val runtime_dependencies.dup end expose :runtime_dependency # # Add a new exclusion pattern for a list of files or folders to exclude # when making the package. # # @example # exclude '.git' # # @param [String] pattern # the thing to exclude # # @return [Array] # the list of current exclusions # def exclude(pattern) exclusions << pattern exclusions.dup end expose :exclude # # Add a config file. # # @example # config_file '/path/to/config.rb' # # @param [String] val # the name of a config file of your software # # @return [Array] # the list of current config files # def config_file(val) config_files << val config_files.dup end expose :config_file # # Add other files or dirs outside of +install_dir+. These files retain their # relative paths inside the scratch directory: # # /path/to/foo.txt #=> /tmp/package/path/to/foo.txt # # # @example # extra_package_file '/path/to/file' # # @param [String] val # the name of a dir or file to include in build # # @return [Array] # the list of current extra package files # def extra_package_file(val) extra_package_files << val extra_package_files.dup end expose :extra_package_file # # A proxy method to the underlying Ohai system. # # @example # ohai['platform_family'] # # @return [Ohai] # def ohai Ohai end expose :ohai # # Set or retrieve the {#license} of the project. # # @example # license 'Apache 2.0' # # @param [String] val # the license to set for the project. # # @return [String] # def license(val = NULL) if null?(val) @license || "Unspecified" else @license = val end end expose :license # # Set or retrieve the location of the {#license_file} # of the project. It can either be a relative path inside # the project source directory or a URL. # # # @example # license_file 'LICENSES/artistic.txt' # # @param [String] val # the location of the license file for the project. # # @return [String] # def license_file(val = NULL) if null?(val) @license_file else @license_file = val end end expose :license_file # # Location of license file that omnibus will create and that will contain # the information about the license of the project plus the details about # the licenses of the software components included in the project. # # If no path is specified install_dir/LICENSE is used. # # @example # license_file_path # # @return [String] # def license_file_path(path = NULL) if null?(path) @license_file_path || File.join(install_dir, "LICENSE") else @license_file_path = File.join(install_dir, path) end end expose :license_file_path # # Location of json-formated version manifest, written at at the # end of the build. If no path is specified # +install_dir+/version-manifest.json is used. # # @example # json_manifest_path # # @return [String] # def json_manifest_path(path = NULL) if null?(path) @json_manifest_path || File.join(install_dir, "version-manifest.json") else @json_manifest_path = path end end expose :json_manifest_path # # Location of text-formatted manifest. # (+install_dir+/version-manifest.txt if none is provided) # # This manifest uses the same format used by the # 'version-manifest' software definition in omnibus-software. # # @example # text_manifest_path # # @return [String] # def text_manifest_path(path = NULL) if null?(path) @test_manifest_path || File.join(install_dir, "version-manifest.txt") else @text_manifest_path = path end end expose :text_manifest_path # # @!endgroup # -------------------------------------------------- # # @!group Public API # # In addition to the DSL methods, the following methods are considered to # be the "public API" for a project. # -------------------------------------------------- # # Recursively load all the dependencies for this project. # # @return [true] # def load_dependencies dependencies.each do |dependency| Software.load(self, dependency, manifest) end true end # # The list of software dependencies for this project. These is the software # that comprises your project, and is distinct from runtime dependencies. # # @see #dependency # # @param [Array] # # @return [Array] # def dependencies @dependencies ||= [] end # # The path (on disk) where this project came from. Warning: this can be # +nil+ if a project was dynamically created! # # @return [String, nil] # def filepath @filepath end # # # The list of config files for this software. # # @return [Array] # def config_files @config_files ||= [] end # # The list of files and directories used to build this project. # # @return [Array] # def extra_package_files(val = NULL) @extra_package_files ||= [] end # # The list of software dependencies for this project. # # These is the software that is used at runtime for your project. # # @return [Array] # def runtime_dependencies @runtime_dependencies ||= [] end # # The list of things this project conflicts with. # # @return [Array] # def conflicts @conflicts ||= [] end # # The list of things this project replaces with. # # @return [Array] # def replaces @replaces ||= [] end # # The list of exclusions for this project. # # @return [Array] # def exclusions @exclusions ||= [] end # # Retrieve the list of overrides for all software being overridden. # # @return [Hash] # def overrides @overrides ||= {} end # # The list of packagers, in the following format: # # { # id: [#, #], # # ... # } # # @return [Hash>] # the packager blocks, indexed by key # def packagers @packagers ||= Hash.new { |h, k| h[k] = [] } end # # Instantiate new instances of the best packagers for this system. # # @return [[~Packager::Base]] # def packagers_for_system @packagers_for_system ||= Packager.for_current_system.map { |p| p.new(self) } end # # The list of compressors, in the following format: # # { # id: [#, #], # # ... # } # # @return [Hash>] # the compressor blocks, indexed by key # def compressors @compressors ||= Hash.new { |h, k| h[k] = [] } end # # Instantiate a new instance of the best compressor for this system. # # @return [~Compressor::Base] # def compressor @compressor ||= Compressor.for_current_system(compressors.keys).new(self) end # # The DSL for this build version. # # @return [BuildVersionDSL] # def build_version_dsl @build_version_dsl end # # Indicates whether the given +software+ is defined as a software component # of this project. # # @param [String, Software] software # the software or name of the software to find # # @return [true, false] # def dependency?(software) name = software.is_a?(Software) ? software.name : software dependencies.include?(name) end # # The library for this Omnibus project. # # @return [Library] # def library @library ||= Library.new(self) end # # Dirty the cache for this project. This can be called by other projects, # install path cache, or software definitions to invalidate the cache for # this project. # # @param [Software] software # the software that dirtied the cache # # @return [true, false] # def dirty!(software) raise ProjectAlreadyDirty.new(self) if culprit @culprit = software end # # The software definition which dirtied this project. # # @return [Software, nil] # def culprit @culprit end # # Determine if the cache for this project is dirty. # # @return [true, false] # def dirty? !!culprit end # # Comparator for two projects (+name+) # # @return [1, 0, -1] # def <=>(other) name <=> other.name end # # Cache the build order so we don't re-compute # # @return [Array] # def softwares @softwares ||= library.build_order end # # Generate a version manifest of the loaded software sources. # # @returns [Omnibus::Manifest] # def built_manifest log.info(log_key) { "Building version manifest" } m = Omnibus::Manifest.new(build_version, build_git_revision, license) softwares.each do |software| m.add(software.name, software.manifest_entry) end m end def download ThreadPool.new(Config.workers) do |pool| softwares.each do |software| pool.schedule { software.fetch } end end end def build FileUtils.rm_rf(install_dir) FileUtils.mkdir_p(install_dir) Licensing.create_incrementally(self) do |license_collector| softwares.each do |software| software.build_me([license_collector]) end # If nothing has dirtied the cache, checkout the last cache dir restore_complete_build unless dirty? end write_json_manifest write_text_manifest HealthCheck.run!(self) package_me compress_me end def restore_complete_build if Config.use_git_caching log.info(log_key) { "Cache not dirtied, restoring last marker" } GitCache.new(softwares.last).restore_from_cache end end def write_json_manifest File.open(json_manifest_path, "w") do |f| f.write(FFI_Yajl::Encoder.encode(built_manifest.to_hash, pretty: true)) end end # # Writes a text manifest to the text_manifest_path. This uses the # same method as the "version-manifest" software definition in # omnibus-software. # def write_text_manifest File.open(text_manifest_path, "w") do |f| f.puts "#{name} #{build_version}" f.puts "" f.puts Omnibus::Reports.pretty_version_map(self) end end # # Download and build the project. Preserved for backwards # compatibility. # def build_me # Download all softwares in parallel download build end # # # def package_me destination = File.expand_path("pkg", Config.project_root) # Create the destination directory unless File.directory?(destination) FileUtils.mkdir_p(destination) end packagers_for_system.each do |packager| # Evaluate any packager-specific blocks, in order. packagers[packager.id].each do |block| packager.evaluate(&block) end # Run the actual packager packager.run! # Copy the generated package and metadata back into the workspace package_path = File.join(Config.package_dir, packager.package_name) FileUtils.cp(package_path, destination, preserve: true) FileUtils.cp("#{package_path}.metadata.json", destination, preserve: true) end end # # # def compress_me destination = File.expand_path("pkg", Config.project_root) # Create the destination directory unless File.directory?(destination) FileUtils.mkdir_p(destination) end # Evaluate any compressor-specific blocks, in order. compressors[compressor.id].each do |block| compressor.evaluate(&block) end # Run the actual compressor compressor.run! # Copy the compressed package and metadata back into the workspace, if it # exists package_path = File.join(Config.package_dir, compressor.package_name) if File.file?(package_path) FileUtils.cp(package_path, destination) FileUtils.cp("#{package_path}.metadata.json", destination) end end # # The unique "hash" for this project. # # @see (#shasum) # # @return [Fixnum] # def hash shasum.hash end # # Determine if two projects are identical. # # @param [Project] other # # @return [true, false] # def ==(other) hash == other.hash end alias_method :eql?, :== # # The unique SHA256 for this project. # # A project is defined by its name, its build_version, its install_dir, # and any overrides (as JSON). Additionally, if provided, the actual file # contents are included in the SHA to ensure uniqueness. # # @return [String] # def shasum @shasum ||= begin digest = Digest::SHA256.new update_with_string(digest, name) update_with_string(digest, install_dir) update_with_string(digest, FFI_Yajl::Encoder.encode(overrides)) if filepath && File.exist?(filepath) update_with_file_contents(digest, filepath) else update_with_string(digest, "") end digest.hexdigest end end # # @!endgroup # -------------------------------------------------- private def get_local_revision GitRepository.new("./").revision rescue StandardError "unknown" end # # The log key for this project, overriden to include the name of the # project for build output. # # @return [String] # def log_key @log_key ||= "#{super}: #{name}" end end end