summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNick Vatamaniuc <vatamane@apache.org>2022-10-26 02:03:44 -0400
committerNick Vatamaniuc <nickva@users.noreply.github.com>2022-10-26 10:01:26 -0400
commit8a71e3db8a43d8f7e916cbe5f7e5b8be507501c2 (patch)
treefcad8d6e9790901c1139956d746f75d6fddec534
parent4e9c5588d765b84742784de5aafa146d14eed11f (diff)
downloadcouchdb-8a71e3db8a43d8f7e916cbe5f7e5b8be507501c2.tar.gz
Integrate config app into main repo
As per consensus in ML discussion https://lists.apache.org/thread/9dphqb6mjh1v234v15rcft7mfpjx9223
-rw-r--r--.gitignore3
-rw-r--r--rebar.config.script2
-rw-r--r--src/config/LICENSE202
-rw-r--r--src/config/src/config.app.src.script32
-rw-r--r--src/config/src/config.erl629
-rw-r--r--src/config/src/config_app.erl66
-rw-r--r--src/config/src/config_listener.erl75
-rw-r--r--src/config/src/config_listener_mon.erl80
-rw-r--r--src/config/src/config_notifier.erl79
-rw-r--r--src/config/src/config_sup.erl39
-rw-r--r--src/config/src/config_util.erl75
-rw-r--r--src/config/src/config_writer.erl73
-rw-r--r--src/config/test/config_tests.erl830
-rw-r--r--src/config/test/fixtures/config_default_test.ini23
-rw-r--r--src/config/test/fixtures/config_tests_1.ini22
-rw-r--r--src/config/test/fixtures/config_tests_2.ini22
-rw-r--r--src/config/test/fixtures/default.d/extra.ini19
-rw-r--r--src/config/test/fixtures/local.d/extra.ini19
18 files changed, 2287 insertions, 3 deletions
diff --git a/.gitignore b/.gitignore
index 518ec7b19..acf98e5c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,7 +19,7 @@ debian/
log
apache-couchdb-*/
bin/
-config.erl
+/config.erl
*.tar.gz
*.tar.bz2
dev/*.beam
@@ -42,7 +42,6 @@ share/www
src/b64url/
src/bear/
src/certifi/
-src/config/
src/couch/priv/couch_js/**/config.h
src/couch/priv/couchjs
src/couch/priv/couchspawnkillable
diff --git a/rebar.config.script b/rebar.config.script
index bfae0c85f..3b91431f5 100644
--- a/rebar.config.script
+++ b/rebar.config.script
@@ -108,6 +108,7 @@ os:putenv("COUCHDB_APPS_CONFIG_DIR", filename:join([COUCHDB_ROOT, "rel/apps"])).
SubDirs = [
%% must be compiled first as it has a custom behavior
"src/couch_epi",
+ "src/config",
"src/couch_log",
"src/chttpd",
"src/couch",
@@ -141,7 +142,6 @@ SubDirs = [
DepDescs = [
%% Independent Apps
-{config, "config", {tag, "2.2.1"}},
{b64url, "b64url", {tag, "1.0.3"}},
{ets_lru, "ets-lru", {tag, "1.1.0"}},
{khash, "khash", {tag, "1.1.0"}},
diff --git a/src/config/LICENSE b/src/config/LICENSE
new file mode 100644
index 000000000..f6cd2bc80
--- /dev/null
+++ b/src/config/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/src/config/src/config.app.src.script b/src/config/src/config.app.src.script
new file mode 100644
index 000000000..e4faf276f
--- /dev/null
+++ b/src/config/src/config.app.src.script
@@ -0,0 +1,32 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+ConfigPath = filename:join([os:getenv("COUCHDB_APPS_CONFIG_DIR"), "config.config"]),
+AppEnv = case filelib:is_file(ConfigPath) of
+ true ->
+ {ok, Result} = file:consult(ConfigPath),
+ Result;
+ false ->
+ []
+end.
+
+{application, config, [
+ {description, "INI file configuration system for Apache CouchDB"},
+ {vsn, git},
+ {registered, [
+ config,
+ config_event
+ ]},
+ {applications, [kernel, stdlib]},
+ {mod, {config_app, []}},
+ {env, AppEnv}
+]}.
diff --git a/src/config/src/config.erl b/src/config/src/config.erl
new file mode 100644
index 000000000..58e3a40d9
--- /dev/null
+++ b/src/config/src/config.erl
@@ -0,0 +1,629 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+% Reads CouchDB's ini file and gets queried for configuration parameters.
+% This module is initialized with a list of ini files that it consecutively
+% reads Key/Value pairs from and saves them in an ets table. If more an one
+% ini file is specified, the last one is used to write changes that are made
+% with store/2 back to that ini file.
+
+-module(config).
+-behaviour(gen_server).
+-vsn(1).
+
+-export([start_link/1, stop/0, reload/0]).
+
+-export([all/0]).
+-export([get/1, get/2, get/3]).
+-export([set/3, set/4, set/5]).
+-export([delete/2, delete/3, delete/4]).
+
+-export([get_integer/3, set_integer/3, set_integer/4]).
+-export([get_float/3, set_float/3, set_float/4]).
+-export([get_boolean/3, set_boolean/3, set_boolean/4]).
+
+-export([features/0, enable_feature/1, disable_feature/1]).
+
+-export([listen_for_changes/2]).
+-export([subscribe_for_changes/1]).
+-export([parse_ini_file/1]).
+
+-export([init/1, terminate/2, code_change/3]).
+-export([handle_call/3, handle_cast/2, handle_info/2]).
+
+-export([is_sensitive/2]).
+
+-define(FEATURES, "features").
+
+-define(TIMEOUT, 30000).
+-define(INVALID_SECTION, <<"Invalid configuration section">>).
+-define(INVALID_KEY, <<"Invalid configuration key">>).
+-define(INVALID_VALUE, <<"Invalid configuration value">>).
+
+-record(config, {
+ notify_funs = [],
+ ini_files = undefined,
+ write_filename = undefined
+}).
+
+start_link(IniFiles) ->
+ gen_server:start_link({local, ?MODULE}, ?MODULE, IniFiles, []).
+
+stop() ->
+ gen_server:cast(?MODULE, stop).
+
+reload() ->
+ gen_server:call(?MODULE, reload, ?TIMEOUT).
+
+all() ->
+ lists:sort(gen_server:call(?MODULE, all, infinity)).
+
+get_integer(Section, Key, Default) when is_integer(Default) ->
+ try
+ to_integer(get(Section, Key, Default))
+ catch
+ error:badarg ->
+ Default
+ end.
+
+set_integer(Section, Key, Value) ->
+ set_integer(Section, Key, Value, true).
+
+set_integer(Section, Key, Value, Persist) when is_integer(Value) ->
+ set(Section, Key, integer_to_list(Value), Persist);
+set_integer(_, _, _, _) ->
+ error(badarg).
+
+to_integer(List) when is_list(List) ->
+ list_to_integer(List);
+to_integer(Int) when is_integer(Int) ->
+ Int;
+to_integer(Bin) when is_binary(Bin) ->
+ list_to_integer(binary_to_list(Bin)).
+
+get_float(Section, Key, Default) when is_float(Default) ->
+ try
+ to_float(get(Section, Key, Default))
+ catch
+ error:badarg ->
+ Default
+ end.
+
+set_float(Section, Key, Value) ->
+ set_float(Section, Key, Value, true).
+
+set_float(Section, Key, Value, Persist) when is_float(Value) ->
+ set(Section, Key, float_to_list(Value), Persist);
+set_float(_, _, _, _) ->
+ error(badarg).
+
+to_float(List) when is_list(List) ->
+ list_to_float(List);
+to_float(Float) when is_float(Float) ->
+ Float;
+to_float(Int) when is_integer(Int) ->
+ list_to_float(integer_to_list(Int) ++ ".0");
+to_float(Bin) when is_binary(Bin) ->
+ list_to_float(binary_to_list(Bin)).
+
+get_boolean(Section, Key, Default) when is_boolean(Default) ->
+ try
+ to_boolean(get(Section, Key, Default))
+ catch
+ error:badarg ->
+ Default
+ end.
+
+set_boolean(Section, Key, Value) ->
+ set_boolean(Section, Key, Value, true).
+
+set_boolean(Section, Key, true, Persist) ->
+ set(Section, Key, "true", Persist);
+set_boolean(Section, Key, false, Persist) ->
+ set(Section, Key, "false", Persist);
+set_boolean(_, _, _, _) ->
+ error(badarg).
+
+to_boolean(List) when is_list(List) ->
+ case list_to_existing_atom(List) of
+ true ->
+ true;
+ false ->
+ false;
+ _ ->
+ error(badarg)
+ end;
+to_boolean(Bool) when is_boolean(Bool) ->
+ Bool.
+
+get(Section) when is_binary(Section) ->
+ ?MODULE:get(binary_to_list(Section));
+get(Section) when is_list(Section) ->
+ Matches = ets:match(?MODULE, {{Section, '$1'}, '$2'}),
+ [{Key, Value} || [Key, Value] <- Matches].
+
+get(Section, Key) ->
+ ?MODULE:get(Section, Key, undefined).
+
+get(Section, Key, Default) when is_binary(Section) and is_binary(Key) ->
+ ?MODULE:get(binary_to_list(Section), binary_to_list(Key), Default);
+get(Section, Key, Default) when is_list(Section), is_list(Key) ->
+ case ets:lookup(?MODULE, {Section, Key}) of
+ [] when Default == undefined -> Default;
+ [] when is_boolean(Default) -> Default;
+ [] when is_float(Default) -> Default;
+ [] when is_integer(Default) -> Default;
+ [] when is_list(Default) -> Default;
+ [] when is_atom(Default) -> Default;
+ [] -> error(badarg);
+ [{_, Match}] -> Match
+ end.
+
+set(Section, Key, Value) ->
+ ?MODULE:set(Section, Key, Value, true, nil).
+
+set(Sec, Key, Val, Opts) when is_binary(Sec) and is_binary(Key) ->
+ ?MODULE:set(binary_to_list(Sec), binary_to_list(Key), Val, Opts);
+set(Section, Key, Value, Persist) when is_boolean(Persist) ->
+ ?MODULE:set(Section, Key, Value, #{persist => Persist});
+set(Section, Key, Value, #{} = Opts) when
+ is_list(Section), is_list(Key), is_list(Value)
+->
+ gen_server:call(?MODULE, {set, Section, Key, Value, Opts}, ?TIMEOUT);
+set(Section, Key, Value, Reason) when
+ is_list(Section), is_list(Key), is_list(Value)
+->
+ ?MODULE:set(Section, Key, Value, #{persist => true, reason => Reason});
+set(_Sec, _Key, _Val, _Options) ->
+ error(badarg).
+
+set(Section, Key, Value, Persist, Reason) when
+ is_list(Section), is_list(Key), is_list(Value)
+->
+ ?MODULE:set(Section, Key, Value, #{persist => Persist, reason => Reason}).
+
+delete(Section, Key) when is_binary(Section) and is_binary(Key) ->
+ delete(binary_to_list(Section), binary_to_list(Key));
+delete(Section, Key) ->
+ delete(Section, Key, true, nil).
+
+delete(Section, Key, Persist) when is_boolean(Persist) ->
+ delete(Section, Key, Persist, nil);
+delete(Section, Key, Reason) ->
+ delete(Section, Key, true, Reason).
+
+delete(Sec, Key, Persist, Reason) when is_binary(Sec) and is_binary(Key) ->
+ delete(binary_to_list(Sec), binary_to_list(Key), Persist, Reason);
+delete(Section, Key, Persist, Reason) when is_list(Section), is_list(Key) ->
+ gen_server:call(
+ ?MODULE,
+ {delete, Section, Key, Persist, Reason},
+ ?TIMEOUT
+ ).
+
+features() ->
+ application:get_env(config, enabled_features, []).
+
+enable_feature(Feature) when is_atom(Feature) ->
+ application:set_env(
+ config,
+ enabled_features,
+ lists:usort([Feature | features()]),
+ [{persistent, true}]
+ ).
+
+disable_feature(Feature) when is_atom(Feature) ->
+ application:set_env(
+ config,
+ enabled_features,
+ features() -- [Feature],
+ [{persistent, true}]
+ ).
+
+listen_for_changes(CallbackModule, InitialState) ->
+ config_listener_mon:subscribe(CallbackModule, InitialState).
+
+subscribe_for_changes(Subscription) ->
+ config_notifier:subscribe(Subscription).
+
+init(IniFiles) ->
+ ets:new(?MODULE, [named_table, set, protected, {read_concurrency, true}]),
+ lists:map(
+ fun(IniFile) ->
+ {ok, ParsedIniValues} = parse_ini_file(IniFile),
+ ets:insert(?MODULE, ParsedIniValues)
+ end,
+ IniFiles
+ ),
+ WriteFile =
+ case IniFiles of
+ [_ | _] -> lists:last(IniFiles);
+ _ -> undefined
+ end,
+ debug_config(),
+ {ok, #config{ini_files = IniFiles, write_filename = WriteFile}}.
+
+terminate(_Reason, _State) ->
+ ok.
+
+handle_call(all, _From, Config) ->
+ Resp = lists:sort((ets:tab2list(?MODULE))),
+ {reply, Resp, Config};
+handle_call({set, Sec, Key, Val, Opts}, _From, Config) ->
+ Persist = maps:get(persist, Opts, true),
+ Reason = maps:get(reason, Opts, nil),
+ IsSensitive = is_sensitive(Sec, Key),
+ case validate_config_update(Sec, Key, Val) of
+ {error, ValidationError} when IsSensitive ->
+ couch_log:error(
+ "~p: [~s] ~s = '****' rejected for reason ~p",
+ [?MODULE, Sec, Key, Reason]
+ ),
+ {reply, {error, ValidationError}, Config};
+ {error, ValidationError} ->
+ couch_log:error(
+ "~p: [~s] ~s = '~s' rejected for reason ~p",
+ [?MODULE, Sec, Key, Val, Reason]
+ ),
+ {reply, {error, ValidationError}, Config};
+ ok ->
+ true = ets:insert(?MODULE, {{Sec, Key}, Val}),
+ case IsSensitive of
+ false ->
+ couch_log:notice(
+ "~p: [~s] ~s set to ~s for reason ~p",
+ [?MODULE, Sec, Key, Val, Reason]
+ );
+ true ->
+ couch_log:notice(
+ "~p: [~s] ~s set to '****' for reason ~p",
+ [?MODULE, Sec, Key, Reason]
+ )
+ end,
+ ConfigWriteReturn =
+ case {Persist, Config#config.write_filename} of
+ {true, undefined} ->
+ ok;
+ {true, FileName} ->
+ config_writer:save_to_file({{Sec, Key}, Val}, FileName);
+ _ ->
+ ok
+ end,
+ case ConfigWriteReturn of
+ ok ->
+ Event = {config_change, Sec, Key, Val, Persist},
+ gen_event:sync_notify(config_event, Event),
+ {reply, ok, Config};
+ {error, Else} ->
+ {reply, {error, Else}, Config}
+ end
+ end;
+handle_call({delete, Sec, Key, Persist, Reason}, _From, Config) ->
+ true = ets:delete(?MODULE, {Sec, Key}),
+ couch_log:notice(
+ "~p: [~s] ~s deleted for reason ~p",
+ [?MODULE, Sec, Key, Reason]
+ ),
+ ConfigDeleteReturn =
+ case {Persist, Config#config.write_filename} of
+ {true, undefined} ->
+ ok;
+ {true, FileName} ->
+ config_writer:save_to_file({{Sec, Key}, ""}, FileName);
+ _ ->
+ ok
+ end,
+ case ConfigDeleteReturn of
+ ok ->
+ Event = {config_change, Sec, Key, deleted, Persist},
+ gen_event:sync_notify(config_event, Event),
+ {reply, ok, Config};
+ Else ->
+ {reply, Else, Config}
+ end;
+handle_call(reload, _From, Config) ->
+ DiskKVs = lists:foldl(
+ fun(IniFile, DiskKVs0) ->
+ {ok, ParsedIniValues} = parse_ini_file(IniFile),
+ lists:foldl(
+ fun({K, V}, DiskKVs1) ->
+ dict:store(K, V, DiskKVs1)
+ end,
+ DiskKVs0,
+ ParsedIniValues
+ )
+ end,
+ dict:new(),
+ Config#config.ini_files
+ ),
+ % Update ets with anything we just read
+ % from disk
+ dict:fold(
+ fun({Sec, Key} = K, V, _) ->
+ VExisting = get(Sec, Key, V),
+ ets:insert(?MODULE, {K, V}),
+ case V =:= VExisting of
+ true ->
+ ok;
+ false ->
+ case is_sensitive(Sec, Key) of
+ false ->
+ couch_log:notice(
+ "Reload detected config change ~s.~s = ~p",
+ [Sec, Key, V]
+ );
+ true ->
+ couch_log:notice(
+ "Reload detected config change ~s.~s = '****'",
+ [Sec, Key]
+ )
+ end,
+ Event = {config_change, Sec, Key, V, true},
+ gen_event:sync_notify(config_event, Event)
+ end
+ end,
+ nil,
+ DiskKVs
+ ),
+ % And remove anything in ets that wasn't
+ % on disk.
+ ets:foldl(
+ fun({{Sec, Key} = K, _}, _) ->
+ case dict:is_key(K, DiskKVs) of
+ true ->
+ ok;
+ false ->
+ couch_log:notice("Reload deleting in-memory config ~s.~s", [Sec, Key]),
+ ets:delete(?MODULE, K),
+ Event = {config_change, Sec, Key, deleted, true},
+ gen_event:sync_notify(config_event, Event)
+ end
+ end,
+ nil,
+ ?MODULE
+ ),
+ {reply, ok, Config}.
+
+handle_cast(stop, State) ->
+ {stop, normal, State};
+handle_cast(_Msg, State) ->
+ {noreply, State}.
+
+handle_info(Info, State) ->
+ couch_log:error("config:handle_info Info: ~p~n", [Info]),
+ {noreply, State}.
+
+code_change(_OldVsn, State, _Extra) ->
+ {ok, State}.
+
+is_sensitive(Section, Key) ->
+ Sensitive = application:get_env(config, sensitive, #{}),
+ case maps:get(Section, Sensitive, false) of
+ all -> true;
+ Fields when is_list(Fields) -> lists:member(Key, Fields);
+ _ -> false
+ end.
+
+parse_ini_file(IniFile) ->
+ IniFilename = config_util:abs_pathname(IniFile),
+ IniBin =
+ case file:read_file(IniFilename) of
+ {ok, IniBin0} ->
+ IniBin0;
+ {error, enoent} ->
+ Fmt = "Couldn't find server configuration file ~s.",
+ Msg = list_to_binary(io_lib:format(Fmt, [IniFilename])),
+ couch_log:error("~s~n", [Msg]),
+ throw({startup_error, Msg})
+ end,
+
+ Lines = re:split(IniBin, "\r\n|\n|\r|\032", [{return, list}]),
+ {_, ParsedIniValues} =
+ lists:foldl(
+ fun(Line, {AccSectionName, AccValues}) ->
+ case string:strip(Line) of
+ "[" ++ Rest ->
+ case re:split(Rest, "\\]", [{return, list}]) of
+ [NewSectionName, ""] ->
+ {NewSectionName, AccValues};
+ % end bracket not at end, ignore this line
+ _Else ->
+ {AccSectionName, AccValues}
+ end;
+ ";" ++ _Comment ->
+ {AccSectionName, AccValues};
+ Line2 ->
+ case re:split(Line2, "\s?=\s?", [{return, list}]) of
+ [Value] ->
+ MultiLineValuePart =
+ case re:run(Line, "^ \\S", []) of
+ {match, _} ->
+ true;
+ _ ->
+ false
+ end,
+ case {MultiLineValuePart, AccValues} of
+ {true, [{{_, ValueName}, PrevValue} | AccValuesRest]} ->
+ % remove comment
+ case re:split(Value, " ;|\t;", [{return, list}]) of
+ [[]] ->
+ % empty line
+ {AccSectionName, AccValues};
+ [LineValue | _Rest] ->
+ E = {
+ {AccSectionName, ValueName},
+ PrevValue ++ " " ++ LineValue
+ },
+ {AccSectionName, [E | AccValuesRest]}
+ end;
+ _ ->
+ {AccSectionName, AccValues}
+ end;
+ % line begins with "=", ignore
+ ["" | _LineValues] ->
+ {AccSectionName, AccValues};
+ % yeehaw, got a line!
+ [ValueName | LineValues] ->
+ RemainingLine = config_util:implode(LineValues, "="),
+ % removes comments
+ case re:split(RemainingLine, " ;|\t;", [{return, list}]) of
+ [[]] ->
+ % empty line means delete this key
+ ets:delete(?MODULE, {AccSectionName, ValueName}),
+ {AccSectionName, AccValues};
+ [LineValue | _Rest] ->
+ LineValueWithoutLeadTrailWS = string:trim(LineValue),
+ {AccSectionName, [
+ {
+ {AccSectionName, ValueName},
+ LineValueWithoutLeadTrailWS
+ }
+ | AccValues
+ ]}
+ end
+ end
+ end
+ end,
+ {"", []},
+ Lines
+ ),
+ {ok, ParsedIniValues}.
+
+debug_config() ->
+ case ?MODULE:get("log", "level") of
+ "debug" ->
+ io:format("Configuration Settings:~n", []),
+ lists:foreach(
+ fun({{Mod, Key}, Val}) ->
+ io:format(" [~s] ~s=~p~n", [Mod, Key, Val])
+ end,
+ lists:sort(ets:tab2list(?MODULE))
+ );
+ _ ->
+ ok
+ end.
+
+validate_config_update(Sec, Key, Val) ->
+ %% See https://erlang.org/doc/man/re.html &
+ %% https://pcre.org/original/doc/html/pcrepattern.html
+ %%
+ %% only characters that are actually screen-visible are allowed
+ %% tabs and spaces are allowed
+ %% no [ ] explicitly to avoid section header bypass
+ {ok, Forbidden} = re:compile("[\]\[]+", [dollar_endonly, unicode]),
+ %% Values are permitted [ ] characters as we use these in
+ %% places like mochiweb socket option lists
+ %% Values may also be empty to delete manual configuration
+ {ok, Printable} = re:compile(
+ "^[[:graph:]\t\s]*$",
+ [dollar_endonly, unicode]
+ ),
+ case
+ {
+ re:run(Sec, Printable),
+ re:run(Sec, Forbidden),
+ re:run(Key, Printable),
+ re:run(Key, Forbidden),
+ re:run(Val, Printable)
+ }
+ of
+ {{match, _}, nomatch, {match, _}, nomatch, {match, _}} -> ok;
+ {nomatch, _, _, _, _} -> {error, ?INVALID_SECTION};
+ {_, {match, _}, _, _, _} -> {error, ?INVALID_SECTION};
+ {_, _, nomatch, _, _} -> {error, ?INVALID_KEY};
+ {_, _, _, {match, _}, _} -> {error, ?INVALID_KEY};
+ {_, _, _, _, nomatch} -> {error, ?INVALID_VALUE}
+ end.
+
+-ifdef(TEST).
+-include_lib("eunit/include/eunit.hrl").
+
+to_integer_test() ->
+ ?assertEqual(1, to_integer(1)),
+ ?assertEqual(1, to_integer(<<"1">>)),
+ ?assertEqual(1, to_integer("1")),
+ ?assertEqual(-1, to_integer("-01")),
+ ?assertEqual(0, to_integer("-0")),
+ ?assertEqual(0, to_integer("+0")),
+ ok.
+
+to_float_test() ->
+ ?assertEqual(1.0, to_float(1)),
+ ?assertEqual(1.0, to_float(<<"1.0">>)),
+ ?assertEqual(1.0, to_float("1.0")),
+ ?assertEqual(-1.1, to_float("-01.1")),
+ ?assertEqual(0.0, to_float("-0.0")),
+ ?assertEqual(0.0, to_float("+0.0")),
+ ok.
+
+validation_test() ->
+ ?assertEqual(ok, validate_config_update("section", "key", "value")),
+ ?assertEqual(ok, validate_config_update("delete", "empty_value", "")),
+ ?assertEqual(
+ {error, ?INVALID_SECTION},
+ validate_config_update("sect[ion", "key", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_SECTION},
+ validate_config_update("sect]ion", "key", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_SECTION},
+ validate_config_update("section\n", "key", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_SECTION},
+ validate_config_update("section\r", "key", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_SECTION},
+ validate_config_update("section\r\n", "key", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_KEY},
+ validate_config_update("section", "key\n", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_KEY},
+ validate_config_update("section", "key\r", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_KEY},
+ validate_config_update("section", "key\r\n", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_VALUE},
+ validate_config_update("section", "key", "value\n")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_VALUE},
+ validate_config_update("section", "key", "value\r")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_VALUE},
+ validate_config_update("section", "key", "value\r\n")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_KEY},
+ validate_config_update("section", "k[ey", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_KEY},
+ validate_config_update("section", "[key", "value")
+ ),
+ ?assertEqual(
+ {error, ?INVALID_KEY},
+ validate_config_update("section", "key]", "value")
+ ),
+ ok.
+
+-endif.
diff --git a/src/config/src/config_app.erl b/src/config/src/config_app.erl
new file mode 100644
index 000000000..8ba58e6d2
--- /dev/null
+++ b/src/config/src/config_app.erl
@@ -0,0 +1,66 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(config_app).
+
+-behaviour(application).
+
+%% Application callbacks
+-export([start/2, stop/1]).
+
+%% ===================================================================
+%% Application callbacks
+%% ===================================================================
+
+start(_StartType, _StartArgs) ->
+ config_sup:start_link(get_ini_files()).
+
+stop(_State) ->
+ ok.
+
+get_ini_files() ->
+ IniFiles = hd([L || L <- [command_line(), env(), default()], L =/= skip]),
+ lists:flatmap(fun expand_dirs/1, IniFiles).
+
+env() ->
+ case application:get_env(config, ini_files) of
+ undefined ->
+ skip;
+ {ok, IniFiles} ->
+ IniFiles
+ end.
+
+command_line() ->
+ case init:get_argument(couch_ini) of
+ error ->
+ skip;
+ {ok, [IniFiles]} ->
+ IniFiles
+ end.
+
+default() ->
+ Etc = filename:join(code:root_dir(), "etc"),
+ Default = [
+ filename:join(Etc, "default.ini"),
+ filename:join(Etc, "default.d"),
+ filename:join(Etc, "local.ini"),
+ filename:join(Etc, "local.d")
+ ],
+ lists:filter(fun filelib:is_file/1, Default).
+
+expand_dirs(File) ->
+ case filelib:is_dir(File) of
+ true ->
+ lists:sort(filelib:wildcard(File ++ "/*.ini"));
+ false ->
+ [File]
+ end.
diff --git a/src/config/src/config_listener.erl b/src/config/src/config_listener.erl
new file mode 100644
index 000000000..dede2e35e
--- /dev/null
+++ b/src/config/src/config_listener.erl
@@ -0,0 +1,75 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(config_listener).
+
+-behaviour(gen_event).
+-vsn(2).
+
+%% Public interface
+-export([start/2]).
+-export([start/3]).
+
+%% Required gen_event interface
+-export([
+ init/1,
+ handle_event/2,
+ handle_call/2,
+ handle_info/2,
+ terminate/2,
+ code_change/3
+]).
+
+-callback handle_config_change(
+ Sec :: string(),
+ Key :: string(),
+ Value :: string(),
+ Persist :: boolean(),
+ State :: term()
+) ->
+ {ok, term()} | remove_handler.
+
+-callback handle_config_terminate(
+ Subscriber :: pid(),
+ Reason :: term(),
+ State :: term()
+) ->
+ term().
+
+start(Module, State) ->
+ start(Module, Module, State).
+
+start(Module, Id, State) ->
+ gen_event:add_sup_handler(config_event, {?MODULE, Id}, {Module, State}).
+
+init({Module, State}) ->
+ {ok, {Module, State}}.
+
+handle_event({config_change, Sec, Key, Value, Persist}, {Module, {From, State}}) ->
+ case Module:handle_config_change(Sec, Key, Value, Persist, State) of
+ {ok, NewState} ->
+ {ok, {Module, {From, NewState}}};
+ remove_handler ->
+ remove_handler
+ end.
+
+handle_call(_Request, St) ->
+ {ok, ignored, St}.
+
+handle_info(_Info, St) ->
+ {ok, St}.
+
+terminate(Reason, {Module, {Subscriber, State}}) ->
+ Module:handle_config_terminate(Subscriber, Reason, State).
+
+code_change(_OldVsn, St, _Extra) ->
+ {ok, St}.
diff --git a/src/config/src/config_listener_mon.erl b/src/config/src/config_listener_mon.erl
new file mode 100644
index 000000000..02cfd9502
--- /dev/null
+++ b/src/config/src/config_listener_mon.erl
@@ -0,0 +1,80 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(config_listener_mon).
+-behaviour(gen_server).
+-vsn(1).
+
+-export([
+ subscribe/2,
+ start_link/2
+]).
+
+-export([
+ init/1,
+ terminate/2,
+ handle_call/3,
+ handle_cast/2,
+ handle_info/2,
+ code_change/3
+]).
+
+-record(st, {
+ pid,
+ ref
+}).
+
+start_link(Module, InitSt) ->
+ proc_lib:start_link(?MODULE, init, [{self(), Module, InitSt}]).
+
+subscribe(Module, InitSt) ->
+ case proc_lib:start(?MODULE, init, [{self(), Module, InitSt}]) of
+ {ok, _} -> ok;
+ Else -> Else
+ end.
+
+init({Pid, Mod, InitSt}) ->
+ Ref = erlang:monitor(process, Pid),
+ case config_listener:start(Mod, {Mod, Pid}, {Pid, InitSt}) of
+ ok ->
+ proc_lib:init_ack({ok, self()}),
+ gen_server:enter_loop(?MODULE, [], #st{pid = Pid, ref = Ref});
+ Else ->
+ proc_lib:init_ack(Else)
+ end.
+
+terminate(_Reason, _St) ->
+ ok.
+
+handle_call(_Message, _From, St) ->
+ {reply, ignored, St}.
+
+handle_cast(_Message, St) ->
+ {noreply, St}.
+
+handle_info({'DOWN', Ref, _, _, _}, #st{ref = Ref} = St) ->
+ {stop, normal, St};
+handle_info({gen_event_EXIT, {config_listener, Module}, Reason}, St) ->
+ Level =
+ case Reason of
+ normal -> debug;
+ shutdown -> debug;
+ _ -> error
+ end,
+ Fmt = "config_listener(~p) for ~p stopped with reason: ~r~n",
+ couch_log:Level(Fmt, [Module, St#st.pid, Reason]),
+ {stop, shutdown, St};
+handle_info(_, St) ->
+ {noreply, St}.
+
+code_change(_OldVsn, St, _Extra) ->
+ {ok, St}.
diff --git a/src/config/src/config_notifier.erl b/src/config/src/config_notifier.erl
new file mode 100644
index 000000000..d09dd4fbc
--- /dev/null
+++ b/src/config/src/config_notifier.erl
@@ -0,0 +1,79 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(config_notifier).
+
+-behaviour(gen_event).
+-vsn(1).
+
+%% Public interface
+-export([subscribe/1]).
+-export([subscribe/2]).
+
+%% gen_event interface
+-export([
+ init/1,
+ handle_event/2,
+ handle_call/2,
+ handle_info/2,
+ terminate/2,
+ code_change/3
+]).
+
+subscribe(Subscription) ->
+ subscribe(self(), Subscription).
+
+subscribe(Subscriber, Subscription) ->
+ case lists:member(Subscriber, handlers()) of
+ true ->
+ ok;
+ false ->
+ gen_event:add_sup_handler(
+ config_event, {?MODULE, Subscriber}, {Subscriber, Subscription}
+ )
+ end.
+
+init({Subscriber, Subscription}) ->
+ {ok, {Subscriber, Subscription}}.
+
+handle_event({config_change, _, _, _, _} = Event, {Subscriber, Subscription}) ->
+ maybe_notify(Event, Subscriber, Subscription),
+ {ok, {Subscriber, Subscription}}.
+
+handle_call(_Request, St) ->
+ {ok, ignored, St}.
+
+handle_info(_Info, St) ->
+ {ok, St}.
+
+terminate(_Reason, {_Subscriber, _Subscription}) ->
+ ok.
+
+code_change(_OldVsn, St, _Extra) ->
+ {ok, St}.
+
+maybe_notify(Event, Subscriber, all) ->
+ Subscriber ! Event;
+maybe_notify({config_change, Sec, Key, _, _} = Event, Subscriber, Subscription) ->
+ case should_notify(Sec, Key, Subscription) of
+ true ->
+ Subscriber ! Event;
+ false ->
+ ok
+ end.
+
+should_notify(Sec, Key, Subscription) ->
+ lists:any(fun(S) -> S =:= Sec orelse S =:= {Sec, Key} end, Subscription).
+
+handlers() ->
+ AllHandlers = gen_event:which_handlers(config_event),
+ [Id || {?MODULE, Id} <- AllHandlers].
diff --git a/src/config/src/config_sup.erl b/src/config/src/config_sup.erl
new file mode 100644
index 000000000..64e1e03bb
--- /dev/null
+++ b/src/config/src/config_sup.erl
@@ -0,0 +1,39 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(config_sup).
+-behaviour(supervisor).
+
+%% API
+-export([start_link/1]).
+
+%% Supervisor callbacks
+-export([init/1]).
+
+%% ===================================================================
+%% API functions
+%% ===================================================================
+
+start_link(IniFiles) ->
+ supervisor:start_link({local, ?MODULE}, ?MODULE, IniFiles).
+
+%% ===================================================================
+%% Supervisor callbacks
+%% ===================================================================
+
+init(IniFiles) ->
+ Children = [
+ {config, {config, start_link, [IniFiles]}, permanent, 5000, worker, [config]},
+ {config_event, {gen_event, start_link, [{local, config_event}]}, permanent, 5000, worker,
+ dynamic}
+ ],
+ {ok, {{one_for_one, 5, 10}, Children}}.
diff --git a/src/config/src/config_util.erl b/src/config/src/config_util.erl
new file mode 100644
index 000000000..d71117ab0
--- /dev/null
+++ b/src/config/src/config_util.erl
@@ -0,0 +1,75 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(config_util).
+
+-export([abs_pathname/1]).
+-export([abs_pathname/2]).
+-export([implode/2]).
+
+% given a pathname "../foo/bar/" it gives back the fully qualified
+% absolute pathname.
+abs_pathname(" " ++ Filename) ->
+ % strip leading whitspace
+ abs_pathname(Filename);
+abs_pathname([$/ | _] = Filename) ->
+ Filename;
+abs_pathname(Filename) ->
+ {ok, Cwd} = file:get_cwd(),
+ {Filename2, Args} = separate_cmd_args(Filename, ""),
+ abs_pathname(Filename2, Cwd) ++ Args.
+
+abs_pathname(Filename, Dir) ->
+ Name = filename:absname(Filename, Dir ++ "/"),
+ OutFilename = filename:join(fix_path_list(filename:split(Name), [])),
+ % If the filename is a dir (last char slash, put back end slash
+ case string:right(Filename, 1) of
+ "/" ->
+ OutFilename ++ "/";
+ "\\" ->
+ OutFilename ++ "/";
+ _Else ->
+ OutFilename
+ end.
+
+implode(List, Sep) ->
+ implode(List, Sep, []).
+
+implode([], _Sep, Acc) ->
+ lists:flatten(lists:reverse(Acc));
+implode([H], Sep, Acc) ->
+ implode([], Sep, [H | Acc]);
+implode([H | T], Sep, Acc) ->
+ implode(T, Sep, [Sep, H | Acc]).
+
+% if this as an executable with arguments, seperate out the arguments
+% ""./foo\ bar.sh -baz=blah" -> {"./foo\ bar.sh", " -baz=blah"}
+separate_cmd_args("", CmdAcc) ->
+ {lists:reverse(CmdAcc), ""};
+% handle skipped value
+separate_cmd_args("\\ " ++ Rest, CmdAcc) ->
+ separate_cmd_args(Rest, " \\" ++ CmdAcc);
+separate_cmd_args(" " ++ Rest, CmdAcc) ->
+ {lists:reverse(CmdAcc), " " ++ Rest};
+separate_cmd_args([Char | Rest], CmdAcc) ->
+ separate_cmd_args(Rest, [Char | CmdAcc]).
+
+% takes a heirarchical list of dirs and removes the dots ".", double dots
+% ".." and the corresponding parent dirs.
+fix_path_list([], Acc) ->
+ lists:reverse(Acc);
+fix_path_list([".." | Rest], [_PrevAcc | RestAcc]) ->
+ fix_path_list(Rest, RestAcc);
+fix_path_list(["." | Rest], Acc) ->
+ fix_path_list(Rest, Acc);
+fix_path_list([Dir | Rest], Acc) ->
+ fix_path_list(Rest, [Dir | Acc]).
diff --git a/src/config/src/config_writer.erl b/src/config/src/config_writer.erl
new file mode 100644
index 000000000..2417416c3
--- /dev/null
+++ b/src/config/src/config_writer.erl
@@ -0,0 +1,73 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+%% @doc Saves a Key/Value pair to a ini file. The Key consists of a Section
+%% and Option combination. If that combination is found in the ini file
+%% the new value replaces the old value. If only the Section is found the
+%% Option and value combination is appended to the Section. If the Section
+%% does not yet exist in the ini file, it is added and the Option/Value
+%% pair is appended.
+%% @see config
+
+-module(config_writer).
+
+-export([save_to_file/2]).
+
+%% @spec save_to_file(
+%% Config::{{Section::string(), Option::string()}, Value::string()},
+%% File::filename()) -> ok
+%% @doc Saves a Section/Key/Value triple to the ini file File::filename()
+save_to_file({{Section, Key}, Value}, File) ->
+ {ok, OldFileContents} = file:read_file(File),
+ Lines = re:split(OldFileContents, "\r\n|\n|\r|\032", [{return, list}]),
+
+ SectionLine = "[" ++ Section ++ "]",
+ {ok, Pattern} = re:compile(["^(\\Q", Key, "\\E\\s*=)|\\[[a-zA-Z0-9\.\_-]*\\]"]),
+
+ NewLines = process_file_lines(Lines, [], SectionLine, Pattern, Key, Value),
+ NewFileContents = reverse_and_add_newline(strip_empty_lines(NewLines), []),
+ file:write_file(File, NewFileContents).
+
+process_file_lines([Section | Rest], SeenLines, Section, Pattern, Key, Value) ->
+ process_section_lines(Rest, [Section | SeenLines], Pattern, Key, Value);
+process_file_lines([Line | Rest], SeenLines, Section, Pattern, Key, Value) ->
+ process_file_lines(Rest, [Line | SeenLines], Section, Pattern, Key, Value);
+process_file_lines([], SeenLines, Section, _Pattern, Key, Value) ->
+ % Section wasn't found. Append it with the option here.
+ [Key ++ " = " ++ Value, Section, "" | strip_empty_lines(SeenLines)].
+
+process_section_lines([Line | Rest], SeenLines, Pattern, Key, Value) ->
+ case re:run(Line, Pattern, [{capture, all_but_first}]) of
+ % Found nothing interesting. Move on.
+ nomatch ->
+ process_section_lines(Rest, [Line | SeenLines], Pattern, Key, Value);
+ % Found another section. Append the option here.
+ {match, []} ->
+ lists:reverse(Rest) ++
+ [Line, "", Key ++ " = " ++ Value | strip_empty_lines(SeenLines)];
+ % Found the option itself. Replace it.
+ {match, _} ->
+ lists:reverse(Rest) ++ [Key ++ " = " ++ Value | SeenLines]
+ end;
+process_section_lines([], SeenLines, _Pattern, Key, Value) ->
+ % Found end of file within the section. Append the option here.
+ [Key ++ " = " ++ Value | strip_empty_lines(SeenLines)].
+
+reverse_and_add_newline([Line | Rest], Content) ->
+ reverse_and_add_newline(Rest, [Line, "\n", Content]);
+reverse_and_add_newline([], Content) ->
+ Content.
+
+strip_empty_lines(["" | Rest]) ->
+ strip_empty_lines(Rest);
+strip_empty_lines(All) ->
+ All.
diff --git a/src/config/test/config_tests.erl b/src/config/test/config_tests.erl
new file mode 100644
index 000000000..3d3ee9c94
--- /dev/null
+++ b/src/config/test/config_tests.erl
@@ -0,0 +1,830 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License. You may obtain a copy of
+% the License at
+%
+% http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+-module(config_tests).
+-behaviour(config_listener).
+
+-export([
+ handle_config_change/5,
+ handle_config_terminate/3
+]).
+
+-include_lib("couch/include/couch_eunit.hrl").
+-include_lib("couch/include/couch_db.hrl").
+
+-define(TIMEOUT, 4000).
+-define(RESTART_TIMEOUT_IN_MILLISEC, 3000).
+
+-define(CONFIG_FIXTURESDIR,
+ filename:join([?BUILDDIR(), "src", "config", "test", "fixtures"])
+).
+
+-define(CONFIG_DEFAULT_TEST,
+ filename:join([?CONFIG_FIXTURESDIR, "config_default_test.ini"])
+).
+
+-define(CONFIG_FIXTURE_1,
+ filename:join([?CONFIG_FIXTURESDIR, "config_tests_1.ini"])
+).
+
+-define(CONFIG_FIXTURE_2,
+ filename:join([?CONFIG_FIXTURESDIR, "config_tests_2.ini"])
+).
+
+-define(CONFIG_DEFAULT_D,
+ filename:join([?CONFIG_FIXTURESDIR, "default.d"])
+).
+
+-define(CONFIG_LOCAL_D,
+ filename:join([?CONFIG_FIXTURESDIR, "local.d"])
+).
+
+-define(CONFIG_FIXTURE_TEMP, begin
+ FileName = filename:join([?TEMPDIR, "config_temp.ini"]),
+ {ok, Fd} = file:open(FileName, write),
+ ok = file:truncate(Fd),
+ ok = file:close(Fd),
+ FileName
+end).
+
+-define(T(F), {erlang:fun_to_list(F), F}).
+-define(FEXT(F), fun(_, _) -> F() end).
+
+setup() ->
+ setup(?CONFIG_CHAIN).
+
+setup({temporary, Chain}) ->
+ setup(Chain);
+setup({persistent, Chain}) ->
+ setup(Chain ++ [?CONFIG_FIXTURE_TEMP]);
+setup(Chain) ->
+ meck:new(couch_log),
+ meck:expect(couch_log, error, fun(_, _) -> ok end),
+ meck:expect(couch_log, notice, fun(_, _) -> ok end),
+ meck:expect(couch_log, debug, fun(_, _) -> ok end),
+ ok = application:set_env(config, ini_files, Chain),
+ test_util:start_applications([config]).
+
+setup_empty() ->
+ setup([]).
+
+setup_config_listener() ->
+ Apps = setup(),
+ Pid = spawn_config_listener(),
+ {Apps, Pid}.
+
+setup_config_notifier(Subscription) ->
+ Apps = setup(),
+ Pid = spawn_config_notifier(Subscription),
+ {Apps, Pid}.
+
+teardown({Apps, Pid}) when is_pid(Pid) ->
+ catch exit(Pid, kill),
+ teardown(Apps);
+teardown(Apps) when is_list(Apps) ->
+ meck:unload(),
+ test_util:stop_applications(Apps).
+
+teardown(_, {Apps, Pid}) when is_pid(Pid) ->
+ catch exit(Pid, kill),
+ teardown(Apps);
+teardown(_, Apps) ->
+ teardown(Apps).
+
+handle_config_change("remove_handler", _Key, _Value, _Persist, {_Pid, _State}) ->
+ remove_handler;
+handle_config_change("update_state", Key, Value, Persist, {Pid, State}) ->
+ Pid ! {config_msg, {{"update_state", Key, Value, Persist}, State}},
+ {ok, {Pid, Key}};
+handle_config_change("throw_error", _Key, _Value, _Persist, {_Pid, _State}) ->
+ throw(this_is_an_error);
+handle_config_change(Section, Key, Value, Persist, {Pid, State}) ->
+ Pid ! {config_msg, {{Section, Key, Value, Persist}, State}},
+ {ok, {Pid, State}}.
+
+handle_config_terminate(Self, Reason, {Pid, State}) ->
+ Pid ! {config_msg, {Self, Reason, State}},
+ ok.
+
+config_get_test_() ->
+ {
+ "Config get tests",
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ fun should_load_all_configs/0,
+ fun should_return_undefined_atom_on_missed_section/0,
+ fun should_return_undefined_atom_on_missed_option/0,
+ fun should_return_custom_default_value_on_missed_option/0,
+ fun should_only_return_default_on_missed_option/0,
+ fun should_fail_to_get_binary_value/0,
+ fun should_return_any_supported_default/0
+ ]
+ }
+ }.
+
+config_set_test_() ->
+ {
+ "Config set tests",
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ fun should_update_option/0,
+ fun should_create_new_section/0,
+ fun should_fail_to_set_binary_value/0
+ ]
+ }
+ }.
+
+config_del_test_() ->
+ {
+ "Config deletion tests",
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ fun should_return_undefined_atom_after_option_deletion/0,
+ fun should_be_ok_on_deleting_unknown_options/0
+ ]
+ }
+ }.
+
+config_features_test_() ->
+ {
+ "Config features tests",
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ {"enable", fun should_enable_features/0},
+ {"disable", fun should_disable_features/0},
+ {"restart config", fun should_keep_features_on_config_restart/0}
+ ]
+ }
+ }.
+
+config_override_test_() ->
+ {
+ "Configs overide tests",
+ {
+ foreachx,
+ fun setup/1,
+ fun teardown/2,
+ [
+ {{temporary, [?CONFIG_DEFAULT_TEST]}, fun should_ensure_in_defaults/2},
+ {
+ {temporary, [?CONFIG_DEFAULT_TEST, ?CONFIG_FIXTURE_1]},
+ fun should_override_options/2
+ },
+ {
+ {temporary, [?CONFIG_DEFAULT_TEST, ?CONFIG_FIXTURE_2]},
+ fun should_create_new_sections_on_override/2
+ },
+ {
+ {temporary, [
+ ?CONFIG_DEFAULT_TEST,
+ ?CONFIG_FIXTURE_1,
+ ?CONFIG_FIXTURE_2
+ ]},
+ fun should_win_last_in_chain/2
+ },
+ {
+ {temporary, [?CONFIG_DEFAULT_TEST, ?CONFIG_DEFAULT_D]},
+ fun should_read_default_d/2
+ },
+ {{temporary, [?CONFIG_DEFAULT_TEST, ?CONFIG_LOCAL_D]}, fun should_read_local_d/2},
+ {
+ {temporary, [
+ ?CONFIG_DEFAULT_TEST,
+ ?CONFIG_DEFAULT_D,
+ ?CONFIG_LOCAL_D
+ ]},
+ fun should_read_default_and_local_d/2
+ }
+ ]
+ }
+ }.
+
+config_persistent_changes_test_() ->
+ {
+ "Config persistent changes",
+ {
+ foreachx,
+ fun setup/1,
+ fun teardown/2,
+ [
+ {{persistent, [?CONFIG_DEFAULT_TEST]}, fun should_write_changes/2},
+ {{temporary, [?CONFIG_DEFAULT_TEST]}, fun should_ensure_default_wasnt_modified/2},
+ {
+ {temporary, [?CONFIG_FIXTURE_TEMP]},
+ fun should_ensure_written_to_last_config_in_chain/2
+ }
+ ]
+ }
+ }.
+
+config_no_files_test_() ->
+ {
+ "Test config with no files",
+ {
+ foreach,
+ fun setup_empty/0,
+ fun teardown/1,
+ [
+ fun should_ensure_that_no_ini_files_loaded/0,
+ fun should_create_non_persistent_option/0,
+ fun should_create_persistent_option/0
+ ]
+ }
+ }.
+
+config_listener_behaviour_test_() ->
+ {
+ "Test config_listener behaviour",
+ {
+ foreach,
+ local,
+ fun setup_config_listener/0,
+ fun teardown/1,
+ [
+ fun should_handle_value_change/1,
+ fun should_pass_correct_state_to_handle_config_change/1,
+ fun should_pass_correct_state_to_handle_config_terminate/1,
+ fun should_pass_subscriber_pid_to_handle_config_terminate/1,
+ fun should_not_call_handle_config_after_related_process_death/1,
+ fun should_remove_handler_when_requested/1,
+ fun should_remove_handler_when_pid_exits/1,
+ fun should_stop_monitor_on_error/1
+ ]
+ }
+ }.
+
+config_notifier_behaviour_test_() ->
+ {
+ "Test config_notifier behaviour",
+ {
+ foreachx,
+ local,
+ fun setup_config_notifier/1,
+ fun teardown/2,
+ [
+ {all, fun should_notify/2},
+ {["section_foo"], fun should_notify/2},
+ {[{"section_foo", "key_bar"}], fun should_notify/2},
+ {["section_foo"], fun should_not_notify/2},
+ {[{"section_foo", "key_bar"}], fun should_not_notify/2},
+ {all, fun should_unsubscribe_when_subscriber_gone/2},
+ {all, fun should_not_add_duplicate/2},
+ {all, fun should_notify_on_config_reload/2},
+ {all, fun should_notify_on_config_reload_flush/2}
+ ]
+ }
+ }.
+
+config_key_has_regex_test_() ->
+ {
+ "Test key with regex can be compiled and written to file",
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ fun should_handle_regex_patterns_in_key/0
+ ]
+ }
+ }.
+
+config_access_right_test_() ->
+ {
+ "Test config file access right",
+ {
+ foreach,
+ fun setup/0,
+ fun teardown/1,
+ [
+ fun should_write_config_to_file/0,
+ fun should_delete_config_from_file/0,
+ fun should_not_write_config_to_file/0,
+ fun should_not_delete_config_from_file/0
+ ]
+ }
+ }.
+
+should_write_config_to_file() ->
+ ?assertEqual(ok, config:set("admins", "foo", "500", true)).
+
+should_handle_regex_patterns_in_key() ->
+ ?assertEqual(ok, config:set("sect1", "pat||*", "true", true)),
+ ?assertEqual([{"pat||*", "true"}], config:get("sect1")).
+
+should_delete_config_from_file() ->
+ ?assertEqual(ok, config:delete("admins", "foo", true)).
+
+should_not_write_config_to_file() ->
+ meck:new(config_writer),
+ meck:expect(config_writer, save_to_file, fun(_, _) -> {error, eacces} end),
+ ?assertEqual({error, eacces}, config:set("admins", "foo", "500", true)),
+ meck:unload(config_writer).
+
+should_not_delete_config_from_file() ->
+ meck:new(config_writer),
+ meck:expect(config_writer, save_to_file, fun(_, _) -> {error, eacces} end),
+ ?assertEqual({error, eacces}, config:delete("admins", "foo", true)),
+ meck:unload(config_writer).
+
+should_load_all_configs() ->
+ ?assert(length(config:all()) > 0).
+
+should_return_undefined_atom_on_missed_section() ->
+ ?assertEqual(undefined, config:get("foo", "bar")).
+
+should_return_undefined_atom_on_missed_option() ->
+ ?assertEqual(undefined, config:get("httpd", "foo")).
+
+should_return_custom_default_value_on_missed_option() ->
+ ?assertEqual("bar", config:get("httpd", "foo", "bar")).
+
+should_only_return_default_on_missed_option() ->
+ ?assertEqual("0", config:get("httpd", "port", "bar")).
+
+should_fail_to_get_binary_value() ->
+ ?assertException(error, badarg, config:get(<<"a">>, <<"b">>, <<"c">>)).
+
+should_return_any_supported_default() ->
+ Values = [undefined, "list", true, false, 0.1, 1],
+ lists:map(
+ fun(V) ->
+ ?assertEqual(V, config:get(<<"foo">>, <<"bar">>, V))
+ end,
+ Values
+ ).
+
+should_update_option() ->
+ ok = config:set("mock_log", "level", "severe", false),
+ ?assertEqual("severe", config:get("mock_log", "level")).
+
+should_create_new_section() ->
+ ?assertEqual(undefined, config:get("new_section", "bizzle")),
+ ?assertEqual(ok, config:set("new_section", "bizzle", "bang", false)),
+ ?assertEqual("bang", config:get("new_section", "bizzle")).
+
+should_fail_to_set_binary_value() ->
+ ?assertException(
+ error,
+ badarg,
+ config:set(<<"a">>, <<"b">>, <<"c">>, false)
+ ).
+
+should_return_undefined_atom_after_option_deletion() ->
+ ?assertEqual(ok, config:delete("mock_log", "level", false)),
+ ?assertEqual(undefined, config:get("mock_log", "level")).
+
+should_be_ok_on_deleting_unknown_options() ->
+ ?assertEqual(ok, config:delete("zoo", "boo", false)).
+
+should_ensure_in_defaults(_, _) ->
+ ?_test(begin
+ ?assertEqual("500", config:get("couchdb", "max_dbs_open")),
+ ?assertEqual("5986", config:get("httpd", "port")),
+ ?assertEqual(undefined, config:get("fizbang", "unicode"))
+ end).
+
+should_override_options(_, _) ->
+ ?_test(begin
+ ?assertEqual("10", config:get("couchdb", "max_dbs_open")),
+ ?assertEqual("4895", config:get("httpd", "port"))
+ end).
+
+should_read_default_d(_, _) ->
+ ?_test(begin
+ ?assertEqual("11", config:get("couchdb", "max_dbs_open"))
+ end).
+
+should_read_local_d(_, _) ->
+ ?_test(begin
+ ?assertEqual("12", config:get("couchdb", "max_dbs_open"))
+ end).
+
+should_read_default_and_local_d(_, _) ->
+ ?_test(begin
+ ?assertEqual("12", config:get("couchdb", "max_dbs_open"))
+ end).
+
+should_create_new_sections_on_override(_, _) ->
+ ?_test(begin
+ ?assertEqual("80", config:get("httpd", "port")),
+ ?assertEqual("normalized", config:get("fizbang", "unicode"))
+ end).
+
+should_win_last_in_chain(_, _) ->
+ ?_test(begin
+ ?assertEqual("80", config:get("httpd", "port"))
+ end).
+
+should_write_changes(_, _) ->
+ ?_test(begin
+ ?assertEqual("5986", config:get("httpd", "port")),
+ ?assertEqual(ok, config:set("httpd", "port", "8080")),
+ ?assertEqual("8080", config:get("httpd", "port")),
+ ?assertEqual(ok, config:delete("httpd", "bind_address", "8080")),
+ ?assertEqual(undefined, config:get("httpd", "bind_address"))
+ end).
+
+should_ensure_default_wasnt_modified(_, _) ->
+ ?_test(begin
+ ?assertEqual("5986", config:get("httpd", "port")),
+ ?assertEqual("127.0.0.1", config:get("httpd", "bind_address"))
+ end).
+
+should_ensure_written_to_last_config_in_chain(_, _) ->
+ ?_test(begin
+ ?assertEqual("8080", config:get("httpd", "port")),
+ ?assertEqual(undefined, config:get("httpd", "bind_address"))
+ end).
+
+should_ensure_that_no_ini_files_loaded() ->
+ ?assertEqual(0, length(config:all())).
+
+should_create_non_persistent_option() ->
+ ?_test(begin
+ ?assertEqual(ok, config:set("httpd", "port", "80", false)),
+ ?assertEqual("80", config:get("httpd", "port"))
+ end).
+
+should_create_persistent_option() ->
+ ?_test(begin
+ ?assertEqual(ok, config:set("httpd", "bind_address", "127.0.0.1")),
+ ?assertEqual("127.0.0.1", config:get("httpd", "bind_address"))
+ end).
+
+should_handle_value_change({_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(ok, config:set("httpd", "port", "80", false)),
+ ?assertMatch({{"httpd", "port", "80", false}, _}, getmsg(Pid))
+ end).
+
+should_pass_correct_state_to_handle_config_change({_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(ok, config:set("update_state", "foo", "any", false)),
+ ?assertMatch({_, undefined}, getmsg(Pid)),
+ ?assertEqual(ok, config:set("httpd", "port", "80", false)),
+ ?assertMatch({_, "foo"}, getmsg(Pid))
+ end).
+
+should_pass_correct_state_to_handle_config_terminate({_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(ok, config:set("update_state", "foo", "any", false)),
+ ?assertMatch({_, undefined}, getmsg(Pid)),
+ ?assertEqual(ok, config:set("httpd", "port", "80", false)),
+ ?assertMatch({_, "foo"}, getmsg(Pid)),
+ ?assertEqual(ok, config:set("remove_handler", "any", "any", false)),
+ ?assertEqual({Pid, remove_handler, "foo"}, getmsg(Pid))
+ end).
+
+should_pass_subscriber_pid_to_handle_config_terminate({_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(ok, config:set("remove_handler", "any", "any", false)),
+ ?assertEqual({Pid, remove_handler, undefined}, getmsg(Pid))
+ end).
+
+should_not_call_handle_config_after_related_process_death({_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(ok, config:set("remove_handler", "any", "any", false)),
+ ?assertEqual({Pid, remove_handler, undefined}, getmsg(Pid)),
+ ?assertEqual(ok, config:set("httpd", "port", "80", false)),
+ Event =
+ receive
+ {config_msg, _} -> got_msg
+ after 250 -> no_msg
+ end,
+ ?assertEqual(no_msg, Event)
+ end).
+
+should_remove_handler_when_requested({_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(1, n_handlers()),
+ ?assertEqual(ok, config:set("remove_handler", "any", "any", false)),
+ ?assertEqual({Pid, remove_handler, undefined}, getmsg(Pid)),
+ ?assertEqual(0, n_handlers())
+ end).
+
+should_remove_handler_when_pid_exits({_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(1, n_handlers()),
+
+ % Monitor the config_listener_mon process
+ {monitored_by, [Mon]} = process_info(Pid, monitored_by),
+ MonRef = erlang:monitor(process, Mon),
+
+ % Kill the process synchronously
+ PidRef = erlang:monitor(process, Pid),
+ exit(Pid, kill),
+ receive
+ {'DOWN', PidRef, _, _, _} -> ok
+ after ?TIMEOUT ->
+ erlang:error({timeout, config_listener_death})
+ end,
+
+ % Wait for the config_listener_mon process to
+ % exit to indicate the handler has been removed.
+ receive
+ {'DOWN', MonRef, _, _, normal} -> ok
+ after ?TIMEOUT ->
+ erlang:error({timeout, config_listener_mon_death})
+ end,
+
+ ?assertEqual(0, n_handlers())
+ end).
+
+should_stop_monitor_on_error({_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(1, n_handlers()),
+
+ % Monitor the config_listener_mon process
+ {monitored_by, [Mon]} = process_info(Pid, monitored_by),
+ MonRef = erlang:monitor(process, Mon),
+
+ % Have the process throw an error
+ ?assertEqual(ok, config:set("throw_error", "foo", "bar", false)),
+
+ % Make sure handle_config_terminate is called
+ ?assertEqual({Pid, {error, this_is_an_error}, undefined}, getmsg(Pid)),
+
+ % Wait for the config_listener_mon process to
+ % exit to indicate the handler has been removed
+ % due to an error
+ receive
+ {'DOWN', MonRef, _, _, shutdown} -> ok
+ after ?TIMEOUT ->
+ erlang:error({timeout, config_listener_mon_shutdown})
+ end,
+
+ ?assertEqual(0, n_handlers())
+ end).
+
+should_notify(Subscription, {_Apps, Pid}) ->
+ {
+ to_string(Subscription),
+ ?_test(begin
+ ?assertEqual(ok, config:set("section_foo", "key_bar", "any", false)),
+ ?assertEqual({config_change, "section_foo", "key_bar", "any", false}, getmsg(Pid)),
+ ok
+ end)
+ }.
+
+should_not_notify([{Section, _}] = Subscription, {_Apps, Pid}) ->
+ {
+ to_string(Subscription),
+ ?_test(begin
+ ?assertEqual(ok, config:set(Section, "any", "any", false)),
+ ?assertError({timeout, config_msg}, getmsg(Pid)),
+ ok
+ end)
+ };
+should_not_notify(Subscription, {_Apps, Pid}) ->
+ {
+ to_string(Subscription),
+ ?_test(begin
+ ?assertEqual(ok, config:set("any", "any", "any", false)),
+ ?assertError({timeout, config_msg}, getmsg(Pid)),
+ ok
+ end)
+ }.
+
+should_unsubscribe_when_subscriber_gone(_Subscription, {_Apps, Pid}) ->
+ ?_test(begin
+ ?assertEqual(1, n_notifiers()),
+
+ ?assert(is_process_alive(Pid)),
+
+ % Monitor subscriber process
+ MonRef = erlang:monitor(process, Pid),
+
+ exit(Pid, kill),
+
+ % Wait for the subscriber process to exit
+ receive
+ {'DOWN', MonRef, _, _, _} -> ok
+ after ?TIMEOUT ->
+ erlang:error({timeout, config_notifier_shutdown})
+ end,
+
+ ?assertNot(is_process_alive(Pid)),
+
+ ?assertEqual(0, n_notifiers()),
+ ok
+ end).
+
+should_not_add_duplicate(_, _) ->
+ ?_test(begin
+ %% spawned from setup
+ ?assertEqual(1, n_notifiers()),
+
+ ?assertMatch(ok, config:subscribe_for_changes(all)),
+
+ ?assertEqual(2, n_notifiers()),
+
+ ?assertMatch(ok, config:subscribe_for_changes(all)),
+
+ ?assertEqual(2, n_notifiers()),
+ ok
+ end).
+
+should_enable_features() ->
+ [config:disable_feature(F) || F <- config:features()],
+ ?assertEqual([], config:features()),
+
+ ?assertEqual(ok, config:enable_feature(snek)),
+ ?assertEqual([snek], config:features()),
+
+ ?assertEqual(ok, config:enable_feature(snek)),
+ ?assertEqual([snek], config:features()),
+
+ ?assertEqual(ok, config:enable_feature(dogo)),
+ ?assertEqual([dogo, snek], config:features()).
+
+should_disable_features() ->
+ [config:disable_feature(F) || F <- config:features()],
+ ?assertEqual([], config:features()),
+
+ config:enable_feature(snek),
+ ?assertEqual([snek], config:features()),
+
+ ?assertEqual(ok, config:disable_feature(snek)),
+ ?assertEqual([], config:features()),
+
+ ?assertEqual(ok, config:disable_feature(snek)),
+ ?assertEqual([], config:features()).
+
+should_keep_features_on_config_restart() ->
+ [config:disable_feature(F) || F <- config:features()],
+ ?assertEqual([], config:features()),
+
+ config:enable_feature(snek),
+ ?assertEqual([snek], config:features()),
+ with_process_restart(config),
+ ?assertEqual([snek], config:features()).
+
+should_notify_on_config_reload(Subscription, {_Apps, Pid}) ->
+ {
+ to_string(Subscription),
+ ?_test(begin
+ ?assertEqual(ok, config:set("section_foo", "key_bar", "any", true)),
+ ?assertEqual({config_change, "section_foo", "key_bar", "any", true}, getmsg(Pid)),
+ ?assertEqual(ok, config:set("section_foo", "key_bar", "not_any", false)),
+ ?assertEqual({config_change, "section_foo", "key_bar", "not_any", false}, getmsg(Pid)),
+ ?assertEqual(ok, config:reload()),
+ ?assertEqual({config_change, "section_foo", "key_bar", "any", true}, getmsg(Pid)),
+ ok
+ end)
+ }.
+
+should_notify_on_config_reload_flush(Subscription, {_Apps, Pid}) ->
+ {
+ to_string(Subscription),
+ ?_test(begin
+ ?assertEqual(ok, config:set("section_foo_temp", "key_bar", "any", false)),
+ ?assertEqual({config_change, "section_foo_temp", "key_bar", "any", false}, getmsg(Pid)),
+ ?assertEqual(ok, config:reload()),
+ ?assertEqual(
+ {config_change, "section_foo_temp", "key_bar", deleted, true}, getmsg(Pid)
+ ),
+ ok
+ end)
+ }.
+
+spawn_config_listener() ->
+ Self = self(),
+ Pid = erlang:spawn(fun() ->
+ ok = config:listen_for_changes(?MODULE, {self(), undefined}),
+ Self ! registered,
+ loop(undefined)
+ end),
+ receive
+ registered -> ok
+ after ?TIMEOUT ->
+ erlang:error({timeout, config_handler_register})
+ end,
+ Pid.
+
+spawn_config_notifier(Subscription) ->
+ Self = self(),
+ Pid = erlang:spawn(fun() ->
+ ok = config:subscribe_for_changes(Subscription),
+ Self ! registered,
+ loop(undefined)
+ end),
+ receive
+ registered -> ok
+ after ?TIMEOUT ->
+ erlang:error({timeout, config_handler_register})
+ end,
+ Pid.
+
+loop(undefined) ->
+ receive
+ {config_msg, _} = Msg ->
+ loop(Msg);
+ {config_change, _, _, _, _} = Msg ->
+ loop({config_msg, Msg});
+ {get_msg, _, _} = Msg ->
+ loop(Msg);
+ Msg ->
+ erlang:error({invalid_message, Msg})
+ end;
+loop({get_msg, From, Ref}) ->
+ receive
+ {config_msg, _} = Msg ->
+ From ! {Ref, Msg};
+ {config_change, _, _, _, _} = Msg ->
+ From ! {Ref, Msg};
+ Msg ->
+ erlang:error({invalid_message, Msg})
+ end,
+ loop(undefined);
+loop({config_msg, _} = Msg) ->
+ receive
+ {get_msg, From, Ref} ->
+ From ! {Ref, Msg};
+ Msg ->
+ erlang:error({invalid_message, Msg})
+ end,
+ loop(undefined).
+
+getmsg(Pid) ->
+ Ref = erlang:make_ref(),
+ Pid ! {get_msg, self(), Ref},
+ receive
+ {Ref, {config_msg, Msg}} -> Msg
+ after ?TIMEOUT ->
+ erlang:error({timeout, config_msg})
+ end.
+
+n_handlers() ->
+ Handlers = gen_event:which_handlers(config_event),
+ length([Pid || {config_listener, {?MODULE, Pid}} <- Handlers]).
+
+n_notifiers() ->
+ Handlers = gen_event:which_handlers(config_event),
+ length([Pid || {config_notifier, Pid} <- Handlers]).
+
+to_string(Term) ->
+ lists:flatten(io_lib:format("~p", [Term])).
+
+with_process_restart(Name) ->
+ ok = stop_sync(whereis(Name), ?TIMEOUT),
+ Now = now_us(),
+ wait_process_restart(
+ Name, ?RESTART_TIMEOUT_IN_MILLISEC * 1000, 50, Now, Now
+ ).
+
+wait_process_restart(_Name, Timeout, _Delay, Started, Prev) when
+ Prev - Started > Timeout
+->
+ timeout;
+wait_process_restart(Name, Timeout, Delay, Started, _Prev) ->
+ case whereis(Name) of
+ undefined ->
+ ok = timer:sleep(Delay),
+ wait_process_restart(Name, Timeout, Delay, Started, now_us());
+ Pid ->
+ Pid
+ end.
+
+stop_sync(Pid, Timeout) when is_pid(Pid) ->
+ MRef = erlang:monitor(process, Pid),
+ try
+ begin
+ catch unlink(Pid),
+ exit(Pid, kill),
+ receive
+ {'DOWN', MRef, _, _, _} ->
+ ok
+ after Timeout ->
+ timeout
+ end
+ end
+ after
+ erlang:demonitor(MRef, [flush])
+ end;
+stop_sync(_, _) ->
+ error(badarg).
+
+now_us() ->
+ {MegaSecs, Secs, MicroSecs} = os:timestamp(),
+ (MegaSecs * 1000000 + Secs) * 1000000 + MicroSecs.
diff --git a/src/config/test/fixtures/config_default_test.ini b/src/config/test/fixtures/config_default_test.ini
new file mode 100644
index 000000000..bac4405e5
--- /dev/null
+++ b/src/config/test/fixtures/config_default_test.ini
@@ -0,0 +1,23 @@
+; Licensed to the Apache Software Foundation (ASF) under one
+; or more contributor license agreements. See the NOTICE file
+; distributed with this work for additional information
+; regarding copyright ownership. The ASF licenses this file
+; 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.
+
+[couchdb]
+max_dbs_open = 500
+
+[httpd]
+port = 5986
+bind_address = 127.0.0.1
diff --git a/src/config/test/fixtures/config_tests_1.ini b/src/config/test/fixtures/config_tests_1.ini
new file mode 100644
index 000000000..55451dade
--- /dev/null
+++ b/src/config/test/fixtures/config_tests_1.ini
@@ -0,0 +1,22 @@
+; Licensed to the Apache Software Foundation (ASF) under one
+; or more contributor license agreements. See the NOTICE file
+; distributed with this work for additional information
+; regarding copyright ownership. The ASF licenses this file
+; 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.
+
+[couchdb]
+max_dbs_open=10
+
+[httpd]
+port=4895
diff --git a/src/config/test/fixtures/config_tests_2.ini b/src/config/test/fixtures/config_tests_2.ini
new file mode 100644
index 000000000..5f46357f5
--- /dev/null
+++ b/src/config/test/fixtures/config_tests_2.ini
@@ -0,0 +1,22 @@
+; Licensed to the Apache Software Foundation (ASF) under one
+; or more contributor license agreements. See the NOTICE file
+; distributed with this work for additional information
+; regarding copyright ownership. The ASF licenses this file
+; 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.
+
+[httpd]
+port = 80
+
+[fizbang]
+unicode = normalized
diff --git a/src/config/test/fixtures/default.d/extra.ini b/src/config/test/fixtures/default.d/extra.ini
new file mode 100644
index 000000000..fda68b32c
--- /dev/null
+++ b/src/config/test/fixtures/default.d/extra.ini
@@ -0,0 +1,19 @@
+; Licensed to the Apache Software Foundation (ASF) under one
+; or more contributor license agreements. See the NOTICE file
+; distributed with this work for additional information
+; regarding copyright ownership. The ASF licenses this file
+; 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.
+
+[couchdb]
+max_dbs_open=11
diff --git a/src/config/test/fixtures/local.d/extra.ini b/src/config/test/fixtures/local.d/extra.ini
new file mode 100644
index 000000000..d6a6d4661
--- /dev/null
+++ b/src/config/test/fixtures/local.d/extra.ini
@@ -0,0 +1,19 @@
+; Licensed to the Apache Software Foundation (ASF) under one
+; or more contributor license agreements. See the NOTICE file
+; distributed with this work for additional information
+; regarding copyright ownership. The ASF licenses this file
+; 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.
+
+[couchdb]
+max_dbs_open=12