class Kontena::Cli::Stacks::YAML::Reader

Attributes

errors[R]
file[R]
loader[R]
notifications[R]

Public Class Methods

new(file) click to toggle source

@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

create_dependency_variables(dependencies, name) click to toggle source

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
create_parent_variable(parent_name) click to toggle source

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
default_envs() click to toggle source

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
default_envs_to_options() click to toggle source

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
dependencies() click to toggle source

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 241
def dependencies
  @dependencies ||= loader.dependencies
end
execute(service_name = nil, name: loader.stack_name.stack, parent_name: nil, skip_validation: false, values: nil, defaults: nil) click to toggle source

@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

  if !skip_validation && !variables.valid?
    stringify_keys(variables.errors).each do |k,v|
      errors << { 'variables' => { k => v } }
    end
  end

  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['labels']        = fully_interpolated_yaml['labels'] || []
    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
    errors << { file => { 'stack' => 'Required field missing' } } if result['stack'].nil?
  end
  result
end
from_external_stack(name, service_name) click to toggle source
# File lib/kontena/cli/stacks/yaml/reader.rb, line 365
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
from_file?() click to toggle source

@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
fully_interpolated_yaml() click to toggle source

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
internals_interpolated_yaml() click to toggle source

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_liquid(content, vars) click to toggle source

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 250
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
parse_services(service_name = nil) click to toggle source

@param [String] service_name - optional service to parse @return [Hash]

# File lib/kontena/cli/stacks/yaml/reader.rb, line 288
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
parse_volumes() click to toggle source
# File lib/kontena/cli/stacks/yaml/reader.rb, line 272
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
process_config(service_config, name=nil) click to toggle source

@param [Hash] service_config

# File lib/kontena/cli/stacks/yaml/reader.rb, line 329
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
process_hash?(hash) click to toggle source

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 311
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
process_volume(name, volume_config) click to toggle source
# File lib/kontena/cli/stacks/yaml/reader.rb, line 345
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
raw_content() click to toggle source

The YAML file raw content

# File lib/kontena/cli/stacks/yaml/reader.rb, line 114
def raw_content
  loader.content
end
raw_yaml() click to toggle source

@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
services() click to toggle source

@return [Hash] - services from YAML file

# File lib/kontena/cli/stacks/yaml/reader.rb, line 361
def services
  @services ||= fully_interpolated_yaml.fetch('services', {})
end
set_variable_defaults(defaults) click to toggle source

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_variable_values(values) click to toggle source

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
validate() click to toggle source

@return [Array<Hash>] array of validation errors

# File lib/kontena/cli/stacks/yaml/reader.rb, line 261
def validate
  result = validator.validate(fully_interpolated_yaml)
  store_failures(result)
  result
end
validator() click to toggle source

@return [Kontena::Cli::Stacks::YAML::ValidatorV3]

# File lib/kontena/cli/stacks/yaml/reader.rb, line 268
def validator
  @validator ||= YAML::ValidatorV3.new
end
variable_values(without_defaults: false, without_vault: false, with_errors: false) click to toggle source

@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
variables() click to toggle source

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
volumes() click to toggle source
# File lib/kontena/cli/stacks/yaml/reader.rb, line 356
def volumes
  @volumes ||= fully_interpolated_yaml.fetch('volumes', {})
end

Private Instance Methods

env() click to toggle source
# File lib/kontena/cli/stacks/yaml/reader.rb, line 520
def env
  ENV
end
expand_build_context(options) click to toggle source
# File lib/kontena/cli/stacks/yaml/reader.rb, line 501
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
extend_config(service_config) click to toggle source

@param [Hash] service_config @return [Hash] updated service config

# File lib/kontena/cli/stacks/yaml/reader.rb, line 451
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
interpolate(content, use_opto: true, substitutions: {}, raise_on_unknown: false, warnings: true) click to toggle source

@param [String] content - content of YAML file

# File lib/kontena/cli/stacks/yaml/reader.rb, line 380
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
merge_env_vars(options) click to toggle source

@param [Hash] options

# File lib/kontena/cli/stacks/yaml/reader.rb, line 484
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
normalize_build_args(options) click to toggle source

@param [Hash] options - service config

# File lib/kontena/cli/stacks/yaml/reader.rb, line 510
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
normalize_env_vars(options) click to toggle source

@param [Hash] options - service config

# File lib/kontena/cli/stacks/yaml/reader.rb, line 477
def normalize_env_vars(options)
  if options['environment'].kind_of?(Hash)
    options['environment'] = options['environment'].map { |k, v| "#{k}=#{v}" }
  end
end
normalize_ifs(ifs) click to toggle source

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 430
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
read_env_file(path) click to toggle source

@param [String] path

# File lib/kontena/cli/stacks/yaml/reader.rb, line 497
def read_env_file(path)
  File.readlines(path).map { |line| line.strip }.reject { |line| line.start_with?('#') || line.empty? }
end
replace_dollar_dollars(text) click to toggle source

@param [String] text - content of yaml file

# File lib/kontena/cli/stacks/yaml/reader.rb, line 418
def replace_dollar_dollars(text)
  text.gsub('$$', '$')
end
store_failures(data) click to toggle source
# File lib/kontena/cli/stacks/yaml/reader.rb, line 469
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