require 'hash_validator' module Kontena::Cli::Stacks module YAML class ValidatorV3 require_relative 'validations' include Validations KNOWN_TOP_LEVEL_KEYS = %i( services errors volumes networks variables stack version data description expose depends labels ) def initialize @schema = common_validations @schema['build'] = optional('stacks_valid_build') @schema['depends_on'] = optional('array') @schema['network_mode'] = optional(%w(host bridge)) @schema['logging'] = optional({ 'driver' => optional('string'), 'options' => optional(-> (value) { value.kind_of?(Hash) }) }) Validations::CustomValidators.load end # borrowed from server/app/helpers/volumes_helpers.rb def parse_volume(vol) elements = vol.split(':') if elements.size >= 2 # Bind mount or volume used if elements[0].start_with?('/') && elements[1] && elements[1].start_with?('/') # Bind mount {bind_mount: elements[0], path: elements[1], flags: elements[2..-1].join(',')} elsif !elements[0].start_with?('/') && elements[1].start_with?('/') # Real volume {volume: elements[0], path: elements[1], flags: elements[2..-1].join(',')} else {error: "volume definition not in right format: #{vol}" } end elsif elements.size == 1 && elements[0].start_with?('/') # anon volume {bind_mount: nil, path: elements[0], flags: nil} # anon vols do not support flags else {error: "volume definition not in right format: #{vol}" } end end ## # @param [Hash] yaml # @param [TrueClass|FalseClass] strict # @return [Array] validation_errors def validate(yaml) result = { errors: [], notifications: [] } yaml.keys.each do |key| unless KNOWN_TOP_LEVEL_KEYS.include?(key) || KNOWN_TOP_LEVEL_KEYS.include?(key.to_sym) result[:notifications] << { key.to_s => "unknown top level key" } end end if yaml.key?('stack') unless yaml['stack'] =~ /\A(?:.+?\/)?(?!-)[a-z0-9\-]+\z/ result[:notifications] << { 'stack' => 'A stack name should only include a-z, 0-9 and - characters and not start with the - character' } end end if yaml.key?('services') if yaml['services'].kind_of?(Hash) yaml['services'].each do |service, options| unless service =~ /\A(?!-)[a-z0-9\-]+\z/ result[:notifications] << { 'services' => { service => { 'name' => 'A service name should only include a-z, 0-9 and - characters and not start with the - character' } } } end unless options.kind_of?(Hash) result[:errors] << { 'services' => { service => { 'options' => "must be a mapping not a #{options.class}"} } } next end option_errors = validate_options(options) result[:errors] << { 'services' => { service => option_errors.errors } } unless option_errors.valid? if options['volumes'] mount_path_occurences = Hash.new(0) options['volumes'].each do |volume| parsed = parse_volume(volume) if parsed[:error] result[:errors] << { 'services' => { service => { 'volumes' => { volume => parsed[:error] } } } } elsif parsed[:path] mount_path_occurences[parsed[:path]] += 1 volume_name = parsed[:volume] if volume_name && !volume_name.start_with?('/') if yaml.key?('volumes') unless yaml['volumes'][volume_name] result[:errors] << { 'services' => { service => { 'volumes' => { volume_name => 'not found in top level volumes list' } } } } end else result[:errors] << { 'services' => { service => { 'volumes' => { volume => 'defines volume name, but file does not contain volumes definitions' } } } } end end else result[:errors] << { 'services' => { service => { 'volumes' => { volume => 'mount point missing' } } } } end end mount_path_occurences.select {|path, occurences| occurences > 1 }.each do |path, occurences| result[:errors] << { 'services' => { service => { 'volumes' => { path => "mount point defined #{occurences} times" } } } } end end end else result[:errors] << { 'services' => "must be a mapping, not #{yaml['services'].class}" } end else result[:notifications] << { 'file' => 'does not define any services' } end if yaml.key?('volumes') if yaml['volumes'].kind_of?(Hash) yaml['volumes'].each do |volume, options| if options.kind_of?(Hash) option_errors = validate_volume_options(options) unless option_errors.valid? result[:errors] << { 'volumes' => { volume => option_errors.errors } } end else result[:errors] << { 'volumes' => { volume => { 'options' => "must be a mapping, not #{options.class}" } } } end end else result[:errors] << { 'volumes' => "must be a mapping, not #{yaml['volumes'].class}" } end end if yaml.key?('networks') result[:notifications] << { 'networks' => 'Kontena does not support multiple networks yet. You can reference services with Kontena\'s internal DNS (service_name.kontena.local)' } end if (yaml['volumes'].nil? || yaml['volumes'].empty?) && (yaml['services'].nil? || yaml['services'].empty?) result[:errors] << { 'file' => 'does not list any services or volumes' } end if yaml.key?('depends') unless yaml['depends'].kind_of?(Hash) result[:errors] << { 'depends' => "Must be a mapping, not #{yaml['depends'].class}" } end yaml['depends'].each do |name, dependency_options| validator = validate_dependencies(dependency_options) result[:errors] << { 'depends' => { name => validator.errors } } unless validator.valid? if yaml.key?('services') && yaml['services'][name] result[:errors] << { 'depends' => { name => 'is defined both as service and dependency name' } } end end end result end end end end