#!/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 io = require('io')
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 json = require('json')
local xlog = require('xlog')
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')
local argparse = require('internal.argparse').parse

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 = arg[1]
-- 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
local positional_arguments
local keyword_arguments

-- usage printer function
local usage

-- 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

-- -------------------------------------------------------------------------- --
--                            CAT command helpers                             --
-- -------------------------------------------------------------------------- --

local function find_space(sid, spaces)
    if type(spaces) == 'number' then
        return sid == spaces
    end
    local shown = false
    for _, v in ipairs(spaces) do
        if v == sid then
            shown = true
            break
        end
    end
    return shown
end

local write_lua_table = nil

-- escaped string will be written
local function write_lua_string(string)
    io.stdout:write("'")
    local pos, byte = 1, string:byte(1)
    while byte ~= nil do
        io.stdout:write(("\\x%x"):format(byte))
        pos = pos + 1
        byte = string:byte(pos)
    end
    io.stdout:write("'")
end

local function write_lua_value(value)
    if type(value) == 'string' then
        write_lua_string(value)
    elseif type(value) == 'table' then
        write_lua_table(value)
    else
        io.stdout:write(tostring(value))
    end
end

local function write_lua_fieldpair(key, val)
    io.stdout:write("[")
    write_lua_value(key)
    io.stdout:write("] = ")
    write_lua_value(val)
end

write_lua_table = function(tuple)
    io.stdout:write('{')
    local is_begin = true
    for key, val in pairs(tuple) do
        if is_begin == false then
            io.stdout:write(', ')
        else
            is_begin = false
        end
        write_lua_fieldpair(key, val)
    end
    io.stdout:write('}')
end

local function cat_lua_cb(record)
    io.stdout:write(('box.space[%d]'):format(record.BODY.space_id))
    local op = record.HEADER.type:lower()
    io.stdout:write((':%s('):format(op))
    if op == 'insert' or op == 'replace' then
        write_lua_table(record.BODY.tuple)
    elseif op == 'delete' then
        write_lua_table(record.BODY.key)
    elseif op == 'update' then
        write_lua_table(record.BODY.key)
        io.stdout:write(', ')
        write_lua_table(record.BODY.tuple)
    elseif op == 'upsert' then
        write_lua_table(record.BODY.tuple)
        io.stdout:write(', ')
        write_lua_table(record.BODY.operations)
    end
    io.stdout:write(')\n')
end

local function cat_yaml_cb(record)
    print(yaml.encode(record):sub(1, -6))
end

local function cat_json_cb(record)
    print(json.encode(record))
end

local cat_formats = setmetatable({
    yaml = cat_yaml_cb,
    json = cat_json_cb,
    lua  = cat_lua_cb,
}, {
    __index = function(self, cmd)
        error(("Unknown formatter '%s'"):format(cmd))
    end
})

-- -------------------------------------------------------------------------- --
--                               Commands                                     --
-- -------------------------------------------------------------------------- --
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 cat()
    local options = keyword_arguments
    local from, to, spaces = options.from, options.to, options.spaces
    local show_system, cat_format = options['show-system'], options.format

    local format_cb   = cat_formats[cat_format]
    local is_printed  = false
    for id, file in ipairs(positional_arguments) do
        log.info(("Processing file '%s'"):format(file))
        for lsn, record in xlog.pairs(file) do
            local sid = record.BODY.space_id
            if (lsn < from) or
               (sid < 512 and show_system == false) or
               (spaces ~= nil and find_space(sid, spaces)) then
                -- pass this tuple
            elseif lsn >= to then
                -- stop, as we've had finished reading tuple with lsn == to
                -- and next lsn's will be bigger
                break
            else
                is_printed = true
                format_cb(record)
                io.stdout:flush()
            end
        end
        if options.format == 'yaml' and is_printed then
            is_printed = false
            print('...\n')
        end
    end
end

local function play()
    local options = keyword_arguments
    local from, to, spaces = options.from, options.to, options.spaces
    local show_system = options['show-system']
    local uri = table.remove(positional_arguments, 1)

    if uri == nil then
        error("Empty URI is provided")
    end
    local remote = netbox.new(uri)
    if not remote:wait_connected() then
        error(("Error, while connecting to host '%s'"):format(uri))
    end
    for id, file in ipairs(positional_arguments) do
        log.info(("Processing file '%s'"):format(file))
        for lsn, record in xlog.pairs(file) do
            local sid = record.BODY.space_id
            if (lsn < from) or
               (sid < 512 and show_system == false) or
               (spaces ~= nil and find_space(sid, spaces)) then
                -- pass this tuple
            elseif lsn >= to then
                -- stop, as we've had finished reading tuple with lsn == to
                -- and next lsn's will be bigger
                break
            else
                local args, so = {}, remote.space[sid]
                if so == nil then
                    error(("No space #%s, stopping"):format(sid))
                end
                table.insert(args, so)
                table.insert(args, record.BODY.key)
                table.insert(args, record.BODY.tuple)
                table.insert(args, record.BODY.operations)
                so[record.HEADER.type:lower()](unpack(args))
            end
        end
    end
    remote:close()
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 },
    cat       = { func = exit_wrapper(cat),       process = process_remote },
    play      = { func = exit_wrapper(play),      process = process_remote }
}, {
    __index = function()
        log.error("Unknown command '%s'", command_name)
        usage()
    end
})

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, '|')

    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 connect URI",         arg[0])
        log.error("       %s cat [file ...]",      arg[0])
        log.error("       %s play URI [file ...]", arg[0])
        log.error("       %s help",                arg[0])
        log.error("")
        log.error("Options for cat/play:")
        log.error("    --space=<number>   filter by space number (may present more than once)")
        log.error("    --show-system      show tuples from system space")
        log.error("    --from=<number>    start xlog file from the specified lsn")
        log.error("    --to=<number>      stop on specified xlog lsn")
        log.error("    --format=<string>  cat output format ('yaml' [default], 'json', 'lua')")
        log.error("")
    end

    if default_file ~= nil then
        log.error("Config file: %s", default_file)
    end
    os.exit(1)
end
-- parse parameters and put result into positional/keyword_arguments
do
    local function keyword_arguments_populate(ka)
        ka                = ka                or {}
        ka.from           = ka.from           or 0
        ka.to             = ka.to             or -1ULL
        ka['show-system'] = ka['show-system'] or false
        ka.format         = ka.format         or 'yaml'
        return ka
    end

    -- returns command name, file list and named parameters
    local function parameters_parse(parameters)
        local command_name = table.remove(parameters, 1)
        local positional_arguments, keyword_arguments = {}, {}
        for k, v in pairs(parameters) do
            if type(k) == 'number' then
                positional_arguments[k] = v
            else
                keyword_arguments[k] = v
            end
        end
        return command_name, positional_arguments, keyword_arguments
    end

    local parameters = argparse(arg, {
        { 'space',       'number+' },
        { 'show-system', 'boolean' },
        { 'from',        'number'  },
        { 'to',          'number'  },
        { 'help',        'boolean' },
        { 'format',      'string'  }
    })

    local cmd_name
    cmd_name, positional_arguments, keyword_arguments = parameters_parse(parameters)
    if cmd_name == 'help' or parameters.help == true or #arg < 1 then usage() end
    keyword_arguments = keyword_arguments_populate(parameters)
end

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
