class Kontena::Cli::Stacks::YAML::Reader
Attributes
Public Class Methods
@param stack_origin [String] a filename, pointer to registry or an URL @return [Reader]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 36 def initialize(file) if file.kind_of?(StackFileLoader) @file = file.source @loader = file else @file = file @loader = StackFileLoader.for(file) end @errors = [] @notifications = [] end
Public Instance Methods
Creates a set of variables using the 'depends' section. The variable name is the name of the dependency and the variable value is the generated child stack name. For example,.have something like: depends:
redis: stack: foo/redis
you will get a new variable called “redis” and its value will be “this-stack-name-redis”. This variable can be used to interpolate for example a hostname to some environment variable: environment:
- "REDIS_HOST=redis.${REDIS}"
# File lib/kontena/cli/stacks/yaml/reader.rb, line 167 def create_dependency_variables(dependencies, name) return if dependencies.nil? dependencies.each do |options| variables.build_option(name: options['name'].to_s, type: :string, value: "#{name}-#{options['name']}") create_dependency_variables(options['depends'], "#{name}.#{options['name']}") end end
If this stack is a part of a dependency chain and has a parent, the variable $PARENT_STACK will interpolate to the name of the parent stack.
# File lib/kontena/cli/stacks/yaml/reader.rb, line 177 def create_parent_variable(parent_name) variables.build_option(name: 'PARENT_STACK', type: :string, value: parent_name) end
Values that are set always when parsing stacks @return [Hash] a hash of key value pairs
# File lib/kontena/cli/stacks/yaml/reader.rb, line 65 def default_envs { 'GRID' => env['GRID'], 'STACK' => env['STACK'], 'PLATFORM' => env['PLATFORM'] || env['GRID'] } end
Creates an opto option definition compatible hash from the default_envs
hash @return [Hash]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 125 def default_envs_to_options default_envs.each_with_object({}) { |env, obj| obj[env[0]] = { type: :string, value: env[1] } } end
Returns an array of hashes containing the dependency tree starting from this file @return [Array<Hash>]]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 234 def dependencies @dependencies ||= loader.dependencies end
@param [String] service_name (set when using extends) @param name [String] override stackname (default is to parse it from the YAML
, but if you set it through -n it needs to be overriden) @param parent_name [String] parent stack name @param skip_validation [Boolean] skip running validations @param values [Hash] force-set variable values using variable_name => variable_value key pairs @param defaults [Hash] set variable defaults from variable_name => variable_value key pairs @return [Hash]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 193 def execute(service_name = nil, name: loader.stack_name.stack, parent_name: nil, skip_validation: false, values: nil, defaults: nil) set_variable_defaults(defaults) if defaults set_variable_values(values) if values create_dependency_variables(dependencies, name) create_parent_variable(parent_name) if parent_name variables.run raise RuntimeError, "Variable validation failed: #{variables.errors.inspect} in #{file}" unless variables.valid? || skip_validation validate unless skip_validation result = {} Dir.chdir(from_file? ? File.dirname(File.expand_path(file)) : Dir.pwd) do result['stack'] = raw_yaml['stack'] result['version'] = loader.stack_name.version || '0.0.1' result['name'] = name result['registry'] = loader.registry result['expose'] = fully_interpolated_yaml['expose'] result['services'] = errors.empty? ? parse_services(service_name) : {} result['volumes'] = errors.empty? ? parse_volumes : {} result['dependencies'] = dependencies result['source'] = raw_content result['variables'] = variable_values(without_defaults: true, without_vault: true) result['metadata'] = raw_yaml['meta'] || {} end if parent_name result['parent'] = { 'name' => parent_name } else result['parent'] = nil end if service_name.nil? result['services'].each do |service| errors << { 'services' => { service['name'] => { 'image' => "image is missing" } } } if service['image'].to_s.empty? end end result end
# File lib/kontena/cli/stacks/yaml/reader.rb, line 358 def from_external_stack(name, service_name) external_reader = StackFileLoader.for(name, loader).reader variables.to_a(with_value: true).each do |var| external_reader.variables.build_option(var) end outcome = external_reader.execute(service_name) errors.concat external_reader.errors unless external_reader.errors.empty? || errors.include?(external_reader.errors) notifications.concat external_reader.notifications unless external_reader.notifications.empty? || notifications.include?(external_reader.notifications) outcome['services'] end
@return [Boolean] did this stack come from a local file?
# File lib/kontena/cli/stacks/yaml/reader.rb, line 182 def from_file? loader.origin == 'file' end
Uses variable interpolation, prompts as needed, liquid interpolation
@return [Hash] the most commplete stack parsing outcome
# File lib/kontena/cli/stacks/yaml/reader.rb, line 95 def fully_interpolated_yaml return @fully_interpolated_yaml if @fully_interpolated_yaml @fully_interpolated_yaml = ::YAML.safe_load( replace_dollar_dollars( interpolate( interpolate_liquid( raw_content, variable_values ), use_opto: true, raise_on_unknown: true ) ), [], [], true, file ) rescue Psych::SyntaxError => ex raise ex, "Error while parsing #{file} : #{ex.message}" end
Only uses the values from default_envs
to provide a hash from minimally interpolated YAML
file. Useful for accessing some parts of the YAML
without asking any questions.
@return [Hash] minimally interpolated YAMl from the stack file.
# File lib/kontena/cli/stacks/yaml/reader.rb, line 77 def internals_interpolated_yaml @internals_interpolated_yaml ||= ::YAML.safe_load( replace_dollar_dollars( interpolate( raw_content, use_opto: false, substitutions: default_envs, warnings: false ) ), [], [], true, file ) rescue Psych::SyntaxError => ex raise ex, "Error while parsing #{file} : #{ex.message}" end
Interpolate any Liquid templating in the YAML
content @param content [String] file content @param vars [Hash] key-value pairs @return [String] @raise [Liquid::Error]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 243 def interpolate_liquid(content, vars) Liquid::Template.error_mode = :strict template = Liquid::Template.parse(content) # Wrap nil values in LiquidNull to not have Liquid consider them as undefined vars = vars.map {|key, value| [key, value.nil? ? LiquidNull.new : value]}.to_h template.render!(vars, strict_variables: true, strict_filters: true) end
@param [String] service_name - optional service to parse @return [Hash]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 281 def parse_services(service_name = nil) services = self.services.dup # do not modify the fully_interpolated_yaml['services'] hash in-place if service_name.nil? services.each do |name, config| services[name] = process_config(config, name) if process_hash?(config) services[name].delete('only_if') services[name].delete('skip_if') else services.delete(name) end end services.map { |name, svc| svc.merge('name' => name) } else raise ("Service '#{service_name}' not found in #{file}") unless services.key?(service_name) process_config(services[service_name], service_name) end end
# File lib/kontena/cli/stacks/yaml/reader.rb, line 265 def parse_volumes volumes.each do |name, config| if process_hash?(config) volumes[name].delete('only_if') volumes[name].delete('skip_if') volumes[name] = process_volume(name, config) else volumes.delete(name) end end volumes.map { |name, vol| vol.merge('name' => name) } end
@param [Hash] service_config
# File lib/kontena/cli/stacks/yaml/reader.rb, line 322 def process_config(service_config, name=nil) normalize_env_vars(service_config) merge_env_vars(service_config) expand_build_context(service_config) normalize_build_args(service_config) if service_config.key?('extends') service_config = extend_config(service_config) service_config.delete('extends') end if name ServiceGeneratorV2.new(service_config).generate.merge('name' => name) else ServiceGeneratorV2.new(service_config).generate end end
If the supplied hash contains skip_if/only_if conditionals, process that conditional and return true/false
@param [Hash] @return [Boolean]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 304 def process_hash?(hash) return true unless hash['skip_if'] || hash['only_if'] skip_lambdas = normalize_ifs(hash['skip_if']) only_lambdas = normalize_ifs(hash['only_if']) if skip_lambdas return false if skip_lambdas.any? { |s| s.call } end if only_lambdas return false unless only_lambdas.all? { |s| s.call } end true end
# File lib/kontena/cli/stacks/yaml/reader.rb, line 338 def process_volume(name, volume_config) return [] if volume_config.nil? || volume_config.empty? if volume_config['external'].is_a?(TrueClass) volume_config['external'] = name elsif volume_config['external']['name'] volume_config['external'] = volume_config['external']['name'] end volume_config['name'] = name volume_config end
The YAML
file raw content
# File lib/kontena/cli/stacks/yaml/reader.rb, line 114 def raw_content loader.content end
@return [Hash] with zero interpolation/processing. Will mostly fail
# File lib/kontena/cli/stacks/yaml/reader.rb, line 119 def raw_yaml loader.yaml end
@return [Hash] - services from YAML
file
# File lib/kontena/cli/stacks/yaml/reader.rb, line 354 def services @services ||= fully_interpolated_yaml.fetch('services', {}) end
Accepts a hash of variable_name => variable_value pairs and sets the values as variable default values Used when previous answers are read from master and passed as default values for upgrade. @param defaults [Hash] { 'variable_name' => 'variable_value' }
# File lib/kontena/cli/stacks/yaml/reader.rb, line 141 def set_variable_defaults(defaults) defaults.each do |key, val| var = variables.option(key.to_s) var.default = val if var end end
Set values from a hash to values of the variables. Used when variable values are read from a file or command line parameters or dependency variable injection @param [Hash] a hash of variable_name => variable_value pairs
# File lib/kontena/cli/stacks/yaml/reader.rb, line 151 def set_variable_values(values) values.each do |key, val| var = variables.option(key.to_s) var.set(val) if var end end
@return [Array<Hash>] array of validation errors
# File lib/kontena/cli/stacks/yaml/reader.rb, line 254 def validate result = validator.validate(fully_interpolated_yaml) store_failures(result) result end
@return [Kontena::Cli::Stacks::YAML::ValidatorV3]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 261 def validator @validator ||= YAML::ValidatorV3.new end
@param without_defaults [TrueClass,FalseClass] strip the GRID, STACK, etc from response @param without_vault [TrueClass,FalseClass] strip out any values that are going to or coming from VAULT @return [Hash] a hash of key value pairs representing the values of stack variables
# File lib/kontena/cli/stacks/yaml/reader.rb, line 52 def variable_values(without_defaults: false, without_vault: false, with_errors: false) result = variables.to_h(values_only: true, with_errors: with_errors) if without_defaults result.delete_if { |k, _| default_envs.key?(k.to_s) || k.to_s == 'PARENT_STACK' } end if without_vault result.delete_if { |k, _| variables.option(k).from.include?('vault') || variables.option(k).to.include?('vault') } end result end
Accessor to the Opto
variable handler @return [Opto::Group]
# File lib/kontena/cli/stacks/yaml/reader.rb, line 131 def variables @variables ||= ::Opto::Group.new( internals_interpolated_yaml.fetch('variables', {}).merge(default_envs_to_options), defaults: { from: :env } ) end
# File lib/kontena/cli/stacks/yaml/reader.rb, line 349 def volumes @volumes ||= fully_interpolated_yaml.fetch('volumes', {}) end
Private Instance Methods
# File lib/kontena/cli/stacks/yaml/reader.rb, line 513 def env ENV end
# File lib/kontena/cli/stacks/yaml/reader.rb, line 494 def expand_build_context(options) if options['build'].kind_of?(String) options['build'] = File.expand_path(options['build']) elsif context = safe_dig(options, 'build', 'context') options['build']['context'] = File.expand_path(context) end end
@param [Hash] service_config @return [Hash] updated service config
# File lib/kontena/cli/stacks/yaml/reader.rb, line 444 def extend_config(service_config) extends = service_config['extends'] case extends when NilClass return when String raise ("Service '#{extends}' not found in #{file}") unless services.key?(extends) parent_config = process_config(services[extends]) when Hash target = extends['file'] || extends['stack'] raise ("Service '#{extends}' does not define file: or stack: source") if target.nil? parent_config = from_external_stack(target, extends['service']) else raise TypeError, "Extends must be a hash or string" end ServiceExtender.new(service_config).extend_from(parent_config) end
@param [String] content - content of YAML
file
# File lib/kontena/cli/stacks/yaml/reader.rb, line 373 def interpolate(content, use_opto: true, substitutions: {}, raise_on_unknown: false, warnings: true) content.split(/[\r\n]/).map.with_index do |row, line_num| # skip lines that opto may be interpolating if row.strip.start_with?('interpolate:') || row.strip.start_with?('evaluate:') row else row.gsub(/(?<!\$)\$(?!\$)\{?\w+\}?/) do |v| # searches $VAR and ${VAR} and not $$VAR var = v.tr('${}', '') if use_opto opt = variables.option(var) if opt.nil? to_env = variables.find { |opt| Array(opt.to[:env]).include?(var) } if to_env val = to_env.value else raise RuntimeError, "Undeclared variable '#{var}' in #{file}:#{line_num} -- #{row}" if raise_on_unknown end else val = opt.value end else val = substitutions[var] end if val && !val.to_s.empty? val.to_s =~ /[\r\n\"\'\|]/ ? val.inspect : val.to_s else puts "Value for #{var} is not set. Substituting with an empty string." if warnings '' end end end end.join("\n") end
@param [Hash] options
# File lib/kontena/cli/stacks/yaml/reader.rb, line 477 def merge_env_vars(options) return options['environment'] unless options['env_file'] options['env_file'] = [options['env_file']] if options['env_file'].kind_of?(String) options['environment'] = [] unless options['environment'] options['env_file'].each do |env_file| options['environment'].concat(read_env_file(env_file)) end options.delete('env_file') options['environment'].uniq! { |s| s.split('=').first } end
@param [Hash] options - service config
# File lib/kontena/cli/stacks/yaml/reader.rb, line 503 def normalize_build_args(options) build = options['build'] return unless build.kind_of?(Hash) args = build['args'] return unless args return unless args.kind_of?(Array) build.delete('args') build['args'] = args.map { |arg| arg.split('=', 2) }.to_h end
@param [Hash] options - service config
# File lib/kontena/cli/stacks/yaml/reader.rb, line 470 def normalize_env_vars(options) if options['environment'].kind_of?(Hash) options['environment'] = options['environment'].map { |k, v| "#{k}=#{v}" } end end
Generates an array of lambdas that return true if a condition is true Possible syntaxes: @example
normalize_ifs( 'wp' ) # lambdas return true if variable wp is not null or false or 'false' normalize_ifs( wp: 1 ) # lambdas return true if value of wp is 1 normalize_ifs( ['wp, :ws'] ) # lambdas return true if wp and ws are not not null or false or 'false' normalize_ifs( wp: 1, ws: 1) # lambdas return true if wp and ws are 1 normalize_ifs(nil) # returns nil
# File lib/kontena/cli/stacks/yaml/reader.rb, line 423 def normalize_ifs(ifs) case ifs when NilClass nil when Array ifs.map do |iff| lambda { val = variables.value_of(iff.to_s); !val.nil? && !val.kind_of?(FalseClass) && val != 'false' } end when Hash ifs.each_with_object([]) do |(k, v), arr| arr << lambda { variables.value_of(k.to_s) == v } end when String, Symbol [lambda { val = variables.value_of(ifs.to_s); !val.nil? && !val.kind_of?(FalseClass) && val != 'false' }] else raise TypeError, "Invalid syntax for if: #{ifs.inspect}" end end
@param [String] path
# File lib/kontena/cli/stacks/yaml/reader.rb, line 490 def read_env_file(path) File.readlines(path).map { |line| line.strip }.reject { |line| line.start_with?('#') || line.empty? } end
@param [String] text - content of yaml file
# File lib/kontena/cli/stacks/yaml/reader.rb, line 411 def replace_dollar_dollars(text) text.gsub('$$', '$') end
# File lib/kontena/cli/stacks/yaml/reader.rb, line 462 def store_failures(data) data['errors'] ||= data[:errors] || [] data['notifications'] ||= data[:notifications] || [] errors << { File.basename(file) => data['errors'] } unless data['errors'].empty? notifications << { File.basename(file) => data['notifications'] } unless data['notifications'].empty? end