#!/usr/bin/env ruby

# Copyright 2013-present Facebook
#
# 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.

$LOAD_PATH.unshift(__dir__ + '/../lib')

require 'rubygems'
require 'time'
require 'optparse'
require 'colorize'

require 'taste_tester/logging'
require 'taste_tester/config'
require 'taste_tester/commands'
require 'taste_tester/hooks'
require 'taste_tester/exceptions'

# Command line parsing and param descriptions
module TasteTester
  extend TasteTester::Logging

  verify = 'Verify your changes were actually applied as intended!'.red

  if ENV['USER'] == 'root'
    logger.warn('You should not be running as root')
    exit(1)
  end

  # Do an initial read of the config file if it's in the default place, so
  # that if people override chef_client_command the help message is correct.
  if File.exist?(File.expand_path(TasteTester::Config.config_file))
    TasteTester::Config.from_file(
      File.expand_path(TasteTester::Config.config_file),
    )
  end

  cmd = TasteTester::Config.chef_client_command
  description = <<-ENDOFDESCRIPTION
Welcome to taste-tester!

Usage: taste-tester <mode> [<options>]

TLDR; Most common usage is:
  vi cookbooks/...              # Make your changes and commit locally
  taste-tester test -s [host]   # Put host in test mode
  ssh root@[host]               # Log on host
  #{format('%-28s', "  #{cmd}")}  # Run chef and watch it break
  vi cookbooks/...              # Fix your cookbooks
  taste-tester upload           # Upload the diff
  ssh root@[host]               # Log on host
  #{format('%-28s', "  #{cmd}")}  # Run chef and watch it succeed
  <#{verify}>
  taste-tester untest -s [host] # Put host back in production
                                #   (optional - will revert itself after 1 hour)

And you're done!
Note: There may be site specific testing instructions, see local documentation for details.

MODES:
  test
    Sync your local repo to your virtual Chef server (same as 'upload'), and
    point some production server specified by -s to your virtual chef server for
    testing.  If you have you a plugin that uses the hookpoint, it'll may amend
    your commit message to denote the server you tested.
    Returns:
      0 Success.
      1 Failure, e.g. host not reachable or usage error.
      2 Partial success, mixture of success and failure due to hosts being taste
        tested by other users.
      3 All hosts were not taste-tested because they are being taste-tested by
        other users.

  upload
    Sync your local repo to your virtual Chef server (i.e. just the first step
    of 'test'). By defailt, it intelligently uploads whatever has changed since
    the last time you ran upload (or test), but tracking git changes (even
    across branch changes). You may specify -f to force a full upload of all
    cookbooks and roles. It also does a fair amount of sanity checking on
    your repo and you may specify --skip-repo-checks to bypass this.

  impact
    Examine local repo changes to determine which roles are potentially
    impacted by the changes. Compares the set of modified cookbooks with the
    dependency lists of each role and reports any role which depends on a
    modified cookbook. It is recommended to test your changes on at least one
    server of each role, potentially also on multiple platforms.

  keeptesting
    Extend the testing time on server specified by -s by 1 hour unless
    otherwise specified by -t.

  untest
    Return the server specified in -s to production.

  status
    Print out the state of the world.

  run
    Run #{cmd} on the machine specified by '-s' over SSH and print the output.
    NOTE!! This is #{'NOT'.red} a sufficient test, you must log onto the remote
    machine and verify the changes you are trying to make are actually present.

  stop
    You probably don't want this. It will shutdown the chef-zero server on
    your localhost.

  start
    You probably don't want this. It will start up the chef-zero server on
    your localhost. taste-tester dynamically starts this if it's down, so there
    should be no need to do this manually.

  restart
    You probably don't want this. It will restart up the chef-zero server on
    your localhost. taste-tester dynamically starts this if it's down, so there
    should be no need to do this manually.
  ENDOFDESCRIPTION

  mode = ARGV.shift unless !ARGV.empty? && ARGV[0].start_with?('-')

  unless mode
    mode = 'help'
    puts "ERROR: No mode specified\n\n"
  end

  options = { :config_file => TasteTester::Config.config_file }
  parser = OptionParser.new do |opts|
    opts.banner = description

    opts.separator ''
    opts.separator 'Global options:'.upcase

    opts.on('-c', '--config FILE', 'Config file') do |file|
      unless File.exist?(File.expand_path(file))
        logger.error("Sorry, cannot find #{file}")
        exit(1)
      end
      options[:config_file] = file
    end

    opts.on('-v', '--verbose', 'Verbosity, provide twice for all debug') do
      # If -vv is supplied this block is executed twice
      if options[:verbosity]
        options[:verbosity] = Logger::DEBUG
      else
        options[:verbosity] = Logger::INFO
      end
    end

    opts.on('-p', '--plugin-path FILE', String, 'Plugin file') do |file|
      unless File.exist?(File.expand_path(file))
        logger.error("Sorry, cannot find #{file}")
        exit(1)
      end
      options[:plugin_path] = file
    end

    opts.on('-h', '--help', 'Print help message.') do
      print opts
      exit
    end

    opts.on('-T', '--timestamp', 'Time-stamped log style output') do
      options[:timestamp] = true
    end

    opts.separator ''
    opts.separator 'Sub-command options:'.upcase

    opts.on(
      '-C', '--cookbooks COOKBOOK[,COOKBOOK]', Array,
      'Specific cookbooks to upload. Intended mostly for debugging,' +
        ' not recommended. Works on upload and test. Not yet implemented.'
    ) do |cbs|
      options[:cookbooks] = cbs
    end

    opts.on(
      '-D', '--databag DATABAG/ITEM[,DATABAG/ITEM]', Array,
      'Specific cookbooks to upload. Intended mostly for debugging,' +
        ' not recommended. Works on upload and test. Not yet implemented.'
    ) do |cbs|
      options[:databags] = cbs
    end

    opts.on(
      '-f', '--force-upload',
      'Force upload everything. Works on upload and test.'
    ) do
      options[:force_upload] = true
    end

    opts.on(
      '--chef-port-range PORT1,PORT2', Array,
      'Port range for chef-zero'
    ) do |ports|
      unless ports.count == 2
        logger.error("Invalid port range: #{ports}")
        exit 1
      end
      options[:chef_port_range] = ports
    end

    opts.on(
      '--tunnel-port PORT', 'Port for ssh tunnel'
    ) do |port|
      options[:user_tunnel_port] = port
    end

    opts.on(
      '-m', '--my-hostname HOSTNAME', String, 'Hostname for local chef server'
    ) do |hostname|
      options[:my_hostname] = hostname
    end

    opts.on(
      '-l', '--linkonly', 'Only setup the remote server, skip uploading.'
    ) do
      options[:linkonly] = true
    end

    opts.on('--transport TRANSPORT', ['locallink', 'ssh', 'noop'],
            "Set the transport\n" +
            "\t\tssh       - [default] Use SSH to talk to remote host\n" +
            "\t\tlocallink - Assume the remote host is ourself\n" +
            "\t\tnoop      - Ignore all remote commands") do |transport|
      options[:transport] = transport
    end

    opts.on(
      '-t', '--testing-timestamp TIME',
      'Until when should the host remain in testing.' +
      ' Anything parsable is ok, such as "5/18 4:35" or "16/9/13".'
    ) do |time|
      # can make this an implicit rescue after we drop ruby 2.4

      options[:testing_until] = Time.parse(time)
    rescue StandardError
      logger.error("Invalid date: #{time}")
      exit 1

    end

    opts.on(
      '-t', '--testing-time TIME',
      'How long should the host remain in testing.' +
      ' Takes a simple relative time string, such as "45m", "4h" or "1d".'
    ) do |time|
      m = time.match(/^(\d+)([d|h|m]+)$/)
      if m
        exp = {
          :d => 60 * 60 * 24,
          :h => 60 * 60,
          :m => 60,
        }[m[2].to_sym]
        delta = m[1].to_i * exp
        options[:testing_until] = Time.now + delta.to_i
      else
        logger.error("Invalid testing-time: #{time}")
        exit 1
      end
    end

    opts.on(
      '-r', '--repo DIR',
      "Custom repo location, current default is #{TasteTester::Config.repo}." +
        ' Works on upload and test.'
    ) do |dir|
      options[:repo] = dir
    end

    opts.on(
      '--repo-type TYPE',
      'Override repo type, default is auto.',
    ) do |type|
      options[:repo_type] = type
    end

    opts.on(
      '--clowntown-no-repo', 'This option enables taste-tester to run ' +
      'without a SCM repo of any sort. This has negative implications like ' +
      'always doing a full upload. This option is here for weird corner ' +
      'cases like having to sync out the export of a repo, but is not ' +
      'generally a good idea or well supported. Use at your own risk.'
    ) do
      options[:no_repo] = true
    end

    opts.on(
      '-R', '--roles ROLE[,ROLE]', Array,
      'Specific roles to upload. Intended mostly for debugging,' +
        ' not recommended. Works on upload and test. Not yet implemented.'
    ) do |roles|
      options[:roles] = roles
    end

    opts.on(
      '-J', '--jumps JUMP',
      'Uses ssh\'s `ProxyJump` support to ssh across bastion/jump hosts. ' +
      'This is particularly useful in tunnel mode to test machines that ' +
      'your workstatation doesn\'t have direct access to. The format is ' +
      'the same as `ssh -J`: a comma-separated list of hosts to forward ' +
      'through.'
    ) do |jumps|
      options[:jumps] = jumps
    end

    opts.on('--really', 'Really do link-only. DANGEROUS!') do |r|
      options[:really] = r
    end

    opts.on(
      '-s', '--servers SERVER[,SERVER]', Array,
      'Server to test/untest/keeptesting.'
    ) do |s|
      options[:servers] = s
    end

    opts.on(
      '--user USER', 'Custom username for SSH, defaults to "root".' +
        ' If custom user is specified, we will use sudo for all commands.'
    ) do |user|
      options[:user] = user
    end

    opts.on(
      '-S', '--[no-]use-ssh-tunnels', 'Protect Chef traffic with SSH tunnels'
    ) do |s|
      options[:use_ssh_tunnels] = s
    end

    opts.on(
      '-e', '--ssh CMD', 'SSH command to use, defaults to "ssh".'
    ) do |c|
      options[:ssh_command] = c
    end

    opts.on(
      '--ssh-connect-timeout TIMEOUT', 'SSH \'-o ConnectTimeout\' value'
    ) do |c|
      options[:ssh_connect_timeout] = c
    end

    opts.on('--skip-repo-checks', 'Skip repository sanity checks') do
      options[:skip_repo_checks] = true
    end

    opts.on('-y', '--yes', 'Do not prompt before testing.') do
      options[:yes] = true
    end

    opts.on(
      '--json', 'Format output as JSON for programatic consumption.' +
      ' Default to false. Works on impact.'
    ) do
      options[:json] = true
    end

    opts.on(
      '-w', '--windows-target',
      'The target is a Windows machine. You will likely want to override ' +
      '`test_timestamp` and `chef_config_path`, but *not* `config_file`. ' +
      'Requires the target be running PowerShell >= 5.1 as the default shell.'
    ) do
      options[:windows_target] = true
    end

    opts.separator ''
    opts.separator 'Control local hook behavior with these options:'

    opts.on(
      '--skip-pre-upload-hook', 'Skip pre-upload hook. Works on upload, test.'
    ) do
      options[:skip_pre_upload_hook] = true
    end

    opts.on(
      '--skip-post-upload-hook', 'Skip post-upload hook. Works on upload, test.'
    ) do
      options[:skip_post_upload_hook] = true
    end

    opts.on(
      '--skip-pre-test-hook', 'Skip pre-test hook. Works on test.'
    ) do
      options[:skip_pre_test_hook] = true
    end

    opts.on(
      '--skip-post-test-hook', 'Skip post-test hook. Works on test.'
    ) do
      options[:skip_post_test_hook] = true
    end

    opts.on(
      '--skip-repo-checks-hook', 'Skip repo-checks hook. Works on upload, test.'
    ) do
      options[:skip_post_test_hook] = true
    end

    opts.on(
      '-b', '--bundle BUNDLE_SETTING', ['true', 'false', 'compatible'], 'Bundle mode setting'
    ) do |bundle_setting|
      if ['true', 'false'].include?(bundle_setting)
        bundle_setting = bundle_setting == 'true'
      end
      options[:bundle] = bundle_setting
    end
  end

  if mode == 'help'
    puts parser
    exit
  end

  parser.parse!

  if File.exist?(File.expand_path(options[:config_file]))
    TasteTester::Config.from_file(File.expand_path(options[:config_file]))
  end
  TasteTester::Config.merge!(options)
  TasteTester::Logging.verbosity = TasteTester::Config.verbosity
  TasteTester::Logging.use_log_formatter = TasteTester::Config.timestamp

  if TasteTester::Config.plugin_path
    path = File.expand_path(TasteTester::Config.plugin_path)
    unless File.exist?(path)
      logger.error("Plugin not found (#{path})")
      exit(1)
    end
    TasteTester::Hooks.get(path)
  end

  begin
    case mode.to_sym
    when :start
      TasteTester::Commands.start
    when :stop
      TasteTester::Commands.stop
    when :restart
      TasteTester::Commands.restart
    when :keeptesting
      TasteTester::Commands.keeptesting
    when :status
      TasteTester::Commands.status
    when :test
      TasteTester::Commands.test
    when :untest
      TasteTester::Commands.untest
    when :run
      TasteTester::Commands.runchef
    when :upload
      TasteTester::Commands.upload
    when :impact
      TasteTester::Commands.impact
    else
      logger.error("Invalid mode: #{mode}")
      puts parser
      exit(1)
    end
  rescue TasteTester::Exceptions::SshError
    exit(1)
  end
end

if $PROGRAM_NAME == __FILE__
  module TasteTester
    include TasteTester
  end
end
