%% ------------------------------------------------------------------- %% %% cuttlefish_conf: handles the reading and generation of .conf files %% %% Copyright (c) 2013 Basho Technologies, Inc. All Rights Reserved. %% %% This file is provided to you under the Apache License, %% Version 2.0 (the "License"); you may not use this file %% except in compliance with the License. You may obtain %% a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, %% software distributed under the License is distributed on an %% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY %% KIND, either express or implied. See the License for the %% specific language governing permissions and limitations %% under the License. %% %% ------------------------------------------------------------------- -module(cuttlefish_conf). -include_lib("kernel/include/logger.hrl"). -export([ generate/1, generate_file/2, file/1, files/1, is_variable_defined/2, pretty_datatype/1]). -type conf_pair() :: {cuttlefish_variable:variable(), any()}. -type conf() :: [conf_pair()]. -export_type([conf_pair/0, conf/0]). -define(FMT(F, Args), lists:flatten(io_lib:format(F, Args))). is_variable_defined(VariableDef, Conf) -> lists:any(fun({X, _}) -> cuttlefish_variable:is_fuzzy_match(X, VariableDef) end, Conf). -spec files([file:name()]) -> conf() | cuttlefish_error:errorlist(). files(ListOfConfFiles) -> {ValidConf, Errors} = lists:foldl( fun(ConfFile, {ConfAcc, ErrorAcc}) -> case cuttlefish_conf:file(ConfFile) of {errorlist, ErrorList} -> {ConfAcc, ErrorList ++ ErrorAcc}; Conf -> {lists:foldl( fun({K,V}, MiniAcc) -> cuttlefish_util:replace_proplist_value(K, V, MiniAcc) end, ConfAcc, Conf), ErrorAcc} end end, {[], []}, ListOfConfFiles), case {ValidConf, Errors} of {_, []} -> ValidConf; _ -> {errorlist, Errors} end. -spec file(file:name()) -> conf() | cuttlefish_error:errorlist(). file(Filename) -> case conf_parse:file(Filename) of {error, Reason} -> %% Reason is an atom via file:open {errorlist, [{error, {file_open, {Filename, Reason}}}]}; {_Conf, Remainder, {{line, L}, {column, C}}} when is_binary(Remainder) -> {errorlist, [{error, {conf_syntax, {Filename, {L, C}}}}]}; Conf0 -> % go through the conf looking for include directives, fold all into % a single proplist Conf = fold_conf_files(Filename, Conf0), %% Conf is a proplist, check if any of the values are cuttlefish_errors {_, Values} = lists:unzip(Conf), case cuttlefish_error:filter(Values) of {errorlist, []} -> % expand any non-literal values (ie. included values) expand_values(Filename, remove_duplicates(Conf)); {errorlist, ErrorList} -> NewErrorList = [ {error, {in_file, {Filename, E}}} || {error, E} <- ErrorList ], {errorlist, NewErrorList} end end. fold_conf_files(Filename, Conf0) -> lists:foldl(fun({include, Included}, Acc) -> DirName = binary_to_list(filename:join(filename:dirname(Filename), filename:dirname(Included))), BaseName = binary_to_list(filename:basename(Included)), Acc ++ lists:flatten( [file(filename:join(DirName, F)) || F <- filelib:wildcard(BaseName, DirName)]); (KeyValue, Acc) -> Acc ++ [KeyValue] end, [], Conf0). expand_values(Filename, Conf) -> lists:map(fun({K, Value0}) -> case re:run(Value0, "^\\$\\(\\<(.+)\\)$", [unicode, {capture, all_but_first, list}]) of {match, [IncludeFilename0]} -> % This is a value of the format "$( {K, re:replace(Value, "[\n\r]$", "", [unicode, {return, list}])}; {error, Reason} -> throw({unable_to_open, IncludeFilename, Reason}) end; _ -> % normal value, nothing to do {K, Value0} end end, Conf). -spec generate([cuttlefish_mapping:mapping()]) -> [string()]. generate(Mappings) -> lists:foldl( fun(Mapping, ConfFile) -> ConfFile ++ generate_element(Mapping) end, [], Mappings). -spec generate_file([cuttlefish_mapping:mapping()], string()) -> ok. generate_file(Mappings, Filename) -> ConfFileLines = generate(Mappings), {ok, S} = file:open(Filename, [write]), _ = [ begin io:format(S, "~ts~n", [lists:flatten(Line)]) end || Line <- ConfFileLines], % add an include directive at the end that will allow % other conf files in `conf.d` and have them get picked up % in order to override settings % example use case is a k8s configMap that is mapped as a file % to conf.d/erlang_vm.conf io:format(S, "include conf.d/*.conf~n~n", []), _ = file:close(S), ok. -spec generate_element(cuttlefish_mapping:mapping()) -> [string()]. generate_element(MappingRecord) -> Default = get_default(MappingRecord), Key = cuttlefish_mapping:variable(MappingRecord), Commented = cuttlefish_mapping:commented(MappingRecord), Level = cuttlefish_mapping:level(MappingRecord), Hidden = cuttlefish_mapping:hidden(MappingRecord), IncDef = cuttlefish_mapping:include_default(MappingRecord), [Datatype|_] = cuttlefish_mapping:datatype(MappingRecord), %% level != basic OR hidden == true: leave out of generated .conf file %% commented $val: insert into .conf file, but commented out with $val %% include_default $val: substitute '$name' or whatever in the key for $val %% e.g. {include_default, "internal"} %% listener.http.$name -> listener.http.internal Field = cuttlefish_variable:format(cuttlefish_variable:replace_match(Key, IncDef)), case Level of basic -> ok; Level -> _ = ?LOG_WARNING("{level, ~tp} has been deprecated. Use 'hidden' or '{hidden, true}'", [Level]) end, case generate_element(Hidden, Level, Default, Commented) of no -> []; commented -> Comments = generate_comments(MappingRecord), Comments ++ [lists:flatten([ "## ", Field, " = ", cuttlefish_datatypes:to_string(Commented, Datatype) ]), ""]; default -> Comments = generate_comments(MappingRecord), Comments ++ [lists:flatten([ Field, " = ", cuttlefish_datatypes:to_string(Default, Datatype) ]), ""] end. get_default(MappingRecord) -> %% Normally we use `default` to determine what value to use when generating %% a config file, but `new_conf_value` can override that. The reason we need %% a separate attribute to override `default` (instead of just changing the %% default directly) is that `default` also affects default values used for %% config keys that haven't been set to any particular value in the .conf file. %% (See `cuttlefish_generator:add_defaults` for the relevant bits of code.) case cuttlefish_mapping:new_conf_value(MappingRecord) of undefined -> cuttlefish_mapping:default(MappingRecord); Value -> Value end. generate_element(true, _, _, _) -> no; generate_element(false, _, undefined, undefined) -> no; generate_element(false, basic, _Default, undefined) -> default; generate_element(false, basic, _, _Comment) -> commented; generate_element(false, _Level, _Default, _Commented) -> no. -spec generate_comments(cuttlefish_mapping:mapping()) -> [string()]. generate_comments(M) -> DocString = cuttlefish_mapping:doc(M), [DefaultDT|_] = cuttlefish_mapping:datatype(M), Default = case cuttlefish_mapping:default(M) of undefined -> []; Other -> [ "", ?FMT("Default: ~ts", [cuttlefish_datatypes:to_string(Other, DefaultDT)]) ] end, Datatypes = ["", "Acceptable values:" | [ ?FMT(" - ~ts", [pretty_datatype(DT)]) || DT <- cuttlefish_mapping:datatype(M)]], Doc = DocString ++ Default ++ Datatypes, [ "## " ++ D || D <- Doc]. -spec pretty_datatype(cuttlefish_datatypes:datatype() | cuttlefish_datatypes:extended()) -> string(). pretty_datatype(integer) -> "an integer"; pretty_datatype({enum, L}) -> "one of: " ++ string:join([ atom_to_list(A) || A <- L], ", "); pretty_datatype(ip) -> "an IP/port pair, e.g. 127.0.0.1:10011"; pretty_datatype(domain_socket) -> "a Unix Domain Socket, e.g. local:/var/run/app.sock:0"; pretty_datatype({duration, _}) -> "a time duration with units, e.g. '10s' for 10 seconds"; pretty_datatype(bytesize) -> "a byte size with units, e.g. 10GB"; pretty_datatype({integer, I}) -> "the integer " ++ integer_to_list(I); pretty_datatype({string, S}) -> "the text \"" ++ S ++ "\""; pretty_datatype({tagged_string, {Tag, String}}) -> "the text \"" ++ String ++ "\"" ++ " tagged as \"" ++ Tag ++ "\""; pretty_datatype({atom, A}) -> "the text \"" ++ atom_to_list(A) ++ "\""; pretty_datatype({ip, {IP, Port}}) -> ?FMT("the address ~ts:~tp", [IP, Port]); pretty_datatype({domain_socket, {local, Path, Port}}) -> ?FMT("the Unix Domain Socket ~ts:~tp", [Path, Port]); pretty_datatype({{duration,_}, D}) -> "the time duration " ++ D; pretty_datatype({bytesize, B}) -> "the bytesize " ++ B; pretty_datatype(file) -> "the path to a file"; pretty_datatype(directory) -> "the path to a directory"; pretty_datatype({file, F}) -> "the file " ++ F; pretty_datatype({directory, D}) -> "the directory " ++ D; pretty_datatype(flag) -> "on or off"; pretty_datatype({flag, On, Off}) when is_atom(On), is_atom(Off) -> ?FMT("~tp or ~tp", [On, Off]); pretty_datatype({flag, {On,_}, {Off,_}}) -> ?FMT("~tp or ~tp", [On, Off]); pretty_datatype(_) -> "text". %% string and atom remove_duplicates(Conf) -> lists:foldl( fun({K,V}, MiniAcc) -> cuttlefish_util:replace_proplist_value(K, V, MiniAcc) end, [], Conf). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. -ifdef(TEST). generate_element_test() -> TestSchemaElement = cuttlefish_mapping:parse({mapping, "ring_size", "riak_core.ring_creation_size", [ {datatype, integer}, {commented, 64}, {doc, ["Default ring creation size. Make sure it is a power of 2,", "e.g. 16, 32, 64, 128, 256, 512 etc"]} ] }), GeneratedConf = generate_element(TestSchemaElement), ?assertEqual(7, length(GeneratedConf)), ?assertEqual( "## Default ring creation size. Make sure it is a power of 2,", lists:nth(1, GeneratedConf) ), ?assertEqual( "## e.g. 16, 32, 64, 128, 256, 512 etc", lists:nth(2, GeneratedConf) ), ?assertEqual( "## ", lists:nth(3, GeneratedConf) ), ?assertEqual( "## Acceptable values:", lists:nth(4, GeneratedConf) ), ?assertEqual( "## - an integer", lists:nth(5, GeneratedConf) ), ?assertEqual( "## ring_size = 64", lists:nth(6, GeneratedConf) ), ok. generate_conf_default_test() -> TestMappings = [{mapping, "default.absent", "undefined", [{datatype, integer}, {new_conf_value, 42}]}, {mapping, "default.present", "undefined", [{datatype, integer}, {default, -1}, {new_conf_value, 9001}]}], TestSchema = lists:map(fun cuttlefish_mapping:parse/1, TestMappings), GeneratedConf = generate(TestSchema), %% TODO Feels pretty fragile to rely on the number of comment lines not changing... %% Would be nice if we had a good way to pinpoint the line we want to check without %% having to hardcode the line numbers into the lists:nth calls. ?assertEqual( "default.absent = 42", lists:nth(4, GeneratedConf) ), ?assertEqual( "default.present = 9001", lists:nth(11, GeneratedConf) ), ok. generate_dollar_test() -> TestSchemaElement = cuttlefish_mapping:parse({ mapping, "listener.http.$name", "riak_core.http", [ {datatype, ip}, {default, "127.0.0.1:8098"}, {mapping, "riak_core.http"}, {include_default,"internal"} ]}), _GeneratedConf = generate_element(TestSchemaElement), ok. generate_comments_test() -> SchemaElement = cuttlefish_mapping:parse({ mapping, "dont.care", "undefined", [ {doc, ["Hi!", "Bye!"]} ]}), Comments = generate_comments(SchemaElement), ?assertEqual(["## Hi!", "## Bye!", "## ", "## Acceptable values:", "## - text"], Comments). duplicates_test() -> Conf = file("test/multi1.conf"), ?assertEqual(2, length(Conf)), ?assertEqual("3", proplists:get_value(["a","b","c"], Conf)), ?assertEqual("1", proplists:get_value(["a","b","d"], Conf)), ok. duplicates_multi_test() -> Conf = files(["test/multi1.conf", "test/multi2.conf"]), ?assertEqual(2, length(Conf)), ?assertEqual("4", proplists:get_value(["a","b","c"], Conf)), ?assertEqual("1", proplists:get_value(["a","b","d"], Conf)), ok. files_one_nonent_test() -> Conf = files(["test/multi1.conf", "test/nonent.conf"]), ?assertMatch({errorlist,[{error, {file_open, {"test/nonent.conf", _}}}]}, Conf), ok. files_incomplete_parse_test() -> Conf = file("test/incomplete.conf"), ?assertEqual({errorlist, [{error, {conf_syntax, {"test/incomplete.conf", {3, 1}}}}]}, Conf), ok. generate_element_level_advanced_test() -> _ = cuttlefish_test_logging:set_up(), _ = cuttlefish_test_logging:bounce(warning), assert_no_output({level, advanced}), [Log] = cuttlefish_test_logging:get_logs(), ?assertMatch({match, _}, re:run(Log, "{level, advanced} has been deprecated. Use 'hidden' or '{hidden, true}'")), ok. generate_element_level_intermediate_test() -> _ = cuttlefish_test_logging:set_up(), _ = cuttlefish_test_logging:bounce(warning), assert_no_output({level, intermediate}), [Log] = cuttlefish_test_logging:get_logs(), ?assertMatch({match, _}, re:run(Log, "{level, intermediate} has been deprecated. Use 'hidden' or '{hidden, true}'")), ok. generate_element_hidden_test() -> _ = cuttlefish_test_logging:set_up(), _ = cuttlefish_test_logging:bounce(warning), assert_no_output(hidden), assert_no_output({hidden, true}), ?assertEqual([], cuttlefish_test_logging:get_logs()), ok. included_file_test() -> Conf = file("test/include_file.conf"), ?assertEqual(lists:sort([ {["ring_size"],"32"}, {["anti_entropy"],"debug"}, {["log","error","file"],"/var/log/error.log"}, {["log","console","file"],"/var/log/console.log"}, {["log","syslog"],"on"}, {["listener","http","internal"],"127.0.0.1:8098"}, {["listener","http","external"],"10.0.0.1:80"} ]), lists:sort(Conf)), ok. included_dir_test() -> Conf = file("test/include_dir.conf"), ?assertEqual(lists:sort([ {["anti_entropy"],"debug"}, {["log","error","file"],"/var/log/error.log"}, {["log","console","file"],"/var/log/console.log"}, {["ring_size"],"5"}, {["rogue","option"],"42"}, {["listener","http","internal"],"127.0.0.1:8098"}, {["listener","http","external"],"10.0.0.1:80"}, {["log","syslog"],"off"} ]), lists:sort(Conf)), ok. included_value_test() -> Conf = file("test/included_value.conf"), ?assertEqual(lists:sort([ {["value1"], "42"}, {["value2"], "43"}, {["value3"], "42"}, {["value4"], "multi\nline\nvalue"}, {["value5"], "12.34"}, {["value6"], "$v1 [$v2] $v3 $v4"} ]), lists:sort(Conf)), ok. assert_no_output(Setting) -> Mapping = cuttlefish_mapping:parse( {mapping, "a", "b", [ {doc, ["blah"]}, Setting ]}), ?assertEqual([], generate_element(Mapping)). -endif.