require 'json' require 'kontena/stacks/change_resolver' require_relative 'common' module Kontena::Cli::Stacks class UpgradeCommand < Kontena::Command include Kontena::Cli::Common include Kontena::Cli::GridOptions include Common banner "Upgrades a stack in a grid on Kontena Master" parameter "NAME", "Stack name" include Common::StackFileOrNameParam include Common::StackValuesToOption include Common::StackValuesFromOption option '--[no-]deploy', :flag, 'Trigger deploy after upgrade', default: true option '--force', :flag, 'Force upgrade' option '--skip-dependencies', :flag, "Do not upgrade any stack dependencies", default: false option '--reuse-values', :flag, "Reuse existing values" option '--dry-run', :flag, "Simulate upgrade" requires_current_master requires_current_master_token # @return [Kontena::Stacks::ChangeResolver] def execute old_data = spinner "Reading stack #{pastel.cyan(stack_name)} from master" do gather_master_data(stack_name) end kontena_requirement = loader.yaml.dig('meta', 'required_kontena_version') unless kontena_requirement.nil? master_version = Gem::Version.new(client.server_version) unless Gem::Requirement.new(kontena_requirement).satisfied_by?(master_version) puts "#{pastel.red("Warning: ")} Stack requires kontena version #{kontena_requirement} but Master version is #{master_version}" confirm("Are you sure? You can skip this prompt by running this command with --force option") unless force? end end new_data = spinner "Parsing #{pastel.cyan(source)}" do loader.flat_dependencies( stack_name, variables: values_from_options ) end changes = process_data(old_data, new_data) display_report(changes) return if dry_run? get_confirmation(changes) deployable_stacks = [] deployable_stacks.concat run_installs(changes) deployable_stacks.concat run_upgrades(changes) run_deploys(deployable_stacks) if deploy? run_removes(changes.removed_stacks) changes end private # Recursively fetch master data in StackFileLoader#flat_dependencies format # @return [Hash{string => Hash}] stackname => hash def gather_master_data(stackname) response = fetch_master_data(stackname) children = response.delete('children') || [] result = { stackname => { stack_data: response } } children.each do |child| result.merge!(gather_master_data(child['name'])) end result end # Preprocess data and return a ChangeResolver # @param old_data [Hash] data from master # @param new_data [Hash] data from files # @return [Kontena::Cli::Stacks::ChangeRsolver] def process_data(old_data, new_data) logger.debug { "Master stacks: #{old_data.keys.join(",")} YAML stacks: #{new_data.keys.join(",")}" } new_data.reverse_each do |stackname, data| spinner "Processing stack #{pastel.cyan(stackname)}" process_stack_data(stackname, data, old_data) hint_on_validation_notifications(reader.notifications, reader.loader.source) abort_on_validation_errors(reader.errors, reader.loader.source) end old_set = Kontena::Stacks::StackDataSet.new(old_data) new_set = Kontena::Stacks::StackDataSet.new(new_data) if skip_dependencies? [old_set, new_set].each(&:remove_dependencies) end spinner "Analyzing upgrade" do Kontena::Stacks::ChangeResolver.new(old_set, new_set) end end # @param stackname [String] # @param data [Hash] # @param old_data [Hash] def process_stack_data(stackname, data, old_data) prev_env = ENV.clone reader = data[:loader].reader values = data[:variables] if reuse_values? && old_data[stackname] old_vars = old_data[stackname][:stack_data]['variables'] values = old_vars.merge(values) end set_env_variables(stackname, current_grid) # set envs for execution time parsed_stack = reader.execute( values: values, defaults: old_data[stackname].nil? ? nil : old_data[stackname][:stack_data]['variables'], parent_name: data[:parent_name], name: data[:name] ) data[:stack_data] = parsed_stack ensure prev_env.each { |k, v| ENV[k] = v } end # @param changes [Kontena::Stacks::ChangeResolver] def display_report(changes) puts caret "Calculated changes:", dots: false unless changes.removed_stacks.empty? changes.removed_stacks.each { |stack| puts " #{pastel.red('-')} #{stack}" changes.removed_services.select { |s| s.split('/')[0] == stack }.each do |s| puts " #{pastel.red('-')} #{s}" end } end unless changes.replaced_stacks.empty? changes.replaced_stacks.each { |stack| puts " #{pastel.yellow('-/+')} #{stack}" changes.upgraded_services.select { |s| s.split('/')[0] == stack }.each do |s| puts " #{pastel.cyan('~')} #{s}" end changes.added_services.select { |s| s.split('/')[0] == stack }.each do |s| puts " #{pastel.green('+')} #{s}" end changes.removed_services.select { |s| s.split('/')[0] == stack }.each do |s| puts " #{pastel.red('-')} #{s}" end } changes.replaced_stacks.each do |installed_name, data| puts "- #{pastel.yellow(installed_name)} from #{pastel.cyan(data[:from])} to #{pastel.cyan(data[:to])}" end puts end unless changes.added_stacks.empty? changes.added_stacks.each { |stack| puts " #{pastel.green('+')} #{stack}" changes.added_services.select { |s| s.split('/')[0] == stack }.each do |s| puts " #{pastel.green('+')} #{s}" end } end unless changes.upgraded_stacks.empty? changes.upgraded_stacks.each { |stack| puts " #{pastel.cyan('~')} #{stack}" changes.upgraded_services.select { |s| s.split('/')[0] == stack }.each do |s| puts " #{pastel.cyan('~')} #{s}" end changes.added_services.select { |s| s.split('/')[0] == stack }.each do |s| puts " #{pastel.green('+')} #{s}" end changes.removed_services.select { |s| s.split('/')[0] == stack }.each do |s| puts " #{pastel.red('-')} #{s}" end } end puts end # requires heavier confirmation when something very dangerous is going to happen def get_confirmation(changes) unless force? unless changes.safe? puts "#{pastel.red('Warning:')} This can not be undone, data will be lost." confirm end end end def deployable_stacks @deployable_stacks ||= [] end # @param removed_stacks [Array] def run_removes(removed_stacks) removed_stacks.reverse_each do |removed_stack| Kontena.run!('stack', 'remove', '--force', '--keep-dependencies', removed_stack) end end # @param changes [Kontena::Stacks::ChangeResolver] # @return [Array] an array of stack names that have been installed, but not yet deployed def run_installs(changes) deployable_stacks = [] changes.added_stacks.reverse_each do |added_stack| data = changes.new_data[added_stack] cmd = ['stack', 'install', '--name', added_stack, '--no-deploy'] cmd.concat ['--parent-name', data.parent] unless data.root? data.variables.each do |k,v| cmd.concat ['-v', "#{k}=#{v}"] end cmd << data.loader.source caret "Installing new dependency #{cmd.last} as #{added_stack}" deployable_stacks << added_stack Kontena.run!(cmd) end deployable_stacks end # @param changes [Kontena::Stacks::ChangeResolver] # @return [Array] an array of stack names that have been upgraded, but not yet deployed def run_upgrades(changes) deployable_stacks = [] changes.upgraded_stacks.reverse_each do |upgraded_stack| data = changes.new_data[upgraded_stack] spinner "Upgrading #{data.root? ? 'stack' : 'dependency'} #{pastel.cyan(upgraded_stack)}" do |spin| deployable_stacks << upgraded_stack update_stack(upgraded_stack, data.data) || spin.fail! end end deployable_stacks end # @param deployable_stacks [Array] an array of stack names that should be deployed def run_deploys(deployable_stacks) deployable_stacks.each do |deployable_stack| Kontena.run!(['stack', 'deploy', deployable_stack]) end end def update_stack(name, data) client.put(stack_url(name), data) end def stack_url(name) "stacks/#{current_grid}/#{name}" end def fetch_master_data(stackname) client.get(stack_url(stackname)) end end end