├── rebar3 ├── Makefile ├── .gitignore ├── include ├── clique_specs.hrl └── clique_status_types.hrl ├── .github └── workflows │ └── erlang.yml ├── src ├── clique_handler.erl ├── clique.app.src ├── clique_typecast.erl ├── clique_sup.erl ├── clique_nodes.erl ├── clique_spec.erl ├── clique_manager.erl ├── clique_app.erl ├── clique_status.erl ├── clique_writer.erl ├── clique_human_writer.erl ├── clique_usage.erl ├── clique_csv_writer.erl ├── clique_error.erl ├── clique_json_writer.erl ├── clique_test_group_leader.erl ├── clique_command.erl ├── clique.erl ├── clique_table.erl ├── clique_parser.erl └── clique_config.erl ├── rebar.config ├── LICENSE └── README.md /rebar3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basho/clique/HEAD/rebar3 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: compile rel cover test dialyzer 2 | REBAR=./rebar3 3 | 4 | compile: 5 | $(REBAR) compile 6 | 7 | clean: 8 | $(REBAR) clean 9 | 10 | cover: test 11 | $(REBAR) cover 12 | 13 | test: compile 14 | $(REBAR) as test do eunit 15 | 16 | dialyzer: 17 | $(REBAR) dialyzer 18 | 19 | xref: 20 | $(REBAR) xref 21 | 22 | check: test dialyzer xref 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rebar 2 | *.crashdump 3 | /.cache/ 4 | /.eqc* 5 | /.eunit/ 6 | /.rebar/ 7 | /.rebar3/ 8 | /_build/ 9 | /_checkouts 10 | /deps 11 | /rebar.lock 12 | 13 | # work environments 14 | *.bak 15 | *.dump 16 | *.iml 17 | *.plt 18 | *.sublime-project 19 | *.sublime-workspace 20 | *.tmp 21 | *.txt 22 | *_plt 23 | *~ 24 | .DS_Store 25 | .idea/ 26 | .project 27 | .settings/ 28 | .tm_properties 29 | erln8.config 30 | tmp/ 31 | 32 | # Erlang build/test artifacts 33 | *.app 34 | *.beam 35 | /doc/ 36 | /ebin/ 37 | log/ 38 | -------------------------------------------------------------------------------- /include/clique_specs.hrl: -------------------------------------------------------------------------------- 1 | %% This record represents the specification for a key-value argument 2 | %% or flag on the command line. 3 | -record(clique_spec, 4 | { 5 | key :: atom(), 6 | name :: string(), 7 | shortname :: char() | undefined, 8 | datatype :: cuttlefish_datatypes:datatype() | undefined, 9 | validator :: fun((term()) -> ok | err()) | undefined, 10 | typecast :: fun((string()) -> err() | term()) | undefined 11 | }). 12 | 13 | -type spec() :: #clique_spec{}. 14 | -------------------------------------------------------------------------------- /.github/workflows/erlang.yml: -------------------------------------------------------------------------------- 1 | name: Erlang CI 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | branches: [ develop ] 8 | 9 | 10 | jobs: 11 | 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | otp: 20 | - "25.1" 21 | - "24.3" 22 | - "22.3" 23 | 24 | container: 25 | image: erlang:${{ matrix.otp }} 26 | 27 | steps: 28 | - uses: lukka/get-cmake@latest 29 | - uses: actions/checkout@v2 30 | - name: Compile 31 | run: ./rebar3 compile 32 | - name: Run xref and dialyzer 33 | run: ./rebar3 do xref, dialyzer 34 | - name: Run eunit 35 | run: ./rebar3 as gha do eunit 36 | -------------------------------------------------------------------------------- /include/clique_status_types.hrl: -------------------------------------------------------------------------------- 1 | %% @doc The following types describe an abstract format for status information. 2 | %% Each type has a semantic, organizational meaning in a way similar to an html 3 | %% document. The difference here is that we want our format to use erlang 4 | %% data structures and types and be able to generate human readable output, json, 5 | %% csv and a subset of html, as well as other possible output. 6 | -type text() :: {text, iolist()}. 7 | -type status_list() :: {list, iolist(), [iolist()]} | {list, [iolist()]}. 8 | -type table() :: {table, [[{atom() | string(), term()}]]}. 9 | -type alert() :: {alert, [status_list() | table() | text()]}. 10 | -type usage() :: usage. 11 | -type elem() :: text() | status_list() | table() | alert() | usage(). 12 | -type status() :: [elem()]. 13 | -------------------------------------------------------------------------------- /src/clique_handler.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | -module(clique_handler). 21 | 22 | -callback register_cli() -> ok. 23 | -------------------------------------------------------------------------------- /src/clique.app.src: -------------------------------------------------------------------------------- 1 | %% -*- mode: erlang; erlang-indent-level: 4; indent-tabs-mode: nil -*- 2 | %% ------------------------------------------------------------------- 3 | %% 4 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 5 | %% 6 | %% This file is provided to you under the Apache License, 7 | %% Version 2.0 (the "License"); you may not use this file 8 | %% except in compliance with the License. You may obtain 9 | %% a copy of the License at 10 | %% 11 | %% http://www.apache.org/licenses/LICENSE-2.0 12 | %% 13 | %% Unless required by applicable law or agreed to in writing, 14 | %% software distributed under the License is distributed on an 15 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | %% KIND, either express or implied. See the License for the 17 | %% specific language governing permissions and limitations 18 | %% under the License. 19 | %% 20 | %% ------------------------------------------------------------------- 21 | 22 | {application, clique, [ 23 | {description, "A CLI library for Erlang"}, 24 | {vsn, git}, 25 | {applications, [kernel, stdlib, cuttlefish]}, 26 | {registered, [clique_manager, clique_sup]}, 27 | {mod, {clique_app, []}}, 28 | {env, []}, 29 | {modules, []}, 30 | {maintainers, ["Basho Technologies, Inc."]}, 31 | {licenses, ["Apache 2.0"]}, 32 | {links, [{"Github", "https://github.com/basho/clique"}]} 33 | ]}. 34 | -------------------------------------------------------------------------------- /src/clique_typecast.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | -module(clique_typecast). 21 | 22 | -export([to_node/1]). 23 | 24 | %% This typecast automatically checks if the node is in our cluster, 25 | %% since the atom will have to be registered 26 | -spec to_node(string()) -> node() | {error, bad_node}. 27 | to_node(Str) -> 28 | try 29 | Node = list_to_existing_atom(Str), 30 | case lists:member(Node, clique_nodes:nodes()) of 31 | true -> 32 | Node; 33 | false -> 34 | {error, bad_node} 35 | end 36 | catch error:badarg -> 37 | {error, bad_node} 38 | end. 39 | -------------------------------------------------------------------------------- /src/clique_sup.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | -module(clique_sup). 21 | -behaviour(supervisor). 22 | 23 | %% beahvior functions 24 | -export([start_link/0, 25 | init/1 26 | ]). 27 | 28 | -define(CHILD(I,Type), {I,{I,start_link,[]},permanent,brutal_kill,Type,[I]}). 29 | 30 | start_link() -> 31 | supervisor:start_link({local, ?MODULE}, ?MODULE, []). 32 | 33 | init([]) -> 34 | %% We want to take down the node if the process gets killed. The process 35 | %% does nothing besides create ets tables and register cuttlefish schemas. 36 | %% If we lose the tables we lose cli access. Therefore 37 | %% riak_core_console_manager should do no work outside of init/1. 38 | {ok, {{one_for_one, 0, 10}, [?CHILD(clique_manager, worker)]}}. 39 | -------------------------------------------------------------------------------- /rebar.config: -------------------------------------------------------------------------------- 1 | %% -*- mode: erlang; erlang-indent-level: 4; indent-tabs-mode: nil -*- 2 | %% ------------------------------------------------------------------- 3 | %% 4 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 5 | %% 6 | %% This file is provided to you under the Apache License, 7 | %% Version 2.0 (the "License"); you may not use this file 8 | %% except in compliance with the License. You may obtain 9 | %% a copy of the License at 10 | %% 11 | %% http://www.apache.org/licenses/LICENSE-2.0 12 | %% 13 | %% Unless required by applicable law or agreed to in writing, 14 | %% software distributed under the License is distributed on an 15 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | %% KIND, either express or implied. See the License for the 17 | %% specific language governing permissions and limitations 18 | %% under the License. 19 | %% 20 | %% ------------------------------------------------------------------- 21 | 22 | {erl_opts, [ 23 | warn_bif_clash, 24 | warn_export_all, 25 | warn_export_vars, 26 | warn_obsolete_guard, 27 | warn_unused_import, 28 | warnings_as_errors 29 | ]}. 30 | 31 | {minimum_otp_vsn, "22.0"}. 32 | 33 | {deps, [ 34 | {cuttlefish, 35 | {git, "https://github.com/basho/cuttlefish.git", 36 | {branch, "develop"} }} 37 | ]}. 38 | 39 | {profiles, [ 40 | 41 | {gha, [{erl_opts, [{d, 'GITHUBEXCLUDE'}]}]}, 42 | 43 | {test, [ 44 | {cover_enabled, true}, 45 | {erl_opts, [ 46 | debug_info, 47 | nowarn_deprecated_function, 48 | nowarn_export_all, 49 | nowarn_unused_function, 50 | warnings_as_errors, 51 | {d, 'BASHO_TEST'} 52 | ]} 53 | ]} 54 | 55 | ]}. 56 | 57 | {xref_checks,[undefined_function_calls,undefined_functions,locals_not_used, 58 | deprecated_function_calls, deprecated_functions]}. 59 | 60 | -------------------------------------------------------------------------------- /src/clique_nodes.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique_nodes). 22 | 23 | -export([ 24 | init/0, 25 | safe_rpc/4, 26 | nodes/0, 27 | register/1 28 | ]). 29 | 30 | -ifdef(TEST). 31 | -export([teardown/0]). 32 | -endif. 33 | 34 | -define(nodes_table, clique_nodes). 35 | 36 | init() -> 37 | _ = ets:new(?nodes_table, [public, named_table]), 38 | ok. 39 | 40 | -ifdef(TEST). 41 | -spec teardown() -> ok. 42 | teardown() -> 43 | _ = ets:delete(?nodes_table), 44 | ok. 45 | -endif. 46 | 47 | -spec register(fun()) -> true. 48 | register(Fun) -> 49 | ets:insert(?nodes_table, {nodes_fun, Fun}). 50 | 51 | -spec nodes() -> [node()]. 52 | nodes() -> 53 | [{nodes_fun, Fun}] = ets:lookup(?nodes_table, nodes_fun), 54 | Fun(). 55 | 56 | %% @doc Wraps an rpc:call/4 in a try/catch to handle the case where the 57 | %% 'rex' process is not running on the remote node. This is safe in 58 | %% the sense that it won't crash the calling process if the rex 59 | %% process is down. 60 | -spec safe_rpc(Node :: node(), Module :: atom(), Function :: atom(), Args :: [any()]) -> {'badrpc', any()} | any(). 61 | safe_rpc(Node, Module, Function, Args) -> 62 | try rpc:call(Node, Module, Function, Args) of 63 | Result -> 64 | Result 65 | catch 66 | exit:{noproc, _NoProcDetails} -> 67 | {badrpc, rpc_process_down} 68 | end. 69 | -------------------------------------------------------------------------------- /src/clique_spec.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2015 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | %% @doc Functions related to specifications of key-value arguments and 22 | %% flags. 23 | -module(clique_spec). 24 | -include("clique_specs.hrl"). 25 | 26 | -export([ 27 | make/1, 28 | key/1, 29 | name/1, 30 | shortname/1, 31 | datatype/1, 32 | validator/1, 33 | typecast/1 34 | ]). 35 | 36 | -type err() :: {error, term()}. 37 | 38 | %% @doc Creates a spec from a list of options. 39 | make({Key, Options}) -> 40 | Shortname = case proplists:get_value(shortname, Options) of 41 | %% Unwrap the shortname character so we can match 42 | %% more efficiently in the parser. 43 | [Char] -> Char; 44 | _ -> undefined 45 | end, 46 | #clique_spec{ 47 | key = Key, 48 | name = proplists:get_value(longname, Options, atom_to_list(Key)), 49 | shortname = Shortname, 50 | datatype = proplists:get_value(datatype, Options, string), 51 | validator = proplists:get_value(validator, Options), 52 | typecast = proplists:get_value(typecast, Options) 53 | }. 54 | 55 | key(#clique_spec{key=V}) -> V. 56 | 57 | name(#clique_spec{name=V}) -> V. 58 | 59 | shortname(#clique_spec{shortname=V}) -> V. 60 | 61 | datatype(#clique_spec{datatype=V}) -> V. 62 | 63 | validator(#clique_spec{validator=V}) -> V. 64 | 65 | typecast(#clique_spec{typecast=V}) -> V. 66 | -------------------------------------------------------------------------------- /src/clique_manager.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique_manager). 22 | 23 | -behaviour(gen_server). 24 | 25 | %% gen_server api and callbacks 26 | -export([start_link/0, 27 | init/1, 28 | handle_call/3, 29 | handle_cast/2, 30 | handle_info/2, 31 | terminate/2, 32 | code_change/3 33 | ]). 34 | 35 | -ifdef(TEST). 36 | -export([teardown/0]). 37 | -endif. 38 | 39 | -define(init_mods, [ 40 | clique_writer, 41 | clique_command, 42 | clique_usage, 43 | clique_config, 44 | clique_nodes 45 | ]). 46 | 47 | -record(state, {}). 48 | 49 | start_link() -> 50 | gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). 51 | 52 | %% Note that this gen_server only exists to create ets tables and keep them 53 | %% around indefinitely. If it dies once, the node will die, as riak-admin will 54 | %% functionality will no longer be available. However, since it discards all 55 | %% messages, it can only die if explicitly killed. 56 | %% 57 | init([]) -> 58 | lists:foreach(fun(M) -> ok = M:init() end, ?init_mods), 59 | {ok, #state{}}. 60 | 61 | handle_call(_Msg, _From, State) -> 62 | {reply, ok, State}. 63 | handle_cast(_Msg, State) -> 64 | {noreply, State}. 65 | handle_info(_Msg, State) -> 66 | {noreply, State}. 67 | terminate(_Reason, _State) -> 68 | ok. 69 | code_change(_OldVsn, State, _Extra) -> 70 | {ok, State}. 71 | 72 | -ifdef(TEST). 73 | -spec teardown() -> ok. 74 | teardown() -> 75 | lists:foreach(fun(M) -> 76 | catch M:teardown() 77 | end, lists:reverse(?init_mods)). 78 | -endif. 79 | -------------------------------------------------------------------------------- /src/clique_app.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique_app). 22 | 23 | -behaviour(application). 24 | 25 | %% Application callbacks 26 | -export([start/2, stop/1]). 27 | 28 | -ifdef(TEST). 29 | -include_lib("eunit/include/eunit.hrl"). 30 | -endif. 31 | 32 | %% =================================================================== 33 | %% Application callbacks 34 | %% =================================================================== 35 | 36 | start(_StartType, _StartArgs) -> 37 | clique_sup:start_link(). 38 | 39 | stop(_State) -> 40 | ok. 41 | 42 | %% =================================================================== 43 | %% Tests 44 | %% =================================================================== 45 | -ifdef(TEST). 46 | 47 | start_stop_test_() -> 48 | {setup, 49 | fun() -> 50 | LogDir = clique:create_test_dir(), 51 | ConLog = filename:join(LogDir, "console.log"), 52 | ErrLog = filename:join(LogDir, "error.log"), 53 | CrashLog = filename:join(LogDir, "crash.log"), 54 | application:load(sasl), 55 | application:set_env(sasl, errlog_type, error), 56 | application:load(lager), 57 | application:set_env(lager, crash_log, CrashLog), 58 | application:set_env(lager, handlers, [ 59 | {lager_console_backend, warn}, 60 | {lager_file_backend, [{file, ErrLog}, {level, warn}]}, 61 | {lager_file_backend, [{file, ConLog}, {level, debug}]}]), 62 | _ = clique:ensure_stopped(), 63 | LogDir 64 | end, 65 | fun clique:delete_test_dir/1, 66 | fun() -> 67 | Ret = application:ensure_all_started(clique), 68 | ?assertMatch({ok, _}, Ret), 69 | {_, Started} = Ret, 70 | lists:foreach(fun(App) -> 71 | ?assertEqual(ok, application:stop(App)) 72 | end, lists:reverse(Started)) 73 | end}. 74 | 75 | -endif. % TEST 76 | -------------------------------------------------------------------------------- /src/clique_status.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | -module(clique_status). 21 | 22 | %% API 23 | -export([parse/3, 24 | text/1, 25 | list/1, 26 | list/2, 27 | table/1, 28 | alert/1, 29 | is_status/1, 30 | usage/0]). 31 | 32 | -include("clique_status_types.hrl"). 33 | 34 | -export_type([status/0]). 35 | 36 | -spec parse(status(), fun(), Acc0 :: term()) -> term(). 37 | parse([], Fun, Acc) -> 38 | Fun(done, Acc); 39 | %% Alert is currently the only non-leaf element 40 | parse([{alert, Elem} | T], Fun, Acc) -> 41 | Acc1 = Fun(alert, Acc), 42 | Acc2 = parse(Elem, Fun, Acc1), 43 | Acc3 = Fun(alert_done, Acc2), 44 | parse(T, Fun, Acc3); 45 | %% Leaf elements 46 | parse([Elem | T], Fun, Acc) -> 47 | Acc1 = Fun(Elem, Acc), 48 | parse(T, Fun, Acc1). 49 | 50 | %% @doc Is the given value a status type? 51 | -spec is_status(any()) -> boolean(). 52 | is_status(L) when is_list(L) -> 53 | is_status(hd(L)); 54 | is_status({text, _}) -> 55 | true; 56 | is_status({list, _, _}) -> 57 | true; 58 | is_status({table, _, _}) -> 59 | true; 60 | is_status({alert, _}) -> 61 | true; 62 | is_status(_) -> 63 | false. 64 | 65 | -spec text(iolist()) -> text(). 66 | text(IoList) -> 67 | {text, IoList}. 68 | 69 | -spec list([iolist()]) -> status_list(). 70 | list(Values) -> 71 | {list, Values}. 72 | 73 | -spec list(iolist(), [iolist()]) -> status_list(). 74 | list(Title, Values) -> 75 | {list, Title, Values}. 76 | 77 | %% @doc A table is constructed from a list of proplists. Each proplist 78 | %% represents a row in the table. The keys in the first row represent 79 | %% column headers; each following row (proplist) must contain the same 80 | %% number of tagged tuples but the keys are ignored. 81 | -spec table([[{atom() | string(), term()}]]) -> table(). 82 | table(Proplists) -> 83 | {table, Proplists}. 84 | 85 | %% A list of elements 86 | -spec alert([status_list() | table() | text()]) -> alert(). 87 | alert(List) -> 88 | {alert, List}. 89 | 90 | %% @doc Using the usage construct, a clique run can indicate that 91 | %% clique should display status for the current level. 92 | usage() -> 93 | usage. 94 | -------------------------------------------------------------------------------- /src/clique_writer.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | %% @doc This module provides a central place to register different output writers 22 | %% with clique (e.g. human-readable, CSV, etc.) 23 | %% There are some built in, but we also allow applications to register their 24 | %% own custom writers if they so choose. 25 | -module(clique_writer). 26 | 27 | -define(writer_table, clique_writers). 28 | 29 | -define(BUILTIN_WRITERS, [ 30 | {"human", clique_human_writer}, 31 | {"csv", clique_csv_writer} 32 | ]). 33 | 34 | -export([ 35 | init/0, 36 | register/2, 37 | write/2 38 | ]). 39 | 40 | -ifdef(TEST). 41 | -export([teardown/0]). 42 | -endif. 43 | -include("clique_status_types.hrl"). 44 | 45 | %% TODO factor err type out into single clique:err() type - DRY! 46 | -type err() :: {error, term()}. 47 | 48 | %% First element of the return value is for stdout, second is stderr 49 | -callback write(status()) -> {iolist(), iolist()}. 50 | 51 | -spec init() -> ok. 52 | init() -> 53 | _ = ets:new(?writer_table, [public, named_table]), 54 | ets:insert(?writer_table, ?BUILTIN_WRITERS), 55 | %% We don't want to make mochiweb into a hard dependency, so only load 56 | %% the JSON writer if we have the mochijson2 module available: 57 | case code:which(mochijson2) of 58 | non_existing -> 59 | ok; 60 | _ -> 61 | ets:insert(?writer_table, {"json", clique_json_writer}), 62 | ok 63 | end. 64 | 65 | -ifdef(TEST). 66 | -spec teardown() -> ok. 67 | teardown() -> 68 | _ = ets:delete(?writer_table), 69 | ok. 70 | -endif. 71 | 72 | -spec register(string(), module()) -> true. 73 | register(Name, Module) -> 74 | ets:insert(?writer_table, {Name, Module}). 75 | 76 | -spec write(err() | clique_status:status(), string()) -> {iolist(), iolist()}. 77 | write(Status, Format) -> 78 | case ets:lookup(?writer_table, Format) of 79 | [{Format, Module}] -> 80 | Module:write(Status); 81 | [] -> 82 | Error = io_lib:format( 83 | "Invalid format ~p! Defaulted to human-readable.~n", [Format]), 84 | {Stdout, Stderr} = clique_human_writer:write(Status), 85 | {Stdout, [Stderr, "\n", Error]} 86 | end. 87 | -------------------------------------------------------------------------------- /src/clique_human_writer.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | -module(clique_human_writer). 21 | 22 | %% @doc This module provides callback functions to the status parsing code in 23 | %% clique_status:parse/3. It specifically formats the output for a human at the 24 | %% console and handles an opaque context passed back during parsing. 25 | 26 | -behavior(clique_writer). 27 | 28 | %% API 29 | -export([write/1]). 30 | 31 | -include("clique_status_types.hrl"). 32 | 33 | -record(context, {alert_set=false :: boolean(), 34 | output="" :: iolist()}). 35 | 36 | -spec write(status()) -> {iolist(), iolist()}. 37 | write(Status) -> 38 | Ctx = clique_status:parse(Status, fun write_status/2, #context{}), 39 | {Ctx#context.output, []}. 40 | 41 | %% @doc Write status information in console format. 42 | -spec write_status(elem(), #context{}) -> #context{}. 43 | write_status(alert, Ctx=#context{alert_set=false}) -> 44 | Ctx#context{alert_set=true}; 45 | write_status(alert, Ctx) -> 46 | %% TODO: Should we just return an error instead? 47 | throw({error, nested_alert, Ctx}); 48 | write_status(alert_done, Ctx) -> 49 | Ctx#context{alert_set=false}; 50 | write_status({list, Data}, Ctx=#context{output=Output}) -> 51 | Ctx#context{output=Output++write_list(Data)}; 52 | write_status({list, Title, Data}, Ctx=#context{output=Output}) -> 53 | Ctx#context{output=Output++write_list(Title, Data)}; 54 | write_status({text, Text}, Ctx=#context{output=Output}) -> 55 | Ctx#context{output=Output++Text++"\n"}; 56 | write_status({table, Rows}, Ctx=#context{output=Output}) -> 57 | Ctx#context{output=Output++write_table(Rows)}; 58 | write_status(done, Ctx) -> 59 | Ctx. 60 | 61 | -spec write_table([{iolist(), iolist()}]) -> iolist(). 62 | write_table([]) -> 63 | ""; 64 | write_table(Rows0) -> 65 | Schema = [Name || {Name, _Val} <- hd(Rows0)], 66 | Rows = [[Val || {_Name, Val} <- Row] || Row <- Rows0], 67 | Table = clique_table:autosize_create_table(Schema, Rows), 68 | io_lib:format("~ts~n", [Table]). 69 | 70 | %% @doc Write a list horizontally 71 | write_list(Title, Items) when is_atom(Title) -> 72 | write_list(atom_to_list(Title), Items); 73 | %% Assume all items are of same type 74 | write_list(Title, Items) when is_atom(hd(Items)) -> 75 | Items2 = [atom_to_list(Item) || Item <- Items], 76 | write_list(Title, Items2); 77 | write_list(Title, Items) -> 78 | %% Todo: add bold/color for Title when supported 79 | Title ++ ":" ++ write_list(Items) ++ "\n". 80 | 81 | write_list(Items) -> 82 | lists:foldl(fun(Item, Acc) -> 83 | Acc++" "++Item 84 | end, "", Items). 85 | -------------------------------------------------------------------------------- /src/clique_usage.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique_usage). 22 | 23 | %% API 24 | -export([ 25 | init/0, 26 | find/1, 27 | register/2, 28 | print/1 29 | ]). 30 | 31 | -export_type([usage/0]). 32 | 33 | -ifdef(TEST). 34 | %-compile(export_all). 35 | -export([teardown/0]). 36 | -include_lib("eunit/include/eunit.hrl"). 37 | -endif. 38 | 39 | -define(usage_table, clique_usage). 40 | -define(usage_prefix, "Usage: "). 41 | 42 | -type err() :: {error, term()}. 43 | -type usage_function() :: fun(() -> iolist()). 44 | -type usage() :: iolist() | usage_function(). 45 | 46 | init() -> 47 | _ = ets:new(?usage_table, [public, named_table]), 48 | ok. 49 | 50 | -ifdef(TEST). 51 | -spec teardown() -> ok. 52 | teardown() -> 53 | _ = ets:delete(?usage_table), 54 | ok. 55 | -endif. 56 | 57 | %% @doc Register usage for a given command sequence. Lookups are by longest 58 | %% match. 59 | -spec register([string()], usage()) -> true. 60 | register(Cmd, Usage) -> 61 | ets:insert(?usage_table, {Cmd, Usage}). 62 | 63 | -spec print(iolist()) -> ok. 64 | print(Cmd = [Script, "describe" | _]) -> 65 | Usage = clique_error:format(Script, {error, describe_no_args}), 66 | clique:print(Usage, Cmd); 67 | print(Cmd = [Script, "show" | _]) -> 68 | Usage = clique_error:format(Script, {error, show_no_args}), 69 | clique:print(Usage, Cmd); 70 | print(Cmd = [Script, "set" | _]) -> 71 | Usage = clique_error:format(Script, {error, set_no_args}), 72 | clique:print(Usage, Cmd); 73 | print(Cmd) -> 74 | Usage = case find(Cmd) of 75 | {error, Error} -> 76 | Error; 77 | Usage2 -> 78 | Usage2 79 | end, 80 | io:format("~s", [Usage]). 81 | 82 | -spec find(iolist()) -> iolist() | err(). 83 | find([]) -> 84 | {error, "Error: Usage information not found for the given command\n\n"}; 85 | find(Cmd) -> 86 | case ets:lookup(?usage_table, Cmd) of 87 | [{Cmd, Fun}] when is_function(Fun) -> 88 | [?usage_prefix, Fun()]; 89 | [{Cmd, Usage}] -> 90 | [?usage_prefix, Usage]; 91 | [] -> 92 | Cmd2 = lists:reverse(tl(lists:reverse(Cmd))), 93 | find(Cmd2) 94 | end. 95 | 96 | -ifdef(TEST). 97 | find_different_types_test_() -> 98 | {setup, 99 | fun init/0, 100 | fun(_) -> teardown() end, 101 | ?_test(begin 102 | String = "clique foo [-f]\n", 103 | Fun = fun() -> String end, 104 | ?MODULE:register(["fun", "usage"], Fun), 105 | ?MODULE:register(["string", "usage"], String), 106 | ?assertEqual([?usage_prefix, String], find(["fun", "usage"])), 107 | ?assertEqual([?usage_prefix, String], find(["string", "usage"])), 108 | ?assertMatch({error, _}, find(["foo"])) 109 | end)}. 110 | -endif. 111 | -------------------------------------------------------------------------------- /src/clique_csv_writer.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | -module(clique_csv_writer). 21 | 22 | %% @doc Implements a writer module for clique which outputs tabular data in CSV format. 23 | 24 | -behavior(clique_writer). 25 | 26 | -export([ 27 | write/1, 28 | write_status/2 29 | ]). 30 | 31 | -include("clique_status_types.hrl"). 32 | 33 | -spec write(status()) -> {iolist(), iolist()}. 34 | write(Status) -> 35 | %% First, pull out any alerts so that we can feed them 36 | %% to the human writer and print them out on stderr: 37 | {Alerts, NonAlerts} = lists:partition(fun({alert, _}) -> true; 38 | (_) -> false 39 | end, Status), 40 | Output = clique_status:parse(NonAlerts, fun write_status/2, []), 41 | AlertOutput = [element(1, clique_human_writer:write(A)) || {alert, A} <- Alerts], 42 | {lists:reverse(Output), AlertOutput}. 43 | 44 | %% @doc Write status information in csv format. 45 | %% 46 | %% Anything other than a table is discarded, since there's no good way to represent non-tabular 47 | %% data in CSV. We want to be able to use the CSV output directly without needing to strip 48 | %% off any extranious stuff. 49 | -spec write_status(elem(), iolist()) -> iolist(). 50 | write_status({table, Rows}, Output) -> 51 | [write_table(Rows) | Output]; 52 | write_status(_, Output) -> 53 | Output. 54 | 55 | write_table([]) -> 56 | ""; 57 | write_table(Rows0) -> 58 | Schema = [Name || {Name, _Val} <- hd(Rows0)], 59 | Header = write_header(Schema), 60 | Rows = write_rows([[Val || {_Name, Val} <- Row] || Row <- Rows0]), 61 | [Header, Rows]. 62 | 63 | write_header(Schema) -> 64 | HeaderStrs = [format_val(Name) || Name <- Schema], 65 | [string:join(HeaderStrs, ","), "\r\n"]. 66 | 67 | write_rows(Rows) -> 68 | [write_row(R) || R <- Rows]. 69 | 70 | write_row(Row) -> 71 | ValStrs = [format_val(V) || V <- Row], 72 | [string:join(ValStrs, ","), "\r\n"]. 73 | 74 | format_val(V) when is_atom(V) -> 75 | format_val(atom_to_list(V)); 76 | format_val(V) when is_integer(V) -> 77 | format_val(integer_to_list(V)); 78 | format_val(V) when is_binary(V) -> 79 | format_val(unicode:characters_to_list(V, utf8)); 80 | format_val(Str0) when is_list(Str0) -> 81 | %% TODO: This could probably be done more efficiently. 82 | %% Maybe we could write a strip func that works directly on iolists? 83 | Str = string:strip(binary_to_list(iolist_to_binary(Str0))), 84 | %% If we have any line breaks, double quotes, or commas, we must surround the value with 85 | %% double quotes. 86 | IsEvilChar = fun(C) -> lists:member(C, [$\r, $\n, $", $,]) end, 87 | case lists:any(IsEvilChar, Str) of 88 | true -> 89 | [$", escape(Str), $"]; 90 | false -> 91 | Str 92 | end. 93 | 94 | escape(Str) -> 95 | %% According to RFC 4180, any double quote chars in quoted fields 96 | %% should be escaped with extra double quotes. e.g. "aaa","b""bb","ccc" 97 | SplitStr = string:tokens(Str, "\""), 98 | string:join(SplitStr, "\"\""). 99 | -------------------------------------------------------------------------------- /src/clique_error.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | -module(clique_error). 21 | 22 | %% API 23 | -export([format/2, 24 | badrpc_to_error/2]). 25 | 26 | -type status() :: clique_status:status(). 27 | -type err() :: {error, term()}. 28 | 29 | -spec format(string(), err()) -> status(). 30 | format(Cmd, {error, show_no_args}) -> 31 | status(io_lib:format("Usage: ~ts show ... [[--node | -n] | --all]", [Cmd])); 32 | format(Cmd, {error, describe_no_args}) -> 33 | status(io_lib:format("Usage: ~ts describe ...", [Cmd])); 34 | format(Cmd, {error, set_no_args}) -> 35 | status(io_lib:format("Usage: ~ts set = ... [[--node | -n] | --all]", 36 | [Cmd])); 37 | format(_Cmd, {error, {no_matching_spec, Cmd}}) -> 38 | case clique_usage:find(Cmd) of 39 | {error, ErrorString} -> 40 | status(ErrorString); 41 | Usage -> 42 | status(io_lib:format("~ts", [Usage])) 43 | end; 44 | format(_Cmd, {error, {invalid_flag, Str}}) -> 45 | status(io_lib:format("Invalid flag: ~p", [Str])); 46 | format(_Cmd, {error, {invalid_action, Str}}) -> 47 | status(io_lib:format("Invalid action: ~p", [Str])); 48 | format(_Cmd, {error, invalid_number_of_args}) -> 49 | status("Invalid number of arguments"); 50 | format(_Cmd, {error, {invalid_key, Str}}) -> 51 | status(io_lib:format("Invalid key: ~p", [Str])); 52 | format(_Cmd, {error, {invalid_argument, Str}}) -> 53 | status(io_lib:format("Invalid argument: ~p", [Str])); 54 | format(_Cmd, {error, {invalid_args, Args}}) -> 55 | Arglist = lists:map(fun({Key, Val}) -> io_lib:format("~ts=~ts ", [Key, Val]) end, Args), 56 | status(io_lib:format("Invalid arguments: ~ts", [Arglist])); 57 | format(_Cmd, {error, {invalid_flags, Flags}}) -> 58 | status(io_lib:format("Invalid Flags: ~p", [Flags])); 59 | format(_Cmd, {error, {invalid_flag_value, {Name, Val}}}) -> 60 | status(io_lib:format("Invalid value: ~p for flag: ~p", [Val, Name])); 61 | format(_Cmd, {error, {invalid_kv_arg, Arg}}) -> 62 | status(io_lib:format("Empty value in argument: ~p", [Arg])); 63 | format(_Cmd, {error, {invalid_flag_combination, Msg}}) -> 64 | status(io_lib:format("Error: ~ts", [Msg])); 65 | format(_Cmd, {error, {invalid_value, Val}}) -> 66 | status(io_lib:format("Invalid value: ~p", [Val])); 67 | format(_Cmd, {error, {invalid_config_keys, Invalid}}) -> 68 | status(io_lib:format("Invalid config keys: ~ts", [Invalid])); 69 | format(_Cmd, {error, {invalid_config, {error, [_H|_T]=Msgs}}}) -> 70 | %% Cuttlefish deeply nested errors (original cuttlefish) 71 | status(string:join(lists:map(fun({error, Msg}) -> Msg end, 72 | Msgs), "\n")); 73 | format(_Cmd, {error, {invalid_config, {errorlist, Errors}}}) -> 74 | %% Cuttlefish deeply nested errors (new cuttlefish error scheme) 75 | status(string:join(lists:map(fun error_map/1, Errors), "\n")); 76 | format(_Cmd, {error, {invalid_config, Msg}}) -> 77 | status(io_lib:format("Invalid configuration: ~p~n", [Msg])); 78 | format(_Cmd, {error, {rpc_process_down, Node}}) -> 79 | status(io_lib:format("Target process could not be reached on node: ~p~n", [Node])); 80 | format(_Cmd, {error, {config_not_settable, Keys}}) -> 81 | status(io_lib:format("The following config keys are not settable: ~p~n", [Keys])); 82 | format(_Cmd, {error, {nodedown, Node}}) -> 83 | status(io_lib:format("Target node is down: ~p~n", [Node])); 84 | format(_Cmd, {error, bad_node}) -> 85 | status("Invalid node name"); 86 | format(_Cmd, {error, {{badrpc, Reason}, Node}}) -> 87 | status(io_lib:format("RPC to node ~p failed for reason: ~p~n", [Node, Reason])); 88 | format(_Cmd, {error, {conversion, _}}=TypeError) -> 89 | %% Type-conversion error originating in cuttlefish 90 | status(cuttlefish_error:xlate(TypeError)). 91 | 92 | -spec badrpc_to_error(string() | node(), term()) -> err(). 93 | badrpc_to_error(Node, nodedown) -> 94 | {error, {nodedown, Node}}; 95 | badrpc_to_error(Node, rpc_process_down) -> 96 | {error, {rpc_process_down, Node}}; 97 | badrpc_to_error(Node, Reason) -> 98 | {error, {{badrpc, Reason}, Node}}. 99 | 100 | -spec status(string()) -> status(). 101 | status(Str) -> 102 | [clique_status:alert([clique_status:text(Str)])]. 103 | 104 | %% Here we can override cuttlefish error messages to make them more 105 | %% useful in an interactive context 106 | -spec error_map(cuttlefish_error:error()) -> iolist(). 107 | error_map({error, {unknown_variable, Variable}}) -> 108 | io_lib:format("Unknown variable: ~ts", [Variable]); 109 | error_map({error, ErrorTerm}) -> 110 | cuttlefish_error:xlate(ErrorTerm). 111 | -------------------------------------------------------------------------------- /src/clique_json_writer.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2015-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique_json_writer). 22 | 23 | %% @doc Write status information in JSON format. 24 | %% 25 | %% The current clique -> JSON translation looks something like this: 26 | %% ``` 27 | %% {text, "hello world"} -> 28 | %% {"type" : "text", "text" : "hello world"} 29 | %% {text, [<>, $l, "lo", ["world"]]} -> 30 | %% {"type" : "text", "text" : "hello world"} 31 | %% {list, ["a", "b", <<"c">>]} -> 32 | %% {"type" : "list", "list" : ["a", "b", "c"]} 33 | %% {list, "Camels", ["Dromedary", "Bactrian", "Sopwith"] -> 34 | %% {"type" : "list", "title" : "Camels", "list" : ["Dromedary", "Bactrian", "Sopwith"]} 35 | %% {alert, [{text, "Shields failing!"}]} -> 36 | %% {"type" : "alert", "alert" : [{"type" : "text", "text" : "Shields failing!"}]} 37 | %% usage -> 38 | %% {"type" : "usage", 39 | %% "usage" : "Usage: riak-admin cluster self-destruct [--delay ]"} 40 | %% {table, [[{name, "Nick"}, {species, "human"}], [{name, "Rowlf"}, {species, "dog"}]]} -> 41 | %% {"type" : "table", 42 | %% "table" : [{"name" : "Nick", "species" : "human"}, {"name", "Rowlf", "species", "dog"}]} 43 | %% ''' 44 | 45 | -behavior(clique_writer). 46 | 47 | -export([write/1]). 48 | 49 | -include("clique_status_types.hrl"). 50 | 51 | -record(context, {alert_set=false :: boolean(), 52 | alert_list=[] :: [elem()], 53 | output=[] :: iolist()}). 54 | 55 | -spec write(status()) -> {iolist(), iolist()}. 56 | write(Status) -> 57 | % make xref and dialyzer happy 58 | JsonMod = mochijson2, 59 | PreparedOutput = lists:reverse(prepare(Status)), 60 | {[JsonMod:encode(PreparedOutput), "\n"], []}. 61 | 62 | %% @doc Returns status data that's been prepared for conversion to JSON. 63 | %% Just reverse the list and pass it to mochijson2:encode and you're set. 64 | prepare(Status) -> 65 | Ctx = clique_status:parse(Status, fun prepare_status/2, #context{}), 66 | Ctx#context.output. 67 | 68 | %% @doc Write status information in JSON format. 69 | -spec prepare_status(elem(), #context{}) -> #context{}. 70 | prepare_status(alert, Ctx=#context{alert_set=true}) -> 71 | %% TODO: Should we just return an error instead? 72 | throw({error, nested_alert, Ctx}); 73 | prepare_status(alert, Ctx) -> 74 | Ctx#context{alert_set=true}; 75 | prepare_status(alert_done, Ctx = #context{alert_list=AList, output=Output}) -> 76 | %% AList is already reversed, and prepare returns reversed output, so they cancel out 77 | AlertJsonVal = prepare(AList), 78 | AlertJson = {struct, [{<<"type">>, <<"alert">>}, {<<"alert">>, AlertJsonVal}]}, 79 | Ctx#context{alert_set=false, alert_list=[], output=[AlertJson | Output]}; 80 | prepare_status(Term, Ctx=#context{alert_set=true, alert_list=AList}) -> 81 | Ctx#context{alert_list=[Term | AList]}; 82 | prepare_status({list, Data}, Ctx=#context{output=Output}) -> 83 | Ctx#context{output=[prepare_list(Data) | Output]}; 84 | prepare_status({list, Title, Data}, Ctx=#context{output=Output}) -> 85 | Ctx#context{output=[prepare_list(Title, Data) | Output]}; 86 | prepare_status({text, Text}, Ctx=#context{output=Output}) -> 87 | Ctx#context{output=[prepare_text(Text) | Output]}; 88 | prepare_status({table, Rows}, Ctx=#context{output=Output}) -> 89 | Ctx#context{output=[prepare_table(Rows) | Output]}; 90 | prepare_status(done, Ctx) -> 91 | Ctx. 92 | 93 | prepare_list(Data) -> 94 | prepare_list(undefined, Data). 95 | 96 | prepare_list(Title, Data) -> 97 | FlattenedData = [erlang:iolist_to_binary(S) || S <- Data], 98 | TitleProp = case Title of 99 | undefined -> 100 | []; 101 | _ -> 102 | [{<<"title">>, erlang:iolist_to_binary(Title)}] 103 | end, 104 | Props = lists:flatten([{<<"type">>, <<"list">>}, TitleProp, {<<"list">>, FlattenedData}]), 105 | {struct, Props}. 106 | 107 | prepare_text(Text) -> 108 | {struct, [{<<"type">>, <<"text">>}, {<<"text">>, erlang:iolist_to_binary(Text)}]}. 109 | 110 | prepare_table(Rows) -> 111 | TableData = [prepare_table_row(R) || R <- Rows], 112 | {struct, [{<<"type">>, <<"table">>}, {<<"table">>, TableData}]}. 113 | 114 | prepare_table_row(Row) -> 115 | [{key_to_binary(K), prepare_table_value(V)} || {K, V} <- Row]. 116 | 117 | key_to_binary(Key) when is_atom(Key) -> 118 | list_to_binary(atom_to_list(Key)); 119 | key_to_binary(Key) when is_list(Key) -> 120 | list_to_binary(Key). 121 | 122 | prepare_table_value(Value) when is_list(Value) -> 123 | %% TODO: This could definitely be done more efficiently. 124 | %% Maybe we could write a strip func that works directly on iolists? 125 | list_to_binary(string:strip(binary_to_list(iolist_to_binary(Value)))); 126 | prepare_table_value(Value) -> 127 | Value. 128 | -------------------------------------------------------------------------------- /src/clique_test_group_leader.erl: -------------------------------------------------------------------------------- 1 | %% @doc Implements an io server that can be used as a group leader in 2 | %% testing, and supports changing the size of the io device. 3 | -module(clique_test_group_leader). 4 | 5 | -export([new_group_leader/0, 6 | get_output/1, 7 | set_size/3, 8 | stop/1]). 9 | 10 | -export([init/1, 11 | handle_call/3, 12 | handle_cast/2, 13 | handle_info/2, 14 | terminate/2, 15 | code_change/3]). 16 | 17 | -behaviour(gen_server). 18 | 19 | -record(state, {output=[], size={80,20}}). 20 | 21 | -ifdef(TEST). 22 | -include_lib("eunit/include/eunit.hrl"). 23 | -endif. 24 | 25 | 26 | %%----------------- 27 | %% API Functions 28 | %%---------------- 29 | 30 | %% @doc Spawns the new group leader and makes it the current 31 | %% group_leader, linking it to the current process. 32 | -spec new_group_leader() -> pid(). 33 | new_group_leader() -> 34 | %% OldLeader = group_leader(), 35 | {ok, Pid} = gen_server:start_link(?MODULE, [], []), 36 | group_leader(Pid, self()), 37 | Pid. 38 | 39 | %% @doc Gets the output captured by the group leader. 40 | -spec get_output(pid()) -> iodata(). 41 | get_output(Pid) -> 42 | gen_server:call(Pid, get_output, infinity). 43 | 44 | %% @doc Sets the dimensions of the group leader's output. 45 | %% @see io:columns/0 46 | %% @see io:rows/0 47 | -spec set_size(pid(), pos_integer(), pos_integer()) -> ok. 48 | set_size(Pid, Cols, Rows) -> 49 | gen_server:call(Pid, {set_size, Cols, Rows}, infinity). 50 | 51 | %% @doc Stops the group leader process. 52 | -spec stop(pid()) -> ok. 53 | stop(Pid) -> 54 | gen_server:cast(Pid, stop). 55 | 56 | %%----------------- 57 | %% gen_server callbacks 58 | %%---------------- 59 | 60 | init([]) -> 61 | {ok, #state{}}. 62 | 63 | handle_call(get_output, _From, #state{output=Out}=State) -> 64 | {reply, lists:reverse(Out), State}; 65 | handle_call({set_size, Cols, Rows}, _From, State) when is_integer(Cols), 66 | is_integer(Rows), 67 | Cols > 0, 68 | Rows > 0 -> 69 | {reply, ok, State#state{size={Cols, Rows}}}; 70 | handle_call({set_size, _, _}, _From, State) -> 71 | {reply, {error, invalid_size}, State}; 72 | handle_call(_, _, State) -> 73 | {reply, {error, bad_call}, State}. 74 | 75 | handle_cast(stop, State) -> 76 | {stop, normal, State}; 77 | handle_cast(_, State) -> 78 | {noreply, State}. 79 | 80 | handle_info({io_request, From, ReplyAs, Req}, State) -> 81 | P = process_flag(priority, normal), 82 | %% run this part under normal priority always 83 | NewState = io_request(From, ReplyAs, Req, State), 84 | process_flag(priority, P), 85 | {noreply, NewState}; 86 | handle_info(_, State) -> 87 | {noreply, State}. 88 | 89 | terminate(_, _State) -> 90 | ok. 91 | 92 | code_change(_OldVsn, State, _Extra) -> 93 | {ok, State}. 94 | 95 | %%----------------- 96 | %% Internal API 97 | %%---------------- 98 | io_request(From, ReplyAs, Req, State) -> 99 | {Reply, NewState} = io_request(Req, State), 100 | _ = io_reply(From, ReplyAs, Reply), 101 | NewState. 102 | 103 | %% sends a reply back to the sending process 104 | io_reply(From, ReplyAs, Reply) -> 105 | From ! {io_reply, ReplyAs, Reply}. 106 | 107 | %% Handles io requests 108 | %% Output: 109 | io_request({put_chars, Chars}, 110 | #state{output=O}=State) when is_binary(Chars); 111 | is_list(Chars) -> 112 | {ok, State#state{output=[Chars|O]}}; 113 | io_request({put_chars, M, F, A}, State) -> 114 | try apply(M, F, A) of 115 | Chars -> 116 | io_request({put_chars, Chars}, State) 117 | catch Class:Reason:Stacktrace -> 118 | {{error, {Class,Reason, Stacktrace}}, State} 119 | end; 120 | io_request({put_chars, _Enc, Chars}, State) -> 121 | io_request({put_chars, Chars}, State); 122 | io_request({put_chars, _Enc, Mod, Func, Args}, State) -> 123 | io_request({put_chars, Mod, Func, Args}, State); 124 | %% Terminal geometry: 125 | io_request({get_geometry,columns}, #state{size={Cols,_}}=State) -> 126 | {Cols, State}; 127 | io_request({get_geometry,rows}, #state{size={_,Rows}}=State) -> 128 | {Rows, State}; 129 | %% Input (unsupported): 130 | io_request({get_chars, _Enc, _Prompt, _N}, State) -> 131 | {eof, State}; 132 | io_request({get_chars, _Prompt, _N}, State) -> 133 | {eof, State}; 134 | io_request({get_line, _Prompt}, State) -> 135 | {eof, State}; 136 | io_request({get_line, _Enc, _Prompt}, State) -> 137 | {eof, State}; 138 | io_request({get_until, _Prompt, _M, _F, _As}, State) -> 139 | {eof, State}; 140 | %% Options: 141 | io_request({setopts, _Opts}, State) -> 142 | {{error, enotsup}, State}; 143 | io_request(getopts, State) -> 144 | {{error, enotsup}, State}; 145 | %% Multi-requests: 146 | io_request({requests, Reqs}, State) -> 147 | io_requests(Reqs, {ok, State}); 148 | %% Ignore all other messages: 149 | io_request(_, State) -> 150 | {{error, request}, State}. 151 | 152 | io_requests([R | Rs], {ok, State}) -> 153 | io_requests(Rs, io_request(R, State)); 154 | io_requests(_, Result) -> 155 | Result. 156 | 157 | -ifdef(TEST). 158 | -define(GL(T), 159 | begin 160 | Res = setup(), 161 | try 162 | T 163 | after 164 | cleanup(Res) 165 | end 166 | end). 167 | 168 | setup() -> 169 | OldLeader = group_leader(), 170 | Leader = new_group_leader(), 171 | {OldLeader, Leader}. 172 | 173 | cleanup({OldLeader, Leader}) -> 174 | group_leader(OldLeader, self()), 175 | io:format("CAPTURED: ~s", [get_output(Leader)]), 176 | stop(Leader). 177 | 178 | leader_test_() -> 179 | [ 180 | {"group leader captures output, readable with get_output", fun test_capture/0}, 181 | {"group leader does not support input", fun test_no_input/0}, 182 | {"set_size/3 changes IO geometry", fun test_set_size/0} 183 | ]. 184 | 185 | test_capture() -> 186 | ?GL(begin 187 | ?assertEqual(ok, io:put_chars(<<"line 1\n">>)), 188 | ?assertEqual(ok, io:put_chars(<<"line 2\n">>)), 189 | ?assertEqual([<<"line 1\n">>,<<"line 2\n">>], 190 | get_output(group_leader())) 191 | end). 192 | 193 | test_set_size() -> 194 | ?GL(begin 195 | ?assertEqual({ok, 80}, io:columns()), 196 | ?assertEqual({ok, 20}, io:rows()), 197 | set_size(group_leader(), 202, 29), 198 | ?assertEqual({ok, 202}, io:columns()), 199 | ?assertEqual({ok, 29}, io:rows()) 200 | end). 201 | 202 | test_no_input() -> 203 | ?GL(begin 204 | ?assertEqual(ok, io:put_chars(<<"line 1\n">>)), 205 | ?assertEqual(eof, io:get_chars("> ", 10)) 206 | end). 207 | -endif. 208 | -------------------------------------------------------------------------------- /src/clique_command.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique_command). 22 | 23 | %% API 24 | -export([ 25 | init/0, 26 | run/1, 27 | match/1, 28 | register/4 29 | ]). 30 | 31 | -ifdef(TEST). 32 | -export([teardown/0]). 33 | -endif. 34 | -include("clique_specs.hrl"). 35 | 36 | -define(cmd_table, clique_commands). 37 | 38 | -type err() :: {error, term()}. 39 | -type proplist() :: [{atom(), term()}]. 40 | -type status() :: clique_status:status(). 41 | 42 | -define(SET_CMD_SPEC, { 43 | ["_", "set"], '_', clique_config:config_flags(), 44 | fun(_, SetArgs, SetFlags) -> 45 | clique_config:set(SetArgs, SetFlags) 46 | end}). 47 | 48 | init() -> 49 | _ = ets:new(?cmd_table, [public, named_table]), 50 | ok. 51 | 52 | -ifdef(TEST). 53 | -spec teardown() -> ok. 54 | teardown() -> 55 | _ = ets:delete(?cmd_table), 56 | ok. 57 | -endif. 58 | 59 | %% @doc Register a cli command (i.e.: "riak-admin handoff status") 60 | -spec register(['*' | string()], '_' | list(), list(), fun()) -> ok | {error, atom()}. 61 | register(Cmd, Keys0, Flags0, Fun) -> 62 | case verify_register(Cmd) of 63 | ok -> 64 | Keys = case Keys0 of 65 | '_' -> '_'; 66 | _ -> make_specs(Keys0) 67 | end, 68 | Flags = make_specs(Flags0), 69 | ets:insert(?cmd_table, {Cmd, Keys, Flags, Fun}), 70 | ok; 71 | {error, Err} -> 72 | error_logger:info_report([{warning, "Clique command registration failed"}, 73 | {reason, Err}, 74 | {command, Cmd}, 75 | {keys, Keys0}, 76 | {flags, Flags0}]), 77 | {error, Err} 78 | end. 79 | 80 | verify_register(Cmd) -> 81 | %% Only thing we currently verify is whether any/all wildcard '*' atoms are grouped at the end 82 | CmdTail = lists:dropwhile(fun(E) -> E =/= '*' end, Cmd), 83 | case lists:any(fun(E) -> E =/= '*' end, CmdTail) of 84 | true -> 85 | {error, bad_wildcard_placement}; 86 | false -> 87 | ok 88 | end. 89 | 90 | -spec run(err()) -> err(); 91 | ({fun(), [string()], clique_parser:args(), clique_parser:flags(), 92 | clique_parser:flags()}) -> {usage | {error, term()} | status(), integer(), string()}. 93 | run({error, _} = E) -> 94 | E; 95 | run({Fun, Cmd, Args, Flags, GlobalFlags}) -> 96 | Format = proplists:get_value(format, GlobalFlags, "human"), 97 | case proplists:is_defined(help, GlobalFlags) of 98 | true -> 99 | {usage, 0, Format}; 100 | false -> 101 | case Fun(Cmd, Args, Flags) of 102 | {exit_status, ExitStatus, Result} -> 103 | {Result, ExitStatus, Format}; 104 | Error = {error, _} -> 105 | {Error, 1, Format}; 106 | Result -> 107 | {Result, 0, Format} 108 | end 109 | end. 110 | 111 | -spec match([list()]) -> {tuple(), list()} | {error, no_matching_spec}. 112 | match(Cmd0) -> 113 | {Cmd, Args} = split_command(Cmd0), 114 | %% Check for builtin commands first. If that fails, check our command table. 115 | case Cmd of 116 | [_Script, "set" | _] -> 117 | {?SET_CMD_SPEC, Args}; 118 | [_Script, "show" | _] -> 119 | Spec = cmd_spec(Cmd, fun clique_config:show/2, clique_config:config_flags()), 120 | {Spec, Args}; 121 | [_Script, "describe" | _] -> 122 | Spec = cmd_spec(Cmd, fun clique_config:describe/2, []), 123 | {Spec, Args}; 124 | _ -> 125 | case match_lookup(Cmd) of 126 | {match, Spec0} -> 127 | %% The matching spec will include the command as-registered, including 128 | %% wildcards, but we want to return back the actual command the user 129 | %% entered so that we can pass the correct stuff along to the cmd callback: 130 | Spec = setelement(1, Spec0, Cmd), 131 | {Spec, Args}; 132 | nomatch -> 133 | {error, {no_matching_spec, Cmd0}} 134 | end 135 | end. 136 | 137 | match_lookup(Cmd) -> 138 | case ets:lookup(?cmd_table, Cmd) of 139 | [Spec] -> 140 | {match, Spec}; 141 | [] -> 142 | %% To support wildcards in our command specs, we'll need to recurse through a 143 | %% series of ets:lookup calls, with each successive call being less restrictive. 144 | %% Start by pulling all the wildcards off of the tail: 145 | RevCmd = lists:reverse(Cmd), 146 | case lists:splitwith(fun(E) -> E =:= '*' end, RevCmd) of 147 | {_, []} -> 148 | %% At this point, everything is a wildcard, so bail out: 149 | nomatch; 150 | {Wildcards, [_H | T]} -> 151 | %% Convert the last non-wildcard element in the 152 | %% command to a wildcard, and try the match again: 153 | NextMatchAttempt = lists:reverse(T) ++ ['*' | Wildcards], 154 | match_lookup(NextMatchAttempt) 155 | end 156 | end. 157 | 158 | -spec split_command([list()]) -> {list(), list()}. 159 | split_command(Cmd0) -> 160 | lists:splitwith(fun(Str) -> 161 | clique_parser:is_not_kv_arg(Str) andalso 162 | clique_parser:is_not_flag(Str) 163 | end, Cmd0). 164 | 165 | 166 | -spec make_specs([{atom(), proplist()}]) -> [spec()]. 167 | make_specs(Specs) -> 168 | [clique_spec:make(Spec) || Spec <- Specs]. 169 | 170 | %% NB This is a bit sneaky. We normally only accept key/value args like 171 | %% "handoff.inbound=off" and flag-style arguments like "--node dev1@127.0.0.1" or "--all", 172 | %% but the builtin "show" and "describe" commands work a bit differently. 173 | %% To handle these special cases, we dynamically build a command spec to smuggle the 174 | %% arguments through the rest of the (otherwise cleanly designed and implemented) code. 175 | cmd_spec(Cmd, CmdFun, AllowedFlags) -> 176 | [_Script, _CmdName | CfgKeys] = Cmd, 177 | %% Discard key/val args passed in since we don't need them, and inject the freeform args: 178 | SpecFun = fun(_, [], Flags) -> CmdFun(CfgKeys, Flags) end, 179 | {Cmd, [], AllowedFlags, SpecFun}. 180 | -------------------------------------------------------------------------------- /src/clique.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique). 22 | 23 | %% API 24 | -export([register/1, 25 | register_node_finder/1, 26 | register_command/4, 27 | register_config/2, 28 | register_formatter/2, 29 | register_writer/2, 30 | register_config_whitelist/1, 31 | register_usage/2, 32 | run/1, 33 | print/2]). 34 | 35 | -ifdef(TEST). 36 | -export([ 37 | create_test_dir/0, 38 | delete_test_dir/1, 39 | ensure_stopped/0 40 | ]). 41 | -include_lib("eunit/include/eunit.hrl"). 42 | -endif. 43 | 44 | -type err() :: {error, term()}. 45 | 46 | -spec register([module()]) -> ok. 47 | register(Modules) -> 48 | _ = [M:register_cli() || M <- Modules], 49 | ok. 50 | 51 | %% @doc RPC calls when using the --all flag need a list of nodes to contact. 52 | %% However, using nodes() only provides currently connected nodes. We want to 53 | %% also report an alert for nodes that are not currently available instead of just 54 | %% ignoring them. This allows the caller to define how we find the list of 55 | %% cluster member nodes. 56 | -spec register_node_finder(fun()) -> true. 57 | register_node_finder(Fun) -> 58 | clique_nodes:register(Fun). 59 | 60 | %% @doc Register configuration callbacks for a given config key 61 | -spec register_config([string()], fun()) -> true. 62 | register_config(Key, Callback) -> 63 | clique_config:register(Key, Callback). 64 | 65 | %% @doc Register a configuration formatter for a given config key 66 | -spec register_formatter([string()], fun()) -> true. 67 | register_formatter(Key, Callback) -> 68 | clique_config:register_formatter(Key, Callback). 69 | 70 | %% @doc Register a module for writing output in a specific format 71 | -spec register_writer(string(), module()) -> true. 72 | register_writer(Name, Module) -> 73 | clique_writer:register(Name, Module). 74 | 75 | %% @doc Register a list of configuration variables that are settable. 76 | %% Clique disallows setting of all config variables by default. They must be in 77 | %% whitelist to be settable. 78 | -spec register_config_whitelist([string()]) -> ok | {error, {invalid_config_keys, [string()]}}. 79 | register_config_whitelist(SettableKeys) -> 80 | clique_config:whitelist(SettableKeys). 81 | 82 | %% @doc Register a cli command (e.g.: "riak-admin handoff status", or "riak-admin cluster join '*'") 83 | -spec register_command(['*' | string()], '_' | list(), list(), fun()) -> ok | {error, atom()}. 84 | register_command(Cmd, Keys, Flags, Fun) -> 85 | clique_command:register(Cmd, Keys, Flags, Fun). 86 | 87 | %% @doc Register usage for a given command sequence. Lookups are by longest 88 | %% match. 89 | -spec register_usage([string()], clique_usage:usage()) -> true. 90 | register_usage(Cmd, Usage) -> 91 | clique_usage:register(Cmd, Usage). 92 | 93 | %% @doc Take a list of status types and generate console output 94 | -spec print({error, term()}, term()) -> {error, 1}; 95 | ({clique_status:status(), integer(), string()}, [string()]) -> ok | {error, integer()}; 96 | (clique_status:status(), [string()]) -> ok. 97 | print({error, _} = E, Cmd) -> 98 | print(E, Cmd, "human"), 99 | {error, 1}; 100 | print({Status, ExitCode, Format}, Cmd) -> 101 | print(Status, Cmd, Format), 102 | case ExitCode of 103 | 0 -> ok; 104 | _ -> {error, ExitCode} 105 | end; 106 | print(Status, Cmd) -> 107 | print(Status, Cmd, "human"), 108 | ok. 109 | 110 | -spec print(usage | err() | clique_status:status(), [string()], string()) -> 111 | ok | {error, integer()}. 112 | print(usage, Cmd, _Format) -> 113 | clique_usage:print(Cmd); 114 | print({error, _} = E, Cmd, Format) -> 115 | Alert = clique_error:format(hd(Cmd), E), 116 | print(Alert, Cmd, Format); 117 | print(Status, _Cmd, Format) -> 118 | {Stdout, Stderr} = clique_writer:write(Status, Format), 119 | %% This is kind of a hack, but I'm not aware of a better way to do this. 120 | %% When the RPC call is executed, it replaces the group_leader with that 121 | %% of the calling process, so that stdout is automatically redirected to 122 | %% the caller. However, stderr is not. To get the correct PID for stderr, 123 | %% we need to do an RPC back to the calling node and get it from them. 124 | CallingNode = node(group_leader()), 125 | RemoteStderr = rpc:call(CallingNode, erlang, whereis, [standard_error]), 126 | io:format("~ts", [Stdout]), 127 | io:format(RemoteStderr, "~ts", [Stderr]). 128 | 129 | %% @doc Run a config operation or command 130 | -spec run([string()]) -> ok | {error, integer()}. 131 | run(Cmd) -> 132 | M0 = clique_command:match(Cmd), 133 | M1 = clique_parser:parse(M0), 134 | M2 = clique_parser:extract_global_flags(M1), 135 | M3 = clique_parser:validate(M2), 136 | print(clique_command:run(M3), Cmd). 137 | 138 | -ifdef(TEST). 139 | 140 | -spec create_test_dir() -> string() | no_return(). 141 | %% Exported: Creates a new, empty, uniquely-named directory for testing. 142 | create_test_dir() -> 143 | string:strip(?cmd("mktemp -d /tmp/" ?MODULE_STRING ".XXXXXXX"), both, $\n). 144 | 145 | -spec delete_test_dir(Dir :: string()) -> ok | no_return(). 146 | %% Exported: Deletes a test directory fully, whether it exists or not. 147 | delete_test_dir(Dir) -> 148 | ?assertCmd("rm -rf " ++ Dir). 149 | 150 | -spec ensure_stopped() -> ok. 151 | %% Exported: Makes sure application processes are all stopped. 152 | ensure_stopped() -> 153 | % Tests may start portions of the app, so work through whatever may be 154 | % running. The final clique_manager:teardown/0 should delete any leftover 155 | % ETS tables, so by the time we're done here the environment should be 156 | % entirely cleaned up. 157 | _ = application:stop(clique), 158 | lists:foreach(fun(Proc) -> 159 | case erlang:whereis(Proc) of 160 | Pid when erlang:is_pid(Pid) -> 161 | erlang:unlink(Pid), 162 | erlang:exit(Pid, shutdown); 163 | _ -> 164 | ok 165 | end 166 | end, [clique_sup, clique_manager]), 167 | clique_manager:teardown(). 168 | 169 | basic_cmd_test() -> 170 | start_manager(), 171 | Cmd = ["clique-test", "basic_cmd_test"], 172 | Callback = fun(CallbackCmd, [], []) -> 173 | ?assertEqual(Cmd, CallbackCmd), 174 | put(pass_basic_cmd_test, true), 175 | [] %% Need to return a valid status, but don't care what's in it 176 | end, 177 | ?assertEqual(ok, register_command(Cmd, [], [], Callback)), 178 | ?assertEqual(ok, run(Cmd)), 179 | ?assertEqual(true, get(pass_basic_cmd_test)). 180 | 181 | cmd_error_status_test() -> 182 | start_manager(), 183 | Cmd = ["clique-test", "cmd_error_status_test"], 184 | Callback = fun(_, [], []) -> {exit_status, 123, []} end, 185 | ?assertEqual(ok, register_command(Cmd, [], [], Callback)), 186 | ?assertEqual({error, 123}, run(Cmd)). 187 | 188 | start_manager() -> 189 | %% May already be started from a different test, which is fine, but we 190 | %% don't want it linked to the test process. 191 | case clique_manager:start_link() of 192 | {ok, Pid} -> 193 | erlang:unlink(Pid); 194 | _ -> 195 | ok 196 | end. 197 | 198 | -endif. 199 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | -------------------------------------------------------------------------------- /src/clique_table.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2013, 2014 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique_table). 22 | 23 | %% API 24 | -export([print/2, print/3, 25 | create_table/2, 26 | autosize_create_table/2, autosize_create_table/3]). 27 | 28 | -include("clique_status_types.hrl"). 29 | 30 | -define(MAX_LINE_LEN, 100). 31 | -define(else, true). 32 | -define(MINWIDTH(W), 33 | if W =< 0 -> 34 | 1; 35 | ?else -> 36 | W 37 | end). 38 | 39 | -spec print(list(), list()) -> ok. 40 | print(_Spec, []) -> 41 | ok; 42 | %% Explict sizes were not given. This is called using the new status types. 43 | print(Schema, Rows) when is_list(hd(Schema)) -> 44 | Table = autosize_create_table(Schema, Rows), 45 | io:format("~n~ts~n", [Table]); 46 | print(Spec, Rows) -> 47 | Table = create_table(Spec, Rows), 48 | io:format("~n~ts~n", [Table]). 49 | 50 | -spec print(list(), list(), list()) -> ok. 51 | print(_Hdr, _Spec, []) -> 52 | ok; 53 | print(Header, Spec, Rows) -> 54 | Table = create_table(Spec, Rows), 55 | io:format("~ts~n~n~ts~n", [Header, Table]). 56 | 57 | -spec autosize_create_table([any()], [[any()]]) -> iolist(). 58 | autosize_create_table(Schema, Rows) -> 59 | autosize_create_table(Schema, Rows, []). 60 | 61 | %% Currently the only constraint supported in the proplist is 62 | %% `fixed_width' with a list of columns that *must not* be shrunk 63 | %% (e.g., integer values). First column is 0. 64 | -spec autosize_create_table([any()], [[any()]], [tuple()]) -> iolist(). 65 | autosize_create_table(Schema, Rows, Constraints) -> 66 | BorderSize = 1 + length(hd(Rows)), 67 | MaxLineLen = case io:columns() of 68 | %% Leaving an extra space seems to work better 69 | {ok, N} -> N - 1; 70 | {error, enotsup} -> ?MAX_LINE_LEN 71 | end, 72 | Sizes = get_field_widths(MaxLineLen - BorderSize, [Schema | Rows], 73 | proplists:get_value(fixed_width, Constraints, [])), 74 | Spec = lists:zip(Schema, Sizes), 75 | create_table(Spec, Rows, MaxLineLen, []). 76 | 77 | -spec create_table(list(), list()) -> iolist(). 78 | create_table(Spec, Rows) -> 79 | Lengths = get_row_length(Spec, Rows), 80 | Length = lists:sum(Lengths)+2, 81 | AdjustedSpec = [{Field, NewLength} || {{Field, _DefaultLength}, NewLength} 82 | <- lists:zip(Spec, Lengths)], 83 | create_table(AdjustedSpec, Rows, Length, []). 84 | 85 | -spec create_table(list(), list(), non_neg_integer(), iolist()) -> iolist(). 86 | create_table(Spec, Rows, Length, []) -> 87 | FirstThreeRows = [vertical_border(Spec), titles(Spec), 88 | vertical_border(Spec)], 89 | create_table(Spec, Rows, Length, FirstThreeRows); 90 | create_table(_Spec, [], _Length, IoList) when length(IoList) == 3 -> 91 | %% table had no rows, no final row needed 92 | lists:reverse(IoList); 93 | create_table(Spec, [], _Length, IoList) -> 94 | BottomBorder = vertical_border(Spec), 95 | %% There are no more rows to print so return the table 96 | lists:reverse([BottomBorder | IoList]); 97 | create_table(Spec, [Row | Rows], Length, IoList) -> 98 | create_table(Spec, Rows, Length, [row(Spec, Row) | IoList]). 99 | 100 | %% Measure and shrink table width as necessary to fit the console 101 | -spec get_field_widths(pos_integer(), [term()], [non_neg_integer()]) -> [non_neg_integer()]. 102 | get_field_widths(MaxLineLen, Rows, Unshrinkable) -> 103 | Widths = max_widths(Rows), 104 | fit_widths_to_terminal(MaxLineLen, Widths, Unshrinkable). 105 | 106 | fit_widths_to_terminal(MaxWidth, Widths, Unshrinkable) -> 107 | Sum = lists:sum(Widths), 108 | Weights = calculate_field_weights(Sum, Widths, Unshrinkable), 109 | MustRemove = Sum - MaxWidth, 110 | calculate_new_widths(MaxWidth, MustRemove, Widths, Weights). 111 | 112 | %% Determine field weighting as proportion of total width of the 113 | %% table. Fields which were flagged as unshrinkable will be given a 114 | %% weight of 0. 115 | -spec calculate_field_weights(pos_integer(), list(pos_integer()), 116 | list(non_neg_integer())) -> 117 | list(number()). 118 | calculate_field_weights(Sum, Widths, []) -> 119 | %% If no fields are constrained as unshrinkable, simply divide 120 | %% each width by the sum of all widths for our proportions 121 | lists:map(fun(X) -> X / Sum end, Widths); 122 | calculate_field_weights(_Sum, Widths, Unshrinkable) -> 123 | TaggedWidths = flag_unshrinkable_widths(Widths, Unshrinkable), 124 | ShrinkableWidth = lists:sum(lists:filter(fun({_X, noshrink}) -> false; 125 | (_X) -> true end, 126 | TaggedWidths)), 127 | lists:map(fun({_X, noshrink}) -> 0; 128 | (X) -> X / ShrinkableWidth end, 129 | TaggedWidths). 130 | 131 | %% Takes a list of column widths and a list of (zero-based) index 132 | %% values of the columns that must not shrink. Returns a mixed list of 133 | %% widths and `noshrink' tuples. 134 | flag_unshrinkable_widths(Widths, NoShrink) -> 135 | {_, NewWidths} = 136 | lists:foldl(fun(X, {Idx, Mapped}) -> 137 | case lists:member(Idx, NoShrink) of 138 | true -> 139 | {Idx + 1, [{X, noshrink}|Mapped]}; 140 | false -> 141 | {Idx + 1, [X|Mapped]} 142 | end 143 | end, {0, []}, Widths), 144 | lists:reverse(NewWidths). 145 | 146 | %% Calculate the proportional weight for each column for shrinking. 147 | %% Zip the results into a `{Width, Weight, Index}' tuple list. 148 | column_zip(Widths, Weights, ToNarrow) -> 149 | column_zip(Widths, Weights, ToNarrow, 0, []). 150 | 151 | column_zip([], [], _ToNarrow, _Index, Accum) -> 152 | lists:reverse(Accum); 153 | column_zip([Width|Widths], [Weight|Weights], ToNarrow, Index, Accum) -> 154 | NewWidth = ?MINWIDTH(Width - round(ToNarrow * Weight)), 155 | column_zip(Widths, Weights, ToNarrow, Index+1, 156 | [{NewWidth, Weight, Index}] ++ Accum). 157 | 158 | %% Given the widths based on data to be displayed, return widths 159 | %% necessary to narrow the table to fit the console. 160 | calculate_new_widths(_Max, ToNarrow, Widths, _Weights) when ToNarrow =< 0 -> 161 | %% Console is wide enough, no need to narrow 162 | Widths; 163 | calculate_new_widths(MaxWidth, ToNarrow, Widths, Weights) -> 164 | fix_rounding(MaxWidth, column_zip(Widths, Weights, ToNarrow)). 165 | 166 | %% Rounding may introduce an error. If so, remove the requisite number 167 | %% of spaces from the widest field 168 | fix_rounding(Target, Cols) -> 169 | Widths = lists:map(fun({Width, _Weight, _Idx}) -> Width end, 170 | Cols), 171 | SumWidths = lists:sum(Widths), 172 | shrink_widest(Target, SumWidths, Widths, Cols). 173 | 174 | %% Determine whether our target table width is wider than the terminal 175 | %% due to any rounding error and find columns eligible to be shrunk. 176 | shrink_widest(Target, Current, Widths, _Cols) when Target =< Current -> 177 | Widths; 178 | shrink_widest(Target, Current, Widths, Cols) -> 179 | Gap = Current - Target, 180 | NonZeroWeighted = lists:dropwhile(fun({_Width, 0, _Idx}) -> true; 181 | (_) -> false end, 182 | Cols), 183 | shrink_widest_weighted(Gap, NonZeroWeighted, Widths). 184 | 185 | %% Take the widest column with a non-zero weight and reduce it by the 186 | %% amount necessary to compensate for any rounding error. 187 | shrink_widest_weighted(_Gap, [], Widths) -> 188 | Widths; %% All columns constrained to fixed widths, nothing we can do 189 | shrink_widest_weighted(Gap, Cols, Widths) -> 190 | SortedCols = lists:sort( 191 | fun({WidthA, _WeightA, _IdxA}, {WidthB, _WeightB, _IdxB}) -> 192 | WidthA > WidthB 193 | end, Cols), 194 | {OldWidth, _Weight, Idx} = hd(SortedCols), 195 | NewWidth = ?MINWIDTH(OldWidth - Gap), 196 | replace_list_element(Idx, NewWidth, Widths). 197 | 198 | %% Replace the item at `Index' in `List' with `Element'. 199 | %% Zero-based indexing. 200 | -spec replace_list_element(non_neg_integer(), term(), list()) -> list(). 201 | replace_list_element(Index, Element, List) -> 202 | {Prefix, Suffix} = lists:split(Index, List), 203 | Prefix ++ [Element] ++ tl(Suffix). 204 | 205 | get_row_length(Spec, Rows) -> 206 | Res = lists:foldl(fun({_Name, MinSize}, Total) -> 207 | Longest = find_longest_field(Rows, length(Total)+1), 208 | Size = erlang:max(MinSize, Longest), 209 | [Size | Total] 210 | end, [], Spec), 211 | lists:reverse(Res). 212 | 213 | -spec find_longest_field(list(), pos_integer()) -> non_neg_integer(). 214 | find_longest_field(Rows, ColumnNo) -> 215 | lists:foldl(fun(Row, Longest) -> 216 | erlang:max(Longest, 217 | field_length(lists:nth(ColumnNo,Row))) 218 | end, 0, Rows). 219 | 220 | -spec max_widths([term()]) -> list(pos_integer()). 221 | max_widths([Row]) -> 222 | field_lengths(Row); 223 | max_widths([Row1 | Rest]) -> 224 | Row1Lengths = field_lengths(Row1), 225 | lists:foldl(fun(Row, Acc) -> 226 | Lengths = field_lengths(Row), 227 | [max(A, B) || {A, B} <- lists:zip(Lengths, Acc)] 228 | end, Row1Lengths, Rest). 229 | 230 | -spec row(list(), list(string())) -> iolist(). 231 | row(Spec, Row0) -> 232 | %% handle multiline fields 233 | Rows = expand_row(Row0), 234 | [ 235 | [ $| | lists:reverse( 236 | ["\n" | lists:foldl(fun({{_, Size}, Str}, Acc) -> 237 | [align(Str, Size) | Acc] 238 | end, [], lists:zip(Spec, Row))])] || Row <- Rows]. 239 | 240 | -spec titles(list()) -> iolist(). 241 | titles(Spec) -> 242 | [ $| | lists:reverse( 243 | ["\n" | lists:foldl(fun({Title, Size}, TitleRow) -> 244 | [align(Title, Size) | TitleRow] 245 | end, [], Spec)])]. 246 | 247 | -spec align(string(), non_neg_integer()) -> iolist(). 248 | align(undefined, Size) -> 249 | align("", Size); 250 | align(Str, Size) when is_integer(Str) -> 251 | align(integer_to_list(Str), Size); 252 | align(Str, Size) when is_binary(Str) -> 253 | align(unicode:characters_to_list(Str, utf8), Size); 254 | align(Str, Size) when is_atom(Str) -> 255 | align(atom_to_list(Str), Size); 256 | align(Str, Size) when is_list(Str), length(Str) >= Size -> 257 | Truncated = lists:sublist(Str, Size), 258 | Truncated ++ "|"; 259 | align(Str, Size) when is_list(Str) -> 260 | string:centre(Str, Size) ++ "|"; 261 | align(Term, Size) -> 262 | Str = lists:flatten(io_lib:format("~p", [Term])), 263 | align(Str, Size). 264 | 265 | -spec vertical_border(list(tuple())) -> string(). 266 | vertical_border(Spec) -> 267 | lists:reverse([$\n, [[char_seq(Length, $-), $+] || 268 | {_Name, Length} <- Spec], $+]). 269 | 270 | -spec char_seq(non_neg_integer(), char()) -> string(). 271 | char_seq(Length, Char) -> 272 | [Char || _ <- lists:seq(1, Length)]. 273 | 274 | field_lengths(Row) -> 275 | [field_length(Field) || Field <- Row]. 276 | 277 | field_length(Field) when is_atom(Field) -> 278 | field_length(atom_to_list(Field)); 279 | field_length(Field) when is_binary(Field) -> 280 | field_length(unicode:characters_to_list(Field, utf8)); 281 | field_length(Field) when is_list(Field) -> 282 | Lines = string:tokens(lists:flatten(Field), "\n"), 283 | lists:foldl(fun(Line, Longest) -> 284 | erlang:max(Longest, 285 | length(Line)) 286 | end, 0, Lines); 287 | field_length(Field) -> 288 | field_length(io_lib:format("~p", [Field])). 289 | 290 | expand_field(Field) when is_atom(Field) -> 291 | expand_field(atom_to_list(Field)); 292 | expand_field(Field) when is_binary(Field) -> 293 | expand_field(unicode:characters_to_list(Field, utf8)); 294 | expand_field(Field) when is_list(Field) -> 295 | string:tokens(lists:flatten(Field), "\n"); 296 | expand_field(Field) -> 297 | expand_field(io_lib:format("~p", [Field])). 298 | 299 | expand_row(Row) -> 300 | {ExpandedRow, MaxHeight} = lists:foldl(fun(Field, {Fields, Max}) -> 301 | EF = expand_field(Field), 302 | {[EF|Fields], erlang:max(Max, length(EF))} 303 | end, {[], 0}, lists:reverse(Row)), 304 | PaddedRow = [pad_field(Field, MaxHeight) || Field <- ExpandedRow], 305 | [ [ lists:nth(N, Field) || Field <- PaddedRow] 306 | || N <- lists:seq(1, MaxHeight)]. 307 | 308 | pad_field(Field, MaxHeight) when length(Field) < MaxHeight -> 309 | Field ++ ["" || _ <- lists:seq(1, MaxHeight - length(Field))]; 310 | pad_field(Field, _MaxHeight) -> 311 | Field. 312 | -------------------------------------------------------------------------------- /src/clique_parser.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014 Basho Technologies, Inc. All Rights Reserved. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | -module(clique_parser). 21 | -include("clique_specs.hrl"). 22 | 23 | -ifdef(TEST). 24 | -include_lib("eunit/include/eunit.hrl"). 25 | -endif. 26 | %% API 27 | -export([parse/1, 28 | parse_flags/1, 29 | extract_global_flags/1, 30 | validate/1, 31 | validate_flags/2, 32 | is_not_kv_arg/1, 33 | is_not_flag/1]). 34 | 35 | -export_type([flags/0, args/0]). 36 | 37 | -type err() :: {error, term()}. 38 | -type flags() :: [{string() | char(), term()}]. 39 | -type args() :: [{string(), string()}]. 40 | -type proplist() :: [{atom(), term()}]. 41 | 42 | -type keyspecs() :: '_' | [spec()]. 43 | -type flagspecs() :: [spec()]. 44 | 45 | -spec parse(err()) -> err(); 46 | ([string()]) -> {args(), flags()} | err(); 47 | ({tuple(), [string()]}) -> 48 | {tuple(), args(), flags()} | err(). 49 | parse({error, _}=E) -> 50 | E; 51 | parse({Spec, ArgsAndFlags}) -> 52 | case parse(ArgsAndFlags) of 53 | {error, _}=E -> 54 | E; 55 | {Args, Flags} -> 56 | {Spec, Args, Flags} 57 | end; 58 | parse(ArgsAndFlags) -> 59 | %% Positional key/value args always come before flags in our cli 60 | {Args0, Flags0} = lists:splitwith(fun is_not_flag/1, ArgsAndFlags), 61 | case parse_kv_args(Args0) of 62 | {error, _}=E -> 63 | E; 64 | Args -> 65 | case parse_flags(Flags0) of 66 | {error, _}=E -> 67 | E; 68 | Flags -> 69 | {Args, Flags} 70 | end 71 | end. 72 | 73 | -spec parse_kv_args([string()]) -> err() | args(). 74 | parse_kv_args(Args) -> 75 | parse_kv_args(Args, []). 76 | 77 | %% All args must be k/v args! 78 | -spec parse_kv_args([string()], args()) -> err() | args(). 79 | parse_kv_args([], Acc) -> 80 | Acc; 81 | parse_kv_args([Arg | Args], Acc) -> 82 | case string:tokens(Arg, "=") of 83 | [Key, Val] -> 84 | parse_kv_args(Args, [{Key, Val} | Acc]); 85 | [Key] -> 86 | {error, {invalid_kv_arg, Key}}; 87 | [Key | ValTokens] -> 88 | GluedValTokens = string:join(ValTokens, "="), 89 | parse_kv_args(Args, [{Key, GluedValTokens} | Acc]) 90 | end. 91 | 92 | 93 | -spec parse_flags([string()]) -> err() | flags(). 94 | parse_flags(Flags) -> 95 | parse_flags(Flags, [], []). 96 | 97 | -spec parse_flags([string()], list(), flags()) -> flags() | err(). 98 | parse_flags([], [], Acc) -> 99 | Acc; 100 | parse_flags([], [Flag], Acc) -> 101 | [{Flag, undefined} | Acc]; 102 | parse_flags(["--"++Long | T], [], Acc) -> 103 | case string:tokens(Long,"=") of 104 | [Flag, Val] -> 105 | parse_flags(T, [], [{Flag, Val} | Acc]); 106 | [Flag] -> 107 | parse_flags(T, [Flag], Acc) 108 | end; 109 | parse_flags(["--"++_Long | _T]=Flags, [Flag], Acc) -> 110 | parse_flags(Flags, [], [{Flag, undefined} | Acc]); 111 | parse_flags([[$-,Short] | T], [], Acc) -> 112 | parse_flags(T, [Short], Acc); 113 | parse_flags([[$-,Short] | T], [Flag], Acc) -> 114 | parse_flags(T, [Short], [{Flag, undefined} | Acc]); 115 | parse_flags([[$-,Short | Arg] | T], [], Acc) -> 116 | parse_flags(T, [], [{Short, Arg} | Acc]); 117 | parse_flags([[$-,Short | Arg] | T], [Flag], Acc) -> 118 | parse_flags(T, [], [{Short, Arg}, {Flag, undefined} | Acc]); 119 | parse_flags([Val | T], [Flag], Acc) -> 120 | parse_flags(T, [], [{Flag, Val} | Acc]); 121 | parse_flags([Val | _T], [], _Acc) -> 122 | {error, {invalid_flag, Val}}. 123 | 124 | %% TODO: If this gets more complicated, write out a function to extract 125 | %% the flag names from ?GFLAG_SPECS instead of hand-coding it in ?GLOBAL_FLAGS 126 | -define(GLOBAL_FLAGS, [$h, "help", "format"]). 127 | -define(GFLAG_SPECS, [clique_spec:make({help, [{shortname, "h"}, 128 | {longname, "help"}]}), 129 | clique_spec:make({format, [{longname, "format"}]})]). 130 | %% @doc Extracts a list of globally applicable flags (e.g. --help) from the 131 | %% the original command. 132 | -spec extract_global_flags(err()) -> err(); 133 | ({tuple(), proplist(), flags()}) -> 134 | {tuple(), proplist(), flags(), flags()}. 135 | extract_global_flags({error, _} = E) -> 136 | E; 137 | extract_global_flags({Spec, Args, Flags0}) -> 138 | PartFun = fun({K, _V}) -> lists:member(K, ?GLOBAL_FLAGS) end, 139 | {GlobalFlags0, Flags} = lists:partition(PartFun, Flags0), 140 | GlobalFlags = validate_flags(?GFLAG_SPECS, GlobalFlags0), 141 | {Spec, Args, Flags, GlobalFlags}. 142 | 143 | -spec validate(err()) -> err(); 144 | ({tuple(), args(), flags(), flags()}) -> 145 | err() | {fun(), [string()], proplist(), proplist(), flags()}. 146 | validate({error, _}=E) -> 147 | E; 148 | validate({Spec, Args0, Flags0, GlobalFlags}) -> 149 | {Cmd, KeySpecs, FlagSpecs, Callback} = Spec, 150 | case validate_args(KeySpecs, Args0) of 151 | {error, _}=E -> 152 | E; 153 | Args -> 154 | case validate_flags(FlagSpecs, Flags0) of 155 | {error, _}=E -> 156 | E; 157 | Flags -> 158 | {Callback, Cmd, Args, Flags, GlobalFlags} 159 | end 160 | end. 161 | 162 | -spec validate_args(keyspecs(), proplist()) -> err() | proplist(). 163 | validate_args('_', Args) -> 164 | Args; 165 | validate_args(KeySpecs, Args) -> 166 | convert_args(KeySpecs, Args, []). 167 | 168 | -spec convert_args(keyspecs(), proplist(), proplist()) -> err() | proplist(). 169 | convert_args(_KeySpec, [], Acc) -> 170 | Acc; 171 | convert_args([], Args, _Acc) -> 172 | {error, {invalid_args, Args}}; 173 | convert_args(KeySpecs, [{Key, Val0} | Args], Acc) -> 174 | case lists:keyfind(Key, #clique_spec.name, KeySpecs) of 175 | Spec=#clique_spec{} -> 176 | case convert_arg(Spec, Val0) of 177 | {error, _}=E -> 178 | E; 179 | Val -> 180 | case validate_arg(Spec, Val) of 181 | ok -> 182 | convert_args(KeySpecs, Args, [{Spec#clique_spec.key, Val} | Acc]); 183 | {error, _}=VE -> 184 | VE 185 | end 186 | end; 187 | false -> 188 | {error, {invalid_key, Key}} 189 | end. 190 | 191 | -spec convert_arg(spec(), string()) -> err() | term(). 192 | convert_arg(#clique_spec{key=Key, typecast=Fun}, Val) when is_function(Fun) -> 193 | try 194 | Fun(Val) 195 | catch error:badarg -> 196 | {error, {invalid_argument, {Key, Val}}} 197 | end; 198 | convert_arg(#clique_spec{key=_Key, datatype=Type}, Val) when Type /= undefined -> 199 | case cuttlefish_datatypes:from_string(Val, Type) of 200 | {error, _}=E -> E; 201 | Casted -> Casted 202 | end. 203 | 204 | -spec validate_arg(spec(), term()) -> ok | err(). 205 | validate_arg(#clique_spec{validator=undefined}, _) -> ok; 206 | validate_arg(#clique_spec{key=Key, validator=Validator}, Val) when is_function(Validator)-> 207 | try 208 | Validator(Val) 209 | catch 210 | _:_ -> 211 | {error, {invalid_argument, {Key, Val}}} 212 | end. 213 | 214 | -spec validate_flags(flagspecs(), flags()) -> err() | proplist(). 215 | validate_flags(FlagSpecs, Flags) -> 216 | convert_flags(FlagSpecs, Flags, []). 217 | 218 | -spec convert_flags(flagspecs(), flags(), proplist()) -> err() | proplist(). 219 | convert_flags([], [], Acc) -> 220 | Acc; 221 | convert_flags(_FlagSpecs, [], Acc) -> 222 | Acc; 223 | convert_flags([], Provided, _Acc) -> 224 | Invalid = [Flag || {Flag, _} <- Provided], 225 | {error, {invalid_flags, Invalid}}; 226 | convert_flags(FlagSpecs, [{Key, Val0} | Flags], Acc) -> 227 | case find_flag(FlagSpecs, Key) of 228 | #clique_spec{key=NewKey}=Spec -> 229 | case convert_flag(Spec, NewKey, Val0) of 230 | {error, _}=E -> E; 231 | Val -> convert_flags(FlagSpecs, Flags, [{NewKey, Val} | Acc]) 232 | end; 233 | {error, _}=E -> E 234 | end. 235 | 236 | -spec find_flag(flagspecs(), string() | char()) -> spec() | err(). 237 | find_flag(FlagSpecs, Key) -> 238 | lists:foldl(fun(Idx, Acc) -> 239 | case lists:keyfind(Key, Idx, FlagSpecs) of 240 | #clique_spec{}=Spec -> Spec; 241 | false -> Acc 242 | end 243 | end, 244 | {error, {invalid_key, Key}}, 245 | [#clique_spec.name, #clique_spec.shortname]). 246 | 247 | 248 | -spec convert_flag(spec(), atom(), string()) -> err() | term(). 249 | convert_flag(Spec, Key, Val) -> 250 | %% Flags don't necessarily have values, in which case Val is undefined here. 251 | %% Additionally, flag values can also be strings and not have typecast funs. 252 | %% It's not incorrect, so just return the value in that case. 253 | case cast_flag(Spec, Key, Val) of 254 | {error, _}=CastError -> CastError; 255 | CastedValue -> 256 | validate_flag(Spec, Key, CastedValue) 257 | end. 258 | 259 | -spec cast_flag(spec(), atom(), string()) -> err() | term(). 260 | cast_flag(_, _, undefined) -> undefined; 261 | cast_flag(#clique_spec{datatype=Type, typecast=Fun}, Key, Val) -> 262 | if is_function(Fun) -> 263 | try 264 | Fun(Val) 265 | catch error:badarg -> 266 | {error, {invalid_flag, {Key, Val}}} 267 | end; 268 | Type == atom -> 269 | %% TODO: We convert atoms here until cuttlefish handles 270 | %% this safely. 271 | try 272 | list_to_existing_atom(Val) 273 | catch 274 | error:badarg -> 275 | {error, {conversion, {Val, atom}}} 276 | end; 277 | Type /= undefined -> 278 | cuttlefish_datatypes:from_string(Val, Type); 279 | true -> 280 | {error, {invalid_flag, {Key, Val}}} 281 | end. 282 | 283 | -spec validate_flag(spec(), atom(), term()) -> err() | term(). 284 | validate_flag(#clique_spec{validator=undefined}, _Key, CastedVal) -> 285 | CastedVal; 286 | validate_flag(#clique_spec{validator=Validator}, Key, CastedVal) when is_function(Validator) -> 287 | try Validator(CastedVal) of 288 | ok -> CastedVal; 289 | {error, _} = Error -> Error 290 | catch 291 | _:_ -> 292 | {error, {invalid_flag, {Key, CastedVal}}} 293 | end. 294 | 295 | -spec is_not_kv_arg(string()) -> boolean(). 296 | is_not_kv_arg("-"++_Str) -> 297 | true; 298 | is_not_kv_arg(Str) -> 299 | case lists:member($=, Str) of 300 | true -> 301 | false; 302 | false -> 303 | true 304 | end. 305 | 306 | -spec is_not_flag(string()) -> boolean(). 307 | is_not_flag(Str) -> 308 | case lists:prefix("-", Str) of 309 | true -> 310 | try 311 | %% negative integers are arguments 312 | _ = list_to_integer(Str), 313 | true 314 | catch error:badarg -> 315 | false 316 | end; 317 | false -> 318 | true 319 | end. 320 | 321 | -ifdef(TEST). 322 | 323 | spec() -> 324 | Cmd = ["riak-admin", "test", "something"], 325 | KeySpecs = [clique_spec:make({sample_size, [{typecast, fun list_to_integer/1}]})], 326 | FlagSpecs = [clique_spec:make({node, [{shortname, "n"}, 327 | {longname, "node"}, 328 | {typecast, fun list_to_atom/1}]}), 329 | clique_spec:make({force, [{shortname, "f"}, 330 | {longname, "force"}]})], 331 | Callback = undefined, 332 | {Cmd, KeySpecs, FlagSpecs, Callback}. 333 | 334 | dt_validate_spec() -> 335 | Cmd = ["riak-admin", "test", "something"], 336 | KeySpecs = [clique_spec:make({sample_size, [{datatype, integer}, 337 | {validator, fun greater_than_zero/1}]})], 338 | FlagSpecs = [clique_spec:make({node, [{shortname, "n"}, 339 | {longname, "node"}, 340 | {datatype, atom}, 341 | {validator, fun phony_is_node/1}]}), 342 | clique_spec:make({force, [{shortname, "f"}, 343 | {longname, "force"}]})], 344 | Callback = undefined, 345 | {Cmd, KeySpecs, FlagSpecs, Callback}. 346 | 347 | greater_than_zero(N) when N > 0 -> ok; 348 | greater_than_zero(N) -> {error, {invalid_value, N}}. 349 | 350 | phony_is_node(N) -> 351 | Nodes = ['a@dev1', 'b@dev2', 'c@dev3'], 352 | case lists:member(N, Nodes) of 353 | true -> ok; 354 | false -> {error, bad_node} 355 | end. 356 | 357 | parse_valid_flag_test() -> 358 | Spec = spec(), 359 | Node = "dev2@127.0.0.1", 360 | ArgsAndFlags = ["-n", Node], 361 | {Spec, Args, Flags} = parse({Spec, ArgsAndFlags}), 362 | ?assertEqual(Args, []), 363 | ?assertEqual(Flags, [{$n, Node}]). 364 | 365 | parse_valid_args_and_flag_test() -> 366 | Spec = spec(), 367 | Node = "dev2@127.0.0.1", 368 | ArgsAndFlags = ["key=value", "-n", Node], 369 | {Spec, Args, Flags} = parse({Spec, ArgsAndFlags}), 370 | ?assertEqual(Args, [{"key", "value"}]), 371 | ?assertEqual(Flags, [{$n, Node}]). 372 | 373 | parse_valid_arg_value_with_equal_sign_test() -> 374 | Spec = spec(), 375 | ArgsAndFlags = ["url=example.com?q=dada"], 376 | {Spec, Args, Flags} = parse({Spec, ArgsAndFlags}), 377 | ?assertEqual(Args, [{"url", "example.com?q=dada"}]), 378 | ?assertEqual(Flags, []). 379 | 380 | %% All arguments must be of type k=v 381 | parse_invalid_kv_arg_test() -> 382 | Spec = spec(), 383 | %% Argument with equal sign and no value 384 | ArgsNoVal = ["ayo="], 385 | ?assertMatch({error, {invalid_kv_arg, _}}, parse({Spec, ArgsNoVal})), 386 | %% Argument without equal sign and no value 387 | ArgsNoEqualAndNoVal = ["ayo"], 388 | ?assertMatch({error, {invalid_kv_arg, _}}, parse({Spec, ArgsNoEqualAndNoVal})). 389 | 390 | 391 | %% This succeeds, because we aren't validating the flag, just parsing 392 | %% Note: Short flags get parsed into tuples with their character as first elem 393 | %% Long flags get translated to atoms in the first elem of the tuple 394 | parse_valueless_flags_test() -> 395 | Spec = spec(), 396 | Args = ["-f", "--do-something"], 397 | {Spec, _, Flags} = parse({Spec, Args}), 398 | %% Flags with no value, get the value undefined 399 | ?assert(lists:member({$f, undefined}, Flags)), 400 | ?assert(lists:member({"do-something", undefined}, Flags)). 401 | 402 | validate_valid_short_flag_test() -> 403 | Spec = spec(), 404 | Cmd = element(1, Spec), 405 | Args = [], 406 | Node = "dev2@127.0.0.1", 407 | Flags = [{$n, Node}, {$f, undefined}], 408 | {undefined, Cmd, [], ConvertedFlags, []} = validate({Spec, Args, Flags, []}), 409 | ?assert(lists:member({node, 'dev2@127.0.0.1'}, ConvertedFlags)), 410 | ?assert(lists:member({force, undefined}, ConvertedFlags)). 411 | 412 | validate_valid_long_flag_test() -> 413 | Spec = spec(), 414 | Cmd = element(1, Spec), 415 | Args = [], 416 | Node = "dev2@127.0.0.1", 417 | Flags = [{"node", Node}, {"force", undefined}], 418 | {undefined, Cmd, [], ConvertedFlags, []} = validate({Spec, Args, Flags, []}), 419 | ?assert(lists:member({node, 'dev2@127.0.0.1'}, ConvertedFlags)), 420 | ?assert(lists:member({force, undefined}, ConvertedFlags)). 421 | 422 | validate_invalid_flags_test() -> 423 | Spec = spec(), 424 | Args = [], 425 | Node = "dev2@127.0.0.1", 426 | InvalidFlags = [{"some-flag", Node}, 427 | {$b, Node}, 428 | {$a, undefined}], 429 | [?assertMatch({error, _}, validate({Spec, Args, [F], []})) || F <- InvalidFlags]. 430 | 431 | validate_valid_args_test() -> 432 | Spec = spec(), 433 | Cmd = element(1, Spec), 434 | Args = [{"sample_size", "5"}], 435 | {undefined, Cmd, ConvertedArgs, [], []} = validate({Spec, Args, [], []}), 436 | ?assertEqual(ConvertedArgs, [{sample_size, 5}]). 437 | 438 | validate_invalid_args_test() -> 439 | Spec = spec(), 440 | InvalidArgs = [{"key", "value"}, {"sample_size", "ayo"}], 441 | [?assertMatch({error, _}, validate({Spec, [A], [], []})) || A <- InvalidArgs]. 442 | 443 | 444 | arg_datatype_test() -> 445 | Spec = dt_validate_spec(), 446 | Cmd = element(1, Spec), 447 | ValidArg = [{"sample_size", "10"}], 448 | {undefined, Cmd, ConvertedArgs, [], []} = validate({Spec, ValidArg, [], []}), 449 | ?assertEqual(ConvertedArgs, [{sample_size, 10}]), 450 | 451 | InvalidTypeArg = [{"sample_size", "A"}], 452 | ?assertMatch({error, _}, validate({Spec, InvalidTypeArg, [], []})). 453 | 454 | arg_validation_test() -> 455 | Spec = dt_validate_spec(), 456 | InvalidArg = [{"sample_size", "0"}], 457 | ?assertMatch({error, {invalid_value, _}}, validate({Spec, InvalidArg, [], []})). 458 | 459 | flag_datatype_test() -> 460 | Spec = dt_validate_spec(), 461 | Cmd = element(1, Spec), 462 | ValidFlag = [{$n, "a@dev1"}], 463 | {undefined, Cmd, [], Flags, []} = validate({Spec, [], ValidFlag, []}), 464 | ?assertEqual([{node, 'a@dev1'}], Flags), 465 | 466 | InvalidFlag = [{"node", "someothernode@foo.bar"}], 467 | ?assertMatch({error, {conversion, _}}, validate({Spec, [], InvalidFlag, []})). 468 | 469 | flag_validation_test() -> 470 | Spec = dt_validate_spec(), 471 | _BadNode = 'badnode@dev2', %% NB: Atom must exist for type conversion to succeed 472 | InvalidFlag = [{"node", "badnode@dev2"}], 473 | ?assertEqual({error, bad_node}, validate({Spec, [], InvalidFlag, []})). 474 | 475 | -endif. 476 | -------------------------------------------------------------------------------- /src/clique_config.erl: -------------------------------------------------------------------------------- 1 | %% ------------------------------------------------------------------- 2 | %% 3 | %% Copyright (c) 2014-2017 Basho Technologies, Inc. 4 | %% 5 | %% This file is provided to you under the Apache License, 6 | %% Version 2.0 (the "License"); you may not use this file 7 | %% except in compliance with the License. You may obtain 8 | %% a copy of the License at 9 | %% 10 | %% http://www.apache.org/licenses/LICENSE-2.0 11 | %% 12 | %% Unless required by applicable law or agreed to in writing, 13 | %% software distributed under the License is distributed on an 14 | %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 15 | %% KIND, either express or implied. See the License for the 16 | %% specific language governing permissions and limitations 17 | %% under the License. 18 | %% 19 | %% ------------------------------------------------------------------- 20 | 21 | -module(clique_config). 22 | 23 | %% API 24 | -export([ 25 | init/0, 26 | register/2, 27 | register_formatter/2, 28 | config_flags/0, 29 | show/2, 30 | set/2, 31 | whitelist/1, 32 | describe/2, 33 | load_schema/1 34 | ]). 35 | 36 | %% Callbacks for rpc calls 37 | -export([ 38 | do_set/1, 39 | get_local_env_status/2, 40 | get_local_env_vals/2 41 | ]). 42 | 43 | -ifdef(TEST). 44 | -export([teardown/0]). 45 | -include_lib("eunit/include/eunit.hrl"). 46 | -endif. 47 | -include("clique_specs.hrl"). 48 | 49 | -define(config_table, clique_config). 50 | -define(schema_table, clique_schema). 51 | -define(whitelist_table, clique_whitelist). 52 | -define(formatter_table, clique_formatter). 53 | 54 | -define(ets_tables, [ 55 | ?config_table, 56 | ?schema_table, 57 | ?whitelist_table, 58 | ?formatter_table 59 | ]). 60 | 61 | -type err() :: {error, term()}. 62 | -type status() :: clique_status:status(). 63 | -type proplist() :: [{atom(), term()}]. 64 | -type flagspecs() :: [spec()]. 65 | -type flags() :: proplist(). 66 | -type args() :: clique_parser:args(). 67 | -type conf() :: [{[string()], string()}]. 68 | 69 | -type envkey() :: {string(), {atom(), atom()}}. 70 | -type cuttlefish_flag_spec() :: {flag, atom(), atom()}. 71 | -type cuttlefish_flag_list() :: [undefined | cuttlefish_flag_spec()]. 72 | 73 | %% @doc Register configuration callbacks for a given config key 74 | -spec register([string()], fun()) -> true. 75 | register(Key, Callback) -> 76 | ets:insert(?config_table, {Key, Callback}). 77 | 78 | %% @doc Register a pretty-print function for a given config key 79 | -spec register_formatter([string()], fun()) -> true. 80 | register_formatter(Key, Callback) -> 81 | ets:insert(?formatter_table, {Key, Callback}). 82 | 83 | init() -> 84 | lists:foreach(fun(T) -> 85 | ets:new(T, [public, named_table]) 86 | end, ?ets_tables). 87 | 88 | -ifdef(TEST). 89 | -spec teardown() -> ok. 90 | teardown() -> 91 | lists:foreach(fun(T) -> 92 | ets:delete(T, [public, named_table]) 93 | end, ?ets_tables). 94 | -endif. 95 | 96 | %% @doc Load Schemas into ets when given directories containing the *.schema files. 97 | %% Note that this must be run before any registrations are made. 98 | -spec load_schema([string()]) -> ok | {error, schema_files_not_found}. 99 | load_schema(Directories) -> 100 | SchemaFiles = schema_paths(Directories), 101 | case SchemaFiles of 102 | [] -> 103 | {error, schema_files_not_found}; 104 | _ -> 105 | Schema = cuttlefish_schema:files(SchemaFiles), 106 | true = ets:insert(?schema_table, {schema, Schema}), 107 | ok 108 | end. 109 | 110 | -spec schema_paths([string()]) -> [string()]. 111 | schema_paths(Directories) -> 112 | lists:foldl(fun(Dir, Acc) -> 113 | Files = filelib:wildcard(Dir ++ "/*.schema"), 114 | Files ++ Acc 115 | end, [], Directories). 116 | 117 | -spec show([string()], proplist()) -> clique_status:status() | err(). 118 | show(Args, Flags) -> 119 | case get_valid_mappings(Args) of 120 | {error, _} = E -> 121 | E; 122 | [] -> 123 | {error, show_no_args}; 124 | KeyMappings -> 125 | EnvKeys = get_env_keys(KeyMappings), 126 | CuttlefishFlags = get_cuttlefish_flags(KeyMappings), 127 | get_env_status(EnvKeys, CuttlefishFlags, Flags) 128 | end. 129 | 130 | -spec describe([string()], proplist()) -> clique_status:status() | err(). 131 | describe(Args, _Flags) -> 132 | case get_valid_mappings(Args) of 133 | {error, _} = E -> 134 | E; 135 | [] -> 136 | {error, describe_no_args}; 137 | %% TODO: Do we want to allow any flags? --verbose maybe? 138 | KeyMappings -> 139 | [begin 140 | Doc = cuttlefish_mapping:doc(M), 141 | Name = cuttlefish_variable:format(cuttlefish_mapping:variable(M)), 142 | clique_status:text(Name ++ ":\n " ++ string:join(Doc, "\n ") ++ "\n") 143 | end || {_, M} <- KeyMappings] 144 | end. 145 | 146 | -spec set(proplist(), proplist()) -> status() | err(). 147 | set(Args, [{all, _}]) -> 148 | %% Done as an io:format instead of a status, so that the user is not totally 149 | %% left in the dark if the multicall ends up taking a while to finish: 150 | io:format("Setting config across the cluster~n", []), 151 | Nodes = clique_nodes:nodes(), 152 | {Results0, Down0} = rpc:multicall(Nodes, ?MODULE, do_set, [Args]), 153 | 154 | Results = [[{"Node", Node}, {"Node Down/Unreachable", false}, {"Result", Status}] || 155 | {Node, Status} <- Results0], 156 | Down = [[{"Node", Node}, {"Node Down/Unreachable", true}, {"Result", "N/A"}] || 157 | Node <- Down0], 158 | 159 | NodeStatuses = lists:sort(Down ++ Results), 160 | [clique_status:table(NodeStatuses)]; 161 | set(Args, [{node, Node}]) -> 162 | M1 = clique_nodes:safe_rpc(Node, ?MODULE, do_set, [Args]), 163 | return_set_status(M1, Node); 164 | set(Args, []) -> 165 | M1 = do_set(Args), 166 | return_set_status(M1, node()); 167 | set(_Args, _Flags) -> 168 | app_config_flags_error(). 169 | 170 | return_set_status({error, _} = E, _Node) -> 171 | E; 172 | return_set_status({badrpc, Reason}, Node) -> 173 | clique_error:badrpc_to_error(Node, Reason); 174 | return_set_status({_, Result}, _Node) -> 175 | [clique_status:text(Result)]. 176 | 177 | do_set(Args) -> 178 | M1 = get_config(Args), 179 | M2 = set_config(M1), 180 | run_callback(M2). 181 | 182 | %% @doc Whitelist settable cuttlefish variables. By default all variables are not settable. 183 | -spec whitelist([string()]) -> ok | {error, {invalid_config_keys, [string()]}}. 184 | whitelist(Keys) -> 185 | case get_valid_mappings(Keys) of 186 | {error, _} = E -> 187 | E; 188 | _ -> 189 | _ = [ets:insert(?whitelist_table, {Key}) || Key <- Keys], 190 | ok 191 | end. 192 | 193 | -spec check_keys_in_whitelist([string()]) -> ok | {error, {config_not_settable, [string()]}}. 194 | check_keys_in_whitelist(Keys) -> 195 | Invalid = lists:foldl(fun(K, Acc) -> 196 | case ets:lookup(?whitelist_table, K) of 197 | [{_K}] -> 198 | Acc; 199 | [] -> 200 | [K | Acc] 201 | end 202 | end, [], Keys), 203 | case Invalid of 204 | [] -> ok; 205 | _ -> {error, {config_not_settable, Invalid}} 206 | end. 207 | 208 | -spec get_env_status([envkey()], cuttlefish_flag_list(), flags()) -> status() | err(). 209 | get_env_status(EnvKeys, CuttlefishFlags, []) -> 210 | get_local_env_status(EnvKeys, CuttlefishFlags); 211 | get_env_status(EnvKeys, CuttlefishFlags, Flags) when length(Flags) =:= 1 -> 212 | [{Key, Val}] = Flags, 213 | case Key of 214 | node -> get_remote_env_status(EnvKeys, CuttlefishFlags, Val); 215 | all -> get_remote_env_status(EnvKeys, CuttlefishFlags) 216 | end; 217 | get_env_status(_EnvKeys, _CuttlefishFlags, _Flags) -> 218 | app_config_flags_error(). 219 | 220 | -spec get_local_env_status([envkey()], cuttlefish_flag_list()) -> status(). 221 | get_local_env_status(EnvKeys, CuttlefishFlags) -> 222 | Row = get_local_env_vals(EnvKeys, CuttlefishFlags), 223 | [clique_status:table([Row])]. 224 | 225 | -spec get_local_env_vals([envkey()], cuttlefish_flag_list()) -> list(). 226 | get_local_env_vals(EnvKeys, CuttlefishFlags) -> 227 | Vals = [begin 228 | {ok, Val} = application:get_env(App, Key), 229 | Val1 = case {CFlagSpec, Val} of 230 | {{flag, TrueVal, _}, true} -> 231 | TrueVal; 232 | {{flag, _, FalseVal}, false} -> 233 | FalseVal; 234 | _ -> 235 | Val 236 | end, 237 | FormatterKey = cuttlefish_variable:tokenize(KeyStr), 238 | Val2 = case ets:lookup(?formatter_table, FormatterKey) of 239 | [] -> 240 | Val1; 241 | [{_K, FormatterFun}] -> 242 | FormatterFun(Val1) 243 | end, 244 | {KeyStr, Val2} 245 | end || {{KeyStr, {App, Key}}, CFlagSpec} <- lists:zip(EnvKeys, CuttlefishFlags)], 246 | [{"node", node()} | Vals]. 247 | 248 | -spec get_remote_env_status([envkey()], cuttlefish_flag_list(), node()) -> status() | err(). 249 | get_remote_env_status(EnvKeys, CuttlefishFlags, Node) -> 250 | case clique_nodes:safe_rpc(Node, ?MODULE, get_local_env_status, 251 | [EnvKeys, CuttlefishFlags]) of 252 | {badrpc, Reason} -> 253 | clique_error:badrpc_to_error(Node, Reason); 254 | Status -> 255 | Status 256 | end. 257 | 258 | -spec get_remote_env_status([{atom(), atom()}], cuttlefish_flag_list()) -> status(). 259 | get_remote_env_status(EnvKeys, CuttlefishFlags) -> 260 | Nodes = clique_nodes:nodes(), 261 | {Rows, Down} = rpc:multicall(Nodes, 262 | ?MODULE, 263 | get_local_env_vals, 264 | [EnvKeys, CuttlefishFlags], 265 | 60000), 266 | Table = clique_status:table(Rows), 267 | case (Down == []) of 268 | false -> 269 | Text = io_lib:format("Failed to get config for: ~p~n", [Down]), 270 | Alert = clique_status:alert([clique_status:text(Text)]), 271 | [Table, Alert]; 272 | true -> 273 | [Table] 274 | end. 275 | 276 | 277 | -spec run_callback(err()) -> err(); 278 | (conf()) -> {node, iolist()}. 279 | run_callback({error, _} = E) -> 280 | E; 281 | run_callback(Args) -> 282 | OutStrings = [run_callback(K, V, F) || {K, V} <- Args, {_, F} <- ets:lookup(?config_table, 283 | K)], 284 | Output = string:join(OutStrings, "\n"), %% TODO return multiple strings tagged with keys 285 | %% Tag the return value with our current node so we know 286 | %% where this result came from when we use multicall: 287 | {node(), Output}. 288 | 289 | run_callback(K, V, F) -> 290 | KeyString = cuttlefish_variable:format(K), 291 | UpdateMsg = io_lib:format("~s set to ~p", [KeyString, V]), 292 | case F(K, V) of 293 | "" -> 294 | UpdateMsg; 295 | Output -> 296 | [UpdateMsg, $\n, Output] 297 | end. 298 | 299 | -spec get_config(args()) -> err() | {args(), proplist(), conf()}. 300 | get_config([]) -> 301 | {error, set_no_args}; 302 | get_config(Args) -> 303 | [{schema, Schema}] = ets:lookup(?schema_table, schema), 304 | Conf = [{cuttlefish_variable:tokenize(K), V} || {K, V} <- Args], 305 | case cuttlefish_generator:minimal_map(Schema, Conf) of 306 | {error, _, Msg} -> 307 | {error, {invalid_config, Msg}}; 308 | AppConfig -> 309 | {Args, AppConfig, Conf} 310 | end. 311 | 312 | -spec set_config(err()) -> err(); 313 | ({args(), proplist(), conf()}) -> err() | conf(). 314 | set_config({error, _} = E) -> 315 | E; 316 | set_config({Args, AppConfig, Conf}) -> 317 | Keys = [K || {K, _} <- Args], 318 | case check_keys_in_whitelist(Keys) of 319 | ok -> 320 | _ = set_app_config(AppConfig), 321 | Conf; 322 | {error, _} = E -> 323 | E 324 | end. 325 | 326 | -spec set_app_config(proplist()) -> _. 327 | set_app_config(AppConfig) -> 328 | [application:set_env(App, Key, Val) || {App, Settings} <- AppConfig, 329 | {Key, Val} <- Settings]. 330 | 331 | -spec config_flags() -> flagspecs(). 332 | config_flags() -> 333 | [clique_spec:make({node, [{shortname, "n"}, 334 | {longname, "node"}, 335 | {typecast, fun clique_typecast:to_node/1}, 336 | {description, 337 | "The node to apply the operation on"}]}), 338 | 339 | clique_spec:make({all, [{shortname, "a"}, 340 | {longname, "all"}, 341 | {description, 342 | "Apply the operation to all nodes in the cluster"}]})]. 343 | 344 | 345 | -spec get_valid_mappings([string()]) -> err() | [{string(), cuttlefish_mapping:mapping()}]. 346 | get_valid_mappings(Keys0) -> 347 | Keys = [cuttlefish_variable:tokenize(K) || K <- Keys0], 348 | [{schema, Schema}] = ets:lookup(?schema_table, schema), 349 | {_Translations, Mappings0, _Validators} = Schema, 350 | KeyMappings0 = valid_mappings(Keys, Mappings0), 351 | KeyMappings = match_key_order(Keys0, KeyMappings0), 352 | case length(KeyMappings) =:= length(Keys) of 353 | false -> 354 | Invalid = invalid_keys(Keys, KeyMappings), 355 | {error, {invalid_config_keys, Invalid}}; 356 | true -> 357 | KeyMappings 358 | end. 359 | 360 | -spec valid_mappings([cuttlefish_variable:variable()], [cuttlefish_mapping:mapping()]) -> 361 | [{string(), cuttlefish_mapping:mapping()}]. 362 | valid_mappings(Keys, Mappings) -> 363 | lists:foldl(fun(Mapping, Acc) -> 364 | Key = cuttlefish_mapping:variable(Mapping), 365 | case lists:member(Key, Keys) of 366 | true -> 367 | Key2 = cuttlefish_variable:format(Key), 368 | [{Key2, Mapping} | Acc]; 369 | false -> 370 | Acc 371 | end 372 | end, [], Mappings). 373 | 374 | %% @doc Match the order of Keys in KeyMappings 375 | match_key_order(Keys, KeyMappings) -> 376 | [lists:keyfind(Key, 1, KeyMappings) || Key <- Keys, 377 | lists:keyfind(Key, 1, KeyMappings) /= false]. 378 | 379 | -spec invalid_keys([cuttlefish_variable:variable()], 380 | [{string(), cuttlefish_mapping:mapping()}]) -> [string()]. 381 | invalid_keys(Keys, KeyMappings) -> 382 | Valid = [cuttlefish_variable:tokenize(K) || {K, _M} <- KeyMappings], 383 | Invalid = Keys -- Valid, 384 | [cuttlefish_variable:format(I) ++ " " || I <- Invalid]. 385 | 386 | -spec get_env_keys([{string(), cuttlefish_mapping:mapping()}]) -> [envkey()]. 387 | get_env_keys(Mappings) -> 388 | KeyEnvStrs = [{Var, cuttlefish_mapping:mapping(M)} || {Var, M} <- Mappings], 389 | VarAppAndKeys = [{Var, string:tokens(S, ".")} || {Var, S} <- KeyEnvStrs], 390 | [{Var, {list_to_atom(App), list_to_atom(Key)}} || {Var, [App, Key]} <- VarAppAndKeys]. 391 | 392 | %% This is part of a minor hack we've added for correctly displaying config 393 | %% values of type 'flag'. We pull out any relevant info from the mappings 394 | %% about flag values, and then use it later on to convert true/false values 395 | %% into e.g. on/off for display to the user. 396 | %% 397 | %% Ideally cuttlefish should provide some way of converting values back to 398 | %% their user-friendly versions (like what you would see in the config file 399 | %% or pass to riak-admin set) but that may require some more in-depth work... 400 | -spec get_cuttlefish_flags([{string(), cuttlefish_mapping:mapping()}]) -> cuttlefish_flag_list(). 401 | get_cuttlefish_flags(KeyMappings) -> 402 | NormalizeFlag = fun({_, M}) -> 403 | case cuttlefish_mapping:datatype(M) of 404 | [flag] -> 405 | {flag, on, off}; 406 | [{flag, TrueVal, FalseVal}] -> 407 | {flag, TrueVal, FalseVal}; 408 | _ -> 409 | undefined 410 | end 411 | end, 412 | lists:map(NormalizeFlag, KeyMappings). 413 | 414 | -spec app_config_flags_error() -> err(). 415 | app_config_flags_error() -> 416 | Msg = "Cannot use --all(-a) and --node(-n) at the same time", 417 | {error, {invalid_flag_combination, Msg}}. 418 | 419 | -ifdef(TEST). 420 | -include_lib("eunit/include/eunit.hrl"). 421 | 422 | schema_paths_test_() -> 423 | {setup, 424 | fun clique:create_test_dir/0, 425 | fun clique:delete_test_dir/1, 426 | fun(TestDir) -> 427 | fun() -> 428 | SchFile = filename:join(TestDir, "example.schema"), 429 | ok = file:write_file(SchFile, <<"thisisnotarealschema">>), 430 | Schemas = schema_paths([TestDir]), 431 | ?assertEqual([SchFile], Schemas), 432 | ok = file:delete(SchFile), 433 | ?assertEqual([], schema_paths([TestDir])) 434 | end 435 | end}. 436 | 437 | set_config_test_() -> 438 | {setup, 439 | fun set_config_test_setup/0, 440 | fun set_config_test_teardown/1, 441 | [ 442 | fun test_blacklisted_conf/0, 443 | fun test_set_basic/0, 444 | fun test_set_bad_flags/0, 445 | fun test_set_all_flag/0, 446 | fun test_set_node_flag/0, 447 | fun test_set_config_callback/0, 448 | fun test_set_callback_output/0 449 | ]}. 450 | 451 | -define(SET_TEST_SCHEMA_FILE, "test.schema"). 452 | 453 | set_config_test_setup() -> 454 | TestDir = clique:create_test_dir(), 455 | SchFile = filename:join(TestDir, "test.schema"), 456 | Schema = <<"{mapping, \"test.config\", \"clique.config_test\", [{datatype, integer}]}.">>, 457 | 458 | clique:ensure_stopped(), 459 | ?assertEqual(ok, clique_nodes:init()), 460 | ?assertEqual(true, clique_nodes:register(fun() -> [node()] end)), 461 | 462 | ?assertEqual(ok, file:write_file(SchFile, Schema)), 463 | ?assertEqual(ok, init()), 464 | ?assertEqual(ok, load_schema([TestDir])), 465 | TestDir. 466 | 467 | set_config_test_teardown(TestDir) -> 468 | clique:ensure_stopped(), 469 | clique:delete_test_dir(TestDir). 470 | 471 | test_blacklisted_conf() -> 472 | true = ets:delete_all_objects(?whitelist_table), 473 | ?assertEqual({error, {config_not_settable, ["test.config"]}}, 474 | set([{"test.config", "42"}], [])). 475 | 476 | test_set_basic() -> 477 | ?assertEqual(ok, whitelist(["test.config"])), 478 | 479 | Result = set([{"test.config", "42"}], []), 480 | ?assertNotMatch({error, _}, Result), 481 | ?assertEqual({ok, 42}, application:get_env(clique, config_test)). 482 | 483 | test_set_bad_flags() -> 484 | Result = set([{"test.config", "43"}], [{all, undefined}, {node, node()}]), 485 | ?assertMatch({error, {invalid_flag_combination, _}}, Result). 486 | 487 | test_set_all_flag() -> 488 | ?assertEqual(ok, whitelist(["test.config"])), 489 | Result = set([{"test.config", "44"}], [{all, undefined}]), 490 | ?assertNotMatch({error, _}, Result), 491 | ?assertEqual({ok, 44}, application:get_env(clique, config_test)). 492 | 493 | test_set_node_flag() -> 494 | ?assertEqual(ok, whitelist(["test.config"])), 495 | Result = set([{"test.config", "45"}], [{node, node()}]), 496 | ?assertNotMatch({error, _}, Result), 497 | ?assertEqual({ok, 45}, application:get_env(clique, config_test)). 498 | 499 | test_set_config_callback() -> 500 | true = ets:delete_all_objects(?config_table), 501 | Callback = fun(Key, Val) -> 502 | ?assertEqual(["test", "config"], Key), 503 | application:set_env(clique, config_test_x10, 10 * list_to_integer(Val)), 504 | "Callback called" 505 | end, 506 | ?MODULE:register(["test", "config"], Callback), 507 | set([{"test.config", "47"}], []), 508 | ?assertEqual({ok, 47}, application:get_env(clique, config_test)), 509 | ?assertEqual({ok, 470}, application:get_env(clique, config_test_x10)). 510 | 511 | test_set_callback_output() -> 512 | true = ets:delete_all_objects(?config_table), 513 | Callback = fun(_, _) -> "Done" end, 514 | ?MODULE:register(["test", "config"], Callback), 515 | 516 | ExpectedText = <<"test.config set to \"48\"\nDone">>, 517 | %% Slightly fragile way to test this, since we assume the internal representation 518 | %% for clique text statuses won't change in the future. But this seems better than 519 | %% any alternative I can think of, since two different iolists representing the 520 | %% same data may or may not compare equal. 521 | [{text, OutText}] = set([{"test.config", "48"}], []), 522 | ?assertEqual(ExpectedText, iolist_to_binary(OutText)), 523 | 524 | ExpectedRow = [{"Node", node()}, 525 | {"Node Down/Unreachable", false}, 526 | {"Result", OutText}], 527 | ExpectedTable = [clique_status:table([ExpectedRow])], 528 | Result = set([{"test.config", "48"}], [{all, undefined}]), 529 | ?assertEqual(ExpectedTable, Result). 530 | 531 | -endif. 532 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Erlang CI Actions Status](https://github.com/basho/clique/workflows/Erlang%20CI/badge.svg)](https://github.com/basho/clique/actions) 2 | 3 | # Introduction 4 | Clique is an opinionated framework for building command line interfaces in 5 | Erlang. It provides users with an interface that gives them enough power to 6 | build complex CLIs, but enough constraint to make them appear consistent. 7 | 8 | ### Why Clique ? 9 | When building a CLI for an Erlang application users frequently run into the following 10 | problems: 11 | 12 | * Output is inconsistent across commands and often implemented differently for 13 | each command with little re-use. 14 | * Output is frequently hard to read for humans, hard to parse for machines, or 15 | both. 16 | * Adding a new command to the system often results in glue code and extra work 17 | rather than just writing a function that gathers information or performs a 18 | given user action. 19 | * Setting and showing configuration often only works on a single node. 20 | * Configuration changes with runtime side effects are often difficult to 21 | implement. 22 | 23 | Clique provides a standard way of implementing status, command, usage and 24 | configuration functionality while minimizing the amount of code needed to be 25 | written by users of the library. 26 | 27 | Clique provides the application developer with the following capabilities: 28 | * Implement callbacks that handle a given cli command such as `riak-admin handoff enable outbound` 29 | * Register usage points to show the correct usage in the command hierarchy when 30 | an incomplete command is run or the user issues the `--help` flag. 31 | * Set, show and describe [cuttlefish](https://github.com/basho/cuttlefish) 32 | configuration across one or all nodes: i.e. `riak-admin set anti-entropy=on --all` 33 | * Return a standard status format that allows output of a variety of content 34 | types: human-readable, csv, html, etc... (Note that currently only 35 | human-readable, CSV, and JSON output formats are implemented) 36 | 37 | ### Why Not Clique ? 38 | * You aren't writing a CLI 39 | * You don't want or need to use cuttlefish for configuration 40 | * You only have a few command permutations and the dependency would be overkill 41 | * You already wrote your own cli tool 42 | * You are a masochist 43 | * You dislike your users 44 | 45 | # CLI usage 46 | Clique provides a consistent and flexible interface to the end user of your 47 | application. In the interest of clarity, a few examples will be given to 48 | illustrate common usage. 49 | 50 | ```console 51 | # Show the configuration for 2 config variables. Multiple values can be 52 | # shown by using spaces between them. The --all flag means: give me the values 53 | # on all nodes in the cluster. 54 | 55 | $ riak-admin show transfer_limit leveldb.limited_developer_mem --all 56 | +--------------+--------------+-----------------------------+ 57 | | Node |transfer_limit|leveldb.limited_developer_mem| 58 | +--------------+--------------+-----------------------------+ 59 | |dev1@127.0.0.1| 4 | true | 60 | |dev2@127.0.0.1| 6 | true | 61 | +--------------+--------------+-----------------------------+ 62 | 63 | # Set the transfer_limit config on dev2 64 | $ riak-admin set transfer_limit=6 --node=dev2@127.0.0.1 65 | Set transfer limit for 'dev2@127.0.0.1' to 6 66 | 67 | # Describe 1 or more configuration variables 68 | # Note that the descriptions are the doc comments in the cuttlefish schema 69 | $ riak-admin describe transfer_limit storage_backend 70 | transfer_limit: 71 | Number of concurrent node-to-node transfers allowed. 72 | 73 | storage_backend: 74 | Specifies the storage engine used for Riak's key-value data 75 | and secondary indexes (if supported). 76 | 77 | # Run an aribtrary, user defined command 78 | $ riak-admin handoff enable outbound 79 | Handoff setting successfully updated 80 | 81 | # Show usage information when a command is incompletely specified 82 | $ riak-admin handoff enable 83 | Usage: riak-admin handoff [[--node | -n] ] [--all] 84 | 85 | Enable or disable handoffs on the specified node(s). 86 | If handoffs are disabled in a direction, any currently 87 | running handoffs in that direction will be terminated. 88 | 89 | Options 90 | -n , --node 91 | Modify the setting on the specified node (default: local node only) 92 | -a, --all 93 | Modify the setting on every node in the cluster 94 | 95 | ``` 96 | 97 | # Erlang API 98 | Clique handles all parsing, validation, and type conversion of input data in a 99 | manner similar to getopt. Clique also handles all formatting and output of 100 | status. The user code registers specifications, usage documentation and 101 | callbacks in order to plug into Clique. When a command is run, the code is 102 | appropriately dispatched via the registry. Each registered callback returns a 103 | [status type](https://github.com/basho/clique/blob/develop/src/clique_status.erl) 104 | that allows clique to format the output in a standardized way. 105 | 106 | ### Load Schemas 107 | Clique requires applications to load their cuttlefish schemas prior to calling `register_config/1` or 108 | `register_config_whitelist/1`. Below shows how `riak_core` loads schemas in a flexible manner 109 | allowing for release or test usage. 110 | 111 | ```erlang 112 | load_schema() -> 113 | case application:get_env(riak_core, schema_dirs) of 114 | {ok, Directories} -> 115 | ok = clique_config:load_schema(Directories); 116 | _ -> 117 | ok = clique_config:load_schema([code:lib_dir()]) 118 | end. 119 | ``` 120 | 121 | ### register/1 122 | Register is a convenience function that gets called by an app with a list 123 | of modules that implement the ``clique_handler`` behaviour. This behaviour 124 | implements a single callback: ``register_cli/0``. This callback is meant to wrap 125 | the other registration functions so that each individual command or logical set 126 | of commands can live in their own module and register themselves appropriately. 127 | 128 | ```erlang 129 | %% Register the handler modules 130 | -module(riak_core_cli_registry). 131 | 132 | clique:register([riak_core_cluster_status_handler]). 133 | ``` 134 | 135 | ```erlang 136 | -module(riak_core_cluster_status_handler]). 137 | -export([register_cli/0]). 138 | 139 | -behaviour(clique_handler). 140 | 141 | register_cli() -> 142 | clique:register_config(...), 143 | clique:register_command(...). 144 | ``` 145 | 146 | ### register_node_finder/1 147 | Configuration can be set and shown across nodes. In order to contact the 148 | appropriate nodes, the application needs to tell ``clique`` how to determine that. 149 | ``riak_core`` would do this in the following manner: 150 | 151 | ```erlang 152 | F = fun() -> 153 | {ok, MyRing} = riak_core_ring_manager:get_my_ring(), 154 | riak_core_ring:all_members(MyRing) 155 | end, 156 | clique:register_node_finder(F). 157 | ``` 158 | 159 | Note that this function only needs to be called once per beam. The callback 160 | itself is stored in an ets table, and calling `clique:register_node_finder/1` 161 | again will overwrite it with a new function. 162 | 163 | ### register_config/2 164 | Showing, setting and describing configuration variables is handled automatically 165 | via integration with cuttlefish. The application environment variables can be 166 | set across nodes using the installed cuttlefish schemas. In some instances 167 | however, a configuration change requires doing something else to the cluster 168 | besides just setting variables. For instance, when reducing the 169 | ``transfer_limit``, we want to shutdown any extra handoff processes so we don't 170 | exceed the new limit. 171 | 172 | Configuration specific behaviour can be managed by registering a callback to 173 | fire when a given configuration variable is set on the cli. The callback runs 174 | *after* the corresponding environment variables are set. The callback function 175 | is a 2-arity function that gets called with the original key (as a list of 176 | strings()), and the untranslated value to set (as a string()). 177 | 178 | On the command-line, the flags can be either '--all' to run on all nodes, or 179 | --node N to run on node N instead of the local node. If no flags are given, 180 | the config change will take place on the local node (where the cli command was 181 | run) only. These flags are not visible to the callback function; rather, the 182 | callback function will be called on whichever nodes the config change is being 183 | made. 184 | 185 | Unlike command callbacks, config callbacks need only return a short iolist 186 | describing any immediate results of the config change that may have taken 187 | place. This allows results from --all to be compiled into a table, and lets 188 | results from other invocations be displayed via simple status messages. 189 | 190 | ```erlang 191 | -spec set_transfer_limit(Key :: [string()], Val :: string()) -> Result :: string(). 192 | ... 193 | 194 | Key = ["transfer_limit"], 195 | Callback = fun set_transfer_limit/2, 196 | clique:register_config(Key, Callback). 197 | ``` 198 | 199 | ### register_formatter/2 200 | By default, the clique "show" command displays the underlying config value, as stored in the 201 | corresponding application env variable (the one exception being values of type "flag", which are 202 | automatically displayed by clique as the user-facing flag value defined in the cuttlefish schema). 203 | In many cases this is fine, but sometimes there may be translations defined in the cuttlefish schema 204 | which make it desirable to show config values in a different format than the one used by the 205 | underlying Erlang code. 206 | 207 | To show a specific config value using a different format than the underlying raw application 208 | config, you can register a config formatter against that value's config key: 209 | 210 | ```erlang 211 | F = fun(Val) -> 212 | case Val of 213 | riak_kv_bitcask_backend -> bitcask; 214 | riak_kv_eleveldb_backend -> leveldb; 215 | riak_kv_memory_backend -> memory; 216 | riak_kv_multi_backend -> multi 217 | end 218 | end, 219 | clique:register_formatter(["storage_backend"], F). 220 | ``` 221 | 222 | ### register_config_whitelist/1 223 | A lot of configuration variables are not intended to be set at runtime. In order to prevent the user 224 | from changing them and anticipating the system to use the new values, we don't allow setting of any 225 | variable by default. Each configuration variable that is settable must be added to a whitelist. 226 | 227 | ```erlang 228 | %% Fail Fast if we pass in a value that is not the name of a configuration variable 229 | ok = register_config_whitelist(["transfer_limit", "handoff.outbound", "handoff.inbound"]). 230 | ``` 231 | 232 | Note that in the future we hope to remove the need for this function by adding support for whitelist 233 | annotations to cuttlefish variables instead. 234 | 235 | ### register_command/4 236 | Users can create their own CLI commands that are not directly configuration 237 | related. These commands are relatively free-form, with the only restrictions 238 | being that arguments are key/value pairs and flags come after arguments. For 239 | example: `riak-admin transfer limit --node=dev2@127.0.0.1`. In this case the 240 | command is "riak-admin transfer limit" which gets passed a `--node` flag. There are no k/v 241 | arguments. These commands can be registered with clique in the following 242 | manner: 243 | 244 | ```erlang 245 | Cmd = ["riak-admin", "handoff", "limit"], 246 | 247 | %% Keyspecs look identical to flagspecs but only have a typecast property. 248 | %% There are no key/value arguments for this command 249 | KeySpecs = [], 250 | FlagSpecs = [{node, [{shortname, "n"}, 251 | {longname, "node"}, 252 | {typecast, fun clique_typecast:to_node/1}]}]. 253 | 254 | %% The function which is registered as the callback for this command gets two 255 | %% arguments. One is a proplist of key/value pairs (if any, appropriately 256 | %% typecast as specified), and the other is a proplist of flags (if any, also 257 | %% appropriately typecast). The flags proplist contains the given "longname" 258 | %% converted to an atom as the proplist key. 259 | %% 260 | %% The expected return value of the callback function is `clique_status:status()`. 261 | %% 262 | %% This pattern matching works here because we know we only allow one flag in 263 | %% the flagspec, and the callback only ever fires with valid flags. 264 | Callback = fun(["riak-admin", "handoff", "limit"]=_Cmd, []=_Keys, [{node, Node}]=_Flags) -> 265 | case clique_nodes:safe_rpc(Node, somemod, somefun, []) of 266 | {error, _} -> 267 | Text = clique_status:text("Failed to Do Something"), 268 | [clique_status:alert([Text])]; 269 | {badrpc, _} -> 270 | Text = clique_status:text("Failed to Do Something"), 271 | [clique_status:alert([Text])]; 272 | Val -> 273 | Text = io_lib:format("Some Thing was done. Value = ~p~n", [Val]), 274 | [clique_status:text(Text)] 275 | end 276 | end, 277 | 278 | clique:register_command(Cmd, KeySpecs, FlagSpecs, Callback). 279 | ``` 280 | 281 | #### Command Wildcards 282 | Users can also use the '*' atom any number of times at the end of a command spec 283 | to indicate wildcard fields in the command. This is useful for simple commands that 284 | always requires certain arguments in a clear concise order. For instance, the command 285 | `riak-admin cluster join ` always requires a node name to be specified, 286 | and it would be cumbersome and redundant if a user had to type 287 | `riak-admin cluster join --node=` instead. However, it is recommended that this 288 | feature be used sparingly, and only in cases with a small number of arguments that are 289 | always specified in a clear, obvious order. Too many free-form arguments can impair usability, 290 | and can lead to situations where it's easy to forget the command format or to specify the 291 | arguments in the wrong order. 292 | 293 | When a command is run, it will try to match the exact command spec first, and then look for 294 | progressively fuzzier matches using wildcards, working from the end of the command backward. 295 | For example, if the user runs registers commands for `["my-cmd", "foo", "bar"]`, 296 | `["my-cmd", "foo", '*']`, and `["my-cmd", '*', '*']`, then running "my-cmd foo bar" will always 297 | match the first spec, "my-cmd foo blub" will match the second spec, and "my-cmd baz blub" will 298 | match the final spec. 299 | 300 | The existence of wildcards is the sole reason that the user-inputted command strings are passed 301 | to the callback. If a command is registered without wildcards, the the same command will always 302 | be passed to the callback function, and so that particular argument can be ignored (as it was 303 | in the example above). 304 | 305 | #### Wildcard Keyspecs 306 | Specifying keyspecs for a command has the advantage of doing type conversions and automatically 307 | recognizing if particular keys are valid or not. However, in some cases users may wish to allow 308 | any and all key/value pairs through to the callback. To achieve this, an '_' atom can be used 309 | as a keyspec, and all key=value pairs will be passed to the command callback in a list of type 310 | `[{string(), string()}]`. 311 | 312 | ### register_usage/2 313 | We want to show usage explicitly in many cases, and not with the `--help` flag. 314 | To make this easier, the user must explicitly register usage points. If one of 315 | these points is hit, via longest match with the command string, the registered 316 | usage string will be shown. Note that "Usage: " will be prepended to the string, 317 | so don't add that part in. 318 | 319 | If you'd like to generate usage output dynamically, pass a 0-arity 320 | function that returns an `iolist()` and it will be called when output 321 | is generated. 322 | 323 | ```erlang 324 | handoff_usage() -> 325 | ["riak-admin handoff \n\n", 326 | " View handoff related status\n\n", 327 | " Sub-commands:\n", 328 | " limit Show transfer limit\n\n" 329 | ]. 330 | 331 | handoff_limit_usage() -> 332 | ["riak-admin handoff limit [[--node | -n] ] [--force-rpc | -f]\n\n", 333 | " Show the handoff concurrency limits (transfer_limit) on all nodes.\n\n", 334 | "Options\n\n", 335 | " -n , --node \n", 336 | " Show the handoff limit for the given node only\n\n", 337 | io_lib:format(" This node is: ~p~n", [node()]), 338 | " -f, --force-rpc\n", 339 | " Retrieve the latest value from a given node or nodes via rpc\n", 340 | " instead of using cluster metadata which may not have propagated\n", 341 | " to the local node yet. WARNING: The use of this flag is not\n", 342 | " recommended as it spams the cluster with messages instead of\n", 343 | " just talking to the local node.\n\n" 344 | ]. 345 | 346 | %% Use a static iolist(): 347 | clique:register_usage(["riak-admin", "handoff"], handoff_usage()), 348 | 349 | %% Register a callback for dynamic output: 350 | clique:register_usage(["riak-admin", "handoff", "limit"], fun handoff_limit_usage/0). 351 | ``` 352 | 353 | ### register_writer/2 354 | This is not something most applications will likely need to use, but the 355 | capability exists to create custom output writer modules. Currently you can 356 | specify the `--format=[human|csv|json]` flag on many commands to determine how 357 | the output will be written; registering a new writer "foo" allows you to use 358 | `--format=foo` to write the output using whatever corresponding writer module 359 | you've registered. 360 | 361 | (Note that the JSON writer is a special case, in that it is only available if 362 | the mochijson2 module is present at startup. We wanted to avoid having to 363 | introduce MochiWeb as a hard dependency, so instead we allow users of Clique to 364 | decide for themselves if/how they want to include the mochijson2 module.) 365 | 366 | Writing custom output writers is relatively undocumented right now, and the 367 | values passed to the `write/1` callback may be subject to future changes. But, 368 | the `clique_*_writer` modules in the Clique source tree provide good examples 369 | that can be used for reference. 370 | 371 | ### run/1 372 | `run/1` takes a given command as a list of strings and attempts to run the 373 | command using the registered information. If called with `set`, `show`, or 374 | `describe` as the second argument in the list, the command is treated as 375 | configuration. Note that the first argument is the program/script name. `run/1` 376 | should only need to be called in one place in a given application. In riak_core 377 | it gets called in ``riak_core_console:command/1`` via an rpc call from Nodetool 378 | in the `riak-admin` shell script. The list of arguments given to run are the 379 | actual arguments given in the shell and provided by Nodetool as a list of 380 | strings. This format is the same format in which command line arguments get 381 | passed to an escript `main/1` function. The difference is that when using 382 | Nodetool you typically also pass the name of the script as the first argument, 383 | while escripts only pass the paramaters not including the script name (argv0). 384 | 385 | ```erlang 386 | %% New CLI API 387 | -export([command/1]). 388 | 389 | -spec command([string()]) -> ok. 390 | command(Cmd) -> 391 | %% Example Cmd = ["riak-admin", "handoff"] 392 | %% This is the way arguments get passed in from a shell script using Nodetool. 393 | %% They are passed into an escript main/1 function in the same manner, but 394 | %% without the script name. 395 | clique:run(Cmd). 396 | ``` 397 | 398 | # Status API 399 | Clique provides pretty printing support for status information. In order to do 400 | this it requires status to be formatted in a specific manner when returned from 401 | a command. All custom commands should return a type of ``clique_status:status()``. 402 | 403 | ## Types 404 | Types are abstract and should be generated by invoking the status API instead of assembled directly. 405 | 406 | * `text` - A text value. 407 | * `list` - A list of related values, with or without a label 408 | * `table` - A matrix of related values. 409 | * `alert` - A description of an error. 410 | 411 | Only `alert` values contain nested status types; e.g., a table does not contain cells which are `text` status types. 412 | 413 | ## Type assembly functions 414 | 415 | See descriptions above for the arguments to each. 416 | 417 | * ``clique_status:text/1`` - Takes an `iolist`, returns a `text` object. 418 | * `clique_status:list/2` - Takes a title (`iolist`) and values (a list of `iolist`) intended to be displayed consecutively. 419 | * `clique_status:list/1` - Takes a title-less list of values (as a list of `iolist`) intened to be displayed consecutively 420 | * `clique_status:table/1` - Takes a list of proplists, each representing a row in the table. The keys in the first row represent column headers; each following row (proplist) must contain the same number of tagged tuples in the same order, and the keys are ignored. 421 | * `clique_status:alert/1` - Takes a list of status types representing an error condition. 422 | --------------------------------------------------------------------------------