#!/usr/bin/env tarantool

--[[

=head1 NAME

tarantoolctl - an utility to control tarantool instances

=head1 SYNOPSIS

    vim /etc/tarantool/instances.enabled/my_instance.lua
    tarantoolctl start my_instance
    tarantoolctl stop  my_instance
    tarantoolctl logrotate my_instance

=head1 DESCRIPTION

The script is read C</etc/sysconfig/tarantool> or C</etc/default/tarantool>.
The file contains common default instances options:

    $ cat /etc/default/tarantool


    -- Options for Tarantool
    default_cfg = {
        -- will become pid_file .. instance .. '.pid'
        pid_file    =   "/var/run/tarantool",
        -- will become wal_dir/instance/
        wal_dir     =   "/var/lib/tarantool",
        -- snap_dir/instance/
        snap_dir    =   "/var/lib/tarantool",

        -- vinyl_dir/instance/
        vinyl_dir  =   "/var/lib/tarantool/vinyl",

        -- logger/instance .. '.log'
        logger      =   "/var/log/tarantool",
        username    =   "tarantool",
    }

    instance_dir = "/etc/tarantool/instances.enabled"


The file defines C<instance_dir> where user can place his
applications (instances).

Each instance can be controlled by C<tarantoolctl>:

=head2 Starting instance

    tarantoolctl start instance_name

=head2 Stopping instance

    tarantoolctl stop instance_name

=head2 Logrotate instance's log

    tarantoolctl logrotate instance_name

=head2 Enter instance admin console

    tarantoolctl enter instance_name

=head2 status

    tarantoolctl status instance_name

Check if instance is up.

If pid file exists and control socket exists and control socket is alive
returns code C<0>.

Return code != 0 in other cases. Can complain in log (stderr) if pid file
exists and socket doesn't, etc.


=head2 separate instances control

If You use SysV init, You can use symlink from
C<tarantoolctl> to C</etc/init.d/instance_name[.lua]>.
C<tarantoolctl> detects if it is started by symlink and uses
instance_name as C<`basename $0 .lua`>.

=head1 COPYRIGHT

Copyright (C) 2010-2013 Tarantool AUTHORS:
please see AUTHORS file.

=cut

]]

local os = require 'os'
local ffi = require 'ffi'
local fio = require 'fio'
local fun = require 'fun'
local log = require 'log'
local uri = require 'uri'
local yaml = require 'yaml'
local errno = require 'errno'
local fiber = require 'fiber'
local netbox = require 'net.box'
local socket = require 'socket'
local console = require 'console'

ffi.cdef[[
struct passwd {
  char *pw_name;   /* username */
  char *pw_passwd; /* user password */
  int   pw_uid;    /* user ID */
  int   pw_gid;    /* group ID */
  char *pw_gecos;  /* user information */
  char *pw_dir;    /* home directory */
  char *pw_shell;  /* shell program */
};

struct group{
  char *gr_name;
  char *gr_passwd;
  int   gr_gid;
  char **gr_mem;
};

int kill(int pid, int sig);
struct passwd *getpwnam(const char *name);
struct group *getgrgid(int gid);
]]
-- command, that we're executing
local command_name
-- true if we're running in HOME directory of a user
local usermode = false
-- true if we're tarantoolctl is symlink and name != tarantoolctl
local linkmode = false
-- a file with system-wide settings
local default_file
-- current instance settings
local instance_name
local instance_path
local console_sock
local group_name
-- overrides for defaults files
local instance_dir
local default_cfg

-- print usage and exit. overloaded later
local usage = function()
    os.exit(-1)
end

--
-- shift argv to remove 'tarantoolctl' from arg[0]
--
local function shift_argv(arg, argno, argcount)
    for i = argno, 128 do
        arg[i] = arg[i + argcount]
        if arg[i] == nil then
            break
        end
    end
end

local function check_user_level()
    local uid = os.getenv('UID')
    local udir = nil
    if uid == 0 then
        return nil
    end
    -- local dir configuration
    local pwd = os.getenv('PWD')
    udir = pwd and pwd .. '/.tarantoolctl'
    udir = udir and fio.stat(udir) and udir or nil
    -- or home dir configuration
    local homedir = os.getenv('HOME')
    udir = udir or homedir and homedir .. '/.config/tarantool/tarantool'
    udir = udir and fio.stat(udir) and udir or nil
    -- if one of previous is not nil
    if udir ~= nil then
        usermode = true
        return udir
    end

    return nil
end

--
-- Find if we're running under a user, and this user has a default file in his
-- home directory. If present, use it. Otherwise assume a system-wide default.
-- If it's missing, it's OK as well.
--
local function find_default_file()
    -- try to find local dir or user config
    local user_level = check_user_level()
    if user_level ~= nil then
        return user_level
    end

    -- no user-level defaults, use a system-wide one
    local cfg = '/etc/default/tarantool'
    if fio.stat(cfg) then
        return cfg
    end
    -- It's OK if there is no default file - load_default_file() will assume
    -- some defaults
    return nil
end

local function check_file(path)
    local rv, err = loadfile(path)
    if rv == nil then
        log.error("%s", debug.traceback())
        log.error("Failed to check instance file '%s'", err)
        return err
    end
    return nil
end

--
-- System-wide default file may be missing, this is OK, we'll assume built-in
-- defaults
-- It uses sandboxing for isolation. It's not completely safe, but it won't
-- allow a pollution of global variables
--
local function load_default_file(default_file)
    if default_file then
        local env = setmetatable({}, { __index = _G })
        local ufunc, msg = loadfile(default_file)
        -- if load fails - show last 10 lines of the log file
        if not ufunc then
            log.error("Failed to load defaults file: %s", msg)
        end
        debug.setfenv(ufunc, env)
        local state, msg = pcall(ufunc)
        if not state then
            log.error('Failed to execute defaults file: %s', msg)
        end
        default_cfg = env.default_cfg
        instance_dir = env.instance_dir
    end
    local d = default_cfg or {}

    d.pid_file   = d.pid_file    or "/var/run/tarantool"
    d.wal_dir    = d.wal_dir     or "/var/lib/tarantool"
    d.snap_dir   = d.snap_dir    or "/var/lib/tarantool"
    d.logger     = d.logger      or "/var/log/tarantool"
    d.vinyl_dir = d.vinyl_dir  or "/var/lib/tarantool"

    d.pid_file   = fio.pathjoin(d.pid_file,   instance_name .. '.pid')
    d.wal_dir    = fio.pathjoin(d.wal_dir,    instance_name)
    d.snap_dir   = fio.pathjoin(d.snap_dir,   instance_name)
    d.vinyl_dir = fio.pathjoin(d.vinyl_dir, instance_name)
    d.logger     = fio.pathjoin(d.logger,     instance_name .. '.log')

    default_cfg = d

    if not usermode then
        -- change user name only if not running locally
        d.username = d.username or "tarantool"
        -- instance_dir must be set in the defaults file, but don't try to set
        -- it to the  global instance dir if the user-local defaults file is in
        -- use
        instance_dir = instance_dir or '/etc/tarantool/instances.enabled'
        -- get user data
        local user_data = ffi.C.getpwnam(ffi.cast('const char*', d.username))
        if user_data == nil then
            log.error('Unknown user: %s', d.username)
            os.exit(-1)
        end

        -- get group data
        local group = ffi.C.getgrgid(user_data.pw_gid)
        if group == nil then
            log.error('Group lookup by gid failed: %d', user_data.pw_gid)
            os.exit(-1)
        end
        group_name = ffi.string(group.gr_name)
    end

    if instance_dir == nil then
        log.error('Instance directory (instance_dir) is not set in %s', default_file)
        os.exit(-1)
    end

    if not fio.stat(instance_dir) then
        log.error('Instance directory %s does not exist', instance_dir)
        os.exit(-1)
    end
end

--
-- In case there is no explicit instance name, check whether arg[0] is a
-- symlink. In that case, the name of the symlink is the instance name.
--
local function find_instance_name(arg0, arg2)
    if arg2 ~= nil then
        return fio.basename(arg2, '.lua')
    end
    local istat = fio.lstat(arg0)
    if istat == nil then
        log.error("Can't stat %s: %s", arg0, errno.strerror())
        os.exit(1)
    end
    if not istat:is_link() then usage() end
    arg[2] = arg0
    linkmode = true
    return fio.basename(arg0, '.lua')
end

local function mkdir(dirname)
    log.info("mkdir %s", dirname)
    if not fio.mkdir(dirname, tonumber('0750', 8)) then
        log.error("Can't mkdir %s: %s", dirname, errno.strerror())
        os.exit(-1)
    end

    if not usermode and
       not fio.chown(dirname, default_cfg.username, group_name) then
        log.error("Can't chown(%s, %s, %s): %s", default_cfg.username,
                  group_name, dirname, errno.strerror())
    end
end

local function read_file(filename)
    local file = fio.open(filename, {'O_RDONLY'})
    local buf = {}
    local i = 1

    while true do
        buf[i] = file:read(1024)
        if buf[i] == '' then
            break
        end
        i = i + 1
    end
    return table.concat(buf)
end

local function mk_default_dirs(cfg)
    local init_dirs = {
        fio.dirname(cfg.pid_file),
        cfg.wal_dir,
        cfg.snap_dir,
        cfg.vinyl_dir,
    }
    local log_dir = fio.dirname(cfg.logger)
    if log_dir:find('|') == nil then
        table.insert(init_dirs, log_dir)
    end
    for _, dir in ipairs(init_dirs) do
        if fio.stat(dir) == nil then
            mkdir(dir)
        end
    end
end

local orig_cfg = box.cfg

local function wrapper_cfg(cfg)
    for i, v in pairs(default_cfg) do
        if cfg[i] == nil then
            cfg[i] = v
        end
    end
    -- force these startup options
    cfg.pid_file = default_cfg.pid_file
    if os.getenv('USER') ~= default_cfg.username then
        cfg.username = default_cfg.username
    else
        cfg.username = nil
    end
    if cfg.background == nil then
        cfg.background = true
    end

    mk_default_dirs(cfg)
    local success, data = pcall(orig_cfg, cfg)
    if not success then
        log.error("Configuration failed: %s", data)
        if fio.stat(default_cfg.logger) then
            os.execute('tail -n 10 ' .. default_cfg.logger)
        end
        os.exit(1)
    end

    fiber.name(instance_name)
    log.info('Run console at %s', console_sock)
    console.listen(console_sock)
    -- gh-1293: members of `tarantool` group should be able to do `enter`
    local console_sock = uri.parse(console_sock).service
    local mode = '0664'
    if not fio.chmod(console_sock, tonumber(mode, 8)) then
        log.error("Can't chmod(%s, %s) [%d]: %s", console_sock, mode, errno(),
                  errno.strerror())
    end

    return data
end

local function start()
    log.info("Starting instance...")
    local stat = check_file(instance_path)
    if stat ~= nil then
        log.error("Error, while checking syntax: halting")
        return 1
    end
    box.cfg = wrapper_cfg
    require('title').update{
        script_name = instance_path,
        __defer_update = true
    }
    shift_argv(arg, 0, 2)
    local success, data = pcall(dofile, instance_path)
    -- if load fails - show last 10 lines of the log file
    if not success then
        log.error("Start failed: %s", data)
        if fio.stat(default_cfg.logger) then
            os.execute('tail -n 10 ' .. default_cfg.logger)
        end
    end
    return 0
end

local function stop()
    local pid_file = default_cfg.pid_file

    local function base_stop()
        log.info("Stopping instance...")
        if fio.stat(pid_file) == nil then
            log.error("Process is not running (pid: %s)", pid_file)
            return 0
        end

        local f = fio.open(pid_file, 'O_RDONLY')
        if f == nil then
            log.error("Can't read pid file %s: %s", pid_file, errno.strerror())
            return -1
        end

        local pid = tonumber(f:read(64))
        f:close()

        if pid == nil or pid <= 0 then
            log.error("Broken pid file %s", pid_file)
            return -1
        end

        if ffi.C.kill(pid, 15) < 0 then
            log.error("Can't kill process %d: %s", pid, errno.strerror())
            return -1
        end
        return 0
    end

    local rv = base_stop()
    if fio.stat(pid_file) then
        fio.unlink(pid_file)
    end
    local console_sock = uri.parse(console_sock).service
    if fio.stat(console_sock) then
        fio.unlink(console_sock)
    end
    return rv
end

local function check()
    local rv = check_file(instance_path)
    if rv ~= nil then
        return 1
    end
    log.info("File '%s' is OK", instance_path)
    return 0
end

local function restart()
    local stat = check_file(instance_path)
    if stat ~= nil then
        log.error("Error, while checking syntax: halting")
        return 1
    end
    stop()
    fiber.sleep(1)
    start()
    return 0
end

local function logrotate()
    local console_sock = uri.parse(console_sock).service
    if fio.stat(console_sock) == nil then
        -- process is not running, do nothing
        return 0
    end

    local s = socket.tcp_connect('unix/', console_sock)
    if s == nil then
        -- socket is not opened, do nothing
        return 0
    end

    s:write[[
        require('log'):rotate()
        require('log').info("Rotate log file")
    ]]

    s:read({ '[.][.][.]' }, 2)

    return 0
end

local function enter()
    local console_sock_path = uri.parse(console_sock).service
    if fio.stat(console_sock_path) == nil then
        log.error("Can't connect to %s (%s)", console_sock_path, errno.strerror())
        if not usermode and errno() == errno.EACCES then
            log.error("Please add $USER to group '%s': usermod -a -G %s $USER",
                      group_name, group_name)
        end
        return -1
    end

    local cmd = string.format("require('console').connect('%s')", console_sock)

    console.on_start(function(self) self:eval(cmd) end)
    console.on_client_disconnect(function(self) self.running = false end)
    console.start()
    return 0
end

local function connect()
    console.on_start(function(self)
        local status, reason
        status, reason = pcall(function() require('console').connect(arg[2]) end)
        if not status then
            self:print(reason)
            self.running = false
        end
    end)
    console.on_client_disconnect(function(self) self.running = false end)
    console.start()
    return 0
end

local function status()
    local pid_file = default_cfg.pid_file
    local console_sock = uri.parse(console_sock).service

    if fio.stat(pid_file) == nil then
        if errno() == errno.ENOENT then
            log.info('%s is stopped (pid file does not exist)', instance_name)
            return 1
        end
        log.error("Can't access pidfile %s: %s", pid_file, errno.strerror())
    end

    if fio.stat(console_sock) == nil and errno() == errno.ENOENT then
        log.error("Pid file exists, but the control socket (%s) doesn't",
                  console_sock)
        return 2
    end

    local s = socket.tcp_connect('unix/', console_sock)
    if s == nil then
        if errno() ~= errno.EACCES then
            log.warn("Can't access control socket '%s' [%d]: %s", console_sock,
                errno(), errno.strerror())
            return 2
        end
        return 0
    end

    s:close()
    log.info('%s is running (pid: %s)', instance_name, default_cfg.pid_file)
    return 0
end

local function eval()
    local console_sock_path = uri.parse(console_sock).service
    local filename = arg[3]
    if filename == nil then
        log.error("Usage: tarantoolctl eval instance_name file.lua")
        return 1
    end
    if fio.stat(filename) == nil and errno() == errno.ENOENT then
        log.error("%s: file not found", filename)
        return 1
    end
    if check_file(filename) ~= nil then
        log.error("Error, while checking syntax: halting")
        return 1
    end
    local code = read_file(filename)

    if fio.stat(console_sock_path) == nil then
        log.warn("pid file exists, but the control socket (%s) doesn't",
                 console_sock_path)
        return 2
    end

    local u = uri.parse(console_sock)
    local remote = netbox.connect(u.host, u.service,
        { user = u.login, password = u.password, console = true })
    if remote == nil then
        log.warn("control socket exists, but tarantool doesn't listen on it")
        return 2
    end

    local full_response = remote:eval(code)
    local error_response = yaml.decode(full_response)[1]
    if type(error_response) == 'table' and error_response.error then
        log.error("Error, while reloading config:")
        log.info(error_response.error)
        return 3
    end

    print(full_response)
    return 0
end

local function exit_wrapper(func)
    return function() os.exit(func()) end
end

local function process_remote(cmd_function)
    cmd_function()
end

local function process_local(cmd_function)
    instance_name = find_instance_name(arg[0], arg[2])

    default_file = find_default_file()
    load_default_file(default_file)

    if #arg < 2 then
        log.error("Not enough arguments for '%s' command", command_name)
        usage()
    end

    instance_path = fio.pathjoin(instance_dir, instance_name .. '.lua')

    if not fio.stat(instance_path) then
        log.error('Instance %s is not found in %s', instance_name, instance_dir)
        os.exit(-1)
    end

    -- create a path to the control socket (admin console)
    console_sock = instance_name .. '.control'
    console_sock = fio.pathjoin(fio.dirname(default_cfg.pid_file), console_sock)
    console_sock = 'unix/:' .. console_sock

    cmd_function()
end

local commands = setmetatable({
    start     = { func = start,                   process = process_local  },
    stop      = { func = exit_wrapper(stop),      process = process_local  },
    logrotate = { func = exit_wrapper(logrotate), process = process_local  },
    status    = { func = exit_wrapper(status),    process = process_local  },
    enter     = { func = exit_wrapper(enter),     process = process_local  },
    restart   = { func = restart,                 process = process_local  },
    reload    = { func = exit_wrapper(eval),      process = process_local  },
    eval      = { func = exit_wrapper(eval),      process = process_local  },
    check     = { func = exit_wrapper(check),     process = process_local  },
    connect   = { func = exit_wrapper(connect),   process = process_remote }
}, {
    __index = function()
        log.error("Unknown command '%s'", command_name)
        usage()
    end
})

local usage = function()
    local local_tbl = fun.iter(commands):filter(function(name, cmd)
        return cmd.process == process_local
    end):map(function(name, cmd) return name end):totable()
    table.sort(local_tbl); local_tbl = table.concat(local_tbl, '|')

    local remote_tbl = fun.iter(commands):filter(function(name, cmd)
        return cmd.process == process_remote
    end):map(function(name, cmd) return name end):totable()
    table.sort(remote_tbl); remote_tbl = table.concat(remote_tbl, '|')

    if linkmode then
        log.error("Usage: %s {%s}",               arg[0], local_tbl )
    else
        log.error("Usage: %s {%s} instance_name", arg[0], local_tbl )
        log.error("       %s {%s} URI",           arg[0], remote_tbl)
    end

    log.error("Config file: %s", default_file)
    os.exit(1)
end

local command_name = arg[1]

if #arg < 2 then
    log.error("Not enough arguments for '%s' command", command_name)
    usage()
end

local cmd_pair = commands[command_name]
cmd_pair.process(cmd_pair.func)

-- vim: syntax=lua
